From 00237b876beef06c7e40ed3b6ee2783ed7d31293 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:55:37 +0800 Subject: [PATCH 001/118] build: compile plugin sources into existing lib paths --- .github/workflows/ci.yml | 30 ++---- .github/workflows/release.yml | 10 +- .gitignore | 3 + AGENTS.md | 98 ++++++++++++++++++ docs/CHANGELOG.md | 9 ++ docs/CONTRIBUTING.md | 40 ++++++- ...41\347\214\256\346\214\207\345\215\227.md" | 27 ++++- package-lock.json | 15 +++ package.json | 8 +- scripts/build.mjs | 50 +++++++++ scripts/check-workflows.mjs | 23 ++++ scripts/verify-gate.mjs | 2 + {lib => src}/authority-paths.js | 0 {lib => src}/bash-ipc.js | 0 {lib => src}/bash-pe.js | 0 {lib => src}/cli/provision-shells.js | 0 {lib => src}/cli/report.js | 0 {lib => src}/cli/uninstall.js | 0 {lib => src}/components/background/icon.png | Bin {lib => src}/components/background/index.js | 0 .../components/background/locale/en.json | 0 .../components/background/locale/zh.json | 0 .../components/background/package.json | 0 {lib => src}/components/file/icon.png | Bin {lib => src}/components/file/index.js | 0 {lib => src}/components/file/locale/en.json | 0 {lib => src}/components/file/locale/zh.json | 0 {lib => src}/components/file/package.json | 0 .../components/hide-official/icon.png | Bin .../components/hide-official/index.js | 0 .../components/hide-official/locale/en.json | 0 .../components/hide-official/locale/zh.json | 0 .../components/hide-official/package.json | 0 {lib => src}/components/panel/icon.png | Bin {lib => src}/components/panel/index.js | 0 {lib => src}/components/panel/locale/en.json | 0 {lib => src}/components/panel/locale/zh.json | 0 {lib => src}/components/panel/package.json | 0 {lib => src}/components/ptc/icon.png | Bin {lib => src}/components/ptc/index.js | 0 {lib => src}/components/ptc/locale/en.json | 0 {lib => src}/components/ptc/locale/zh.json | 0 {lib => src}/components/ptc/package.json | 0 {lib => src}/components/shell/icon.png | Bin {lib => src}/components/shell/index.js | 0 {lib => src}/components/shell/locale/en.json | 0 {lib => src}/components/shell/locale/zh.json | 0 {lib => src}/components/shell/package.json | 0 {lib => src}/components/tui/dsh-plugin.json | 0 {lib => src}/components/tui/icon.png | Bin {lib => src}/components/tui/index.js | 0 {lib => src}/components/tui/locale/en.json | 0 {lib => src}/components/tui/locale/zh.json | 0 {lib => src}/components/tui/package.json | 0 {lib => src}/config-updates.js | 0 {lib => src}/config.js | 0 {lib => src}/credential-policy.js | 0 {lib => src}/file/contract.js | 0 {lib => src}/file/runtime.js | 0 {lib => src}/file/worker.js | 0 {lib => src}/index.js | 0 {lib => src}/official-tool-catalog.js | 0 {lib => src}/official-tool-hiding.js | 0 {lib => src}/panel/collect.js | 0 {lib => src}/panel/panel.html | 0 {lib => src}/panel/render.js | 0 {lib => src}/paths.js | 0 {lib => src}/permissions.js | 0 {lib => src}/policy.js | 0 {lib => src}/profile-install.js | 0 {lib => src}/ptc/contract.js | 0 {lib => src}/ptc/index.js | 0 {lib => src}/ptc/runtime.js | 0 {lib => src}/ptc/worker.js | 0 {lib => src}/rust.js | 0 {lib => src}/session-pwsh.js | 0 {lib => src}/session-shells.js | 0 {lib => src}/session-temp.js | 0 {lib => src}/shells.js | 0 {lib => src}/shells/argument-error.js | 0 {lib => src}/shells/constants.js | 0 {lib => src}/shells/paths.js | 0 {lib => src}/shells/pins.js | 0 {lib => src}/shells/publish.js | 0 {lib => src}/shells/resolve.js | 0 {lib => src}/shells/spawn-error.js | 0 {lib => src}/shells/tool.js | 0 {lib => src}/teardown.js | 0 {lib => src}/tui/activity.js | 0 {lib => src}/tui/attention.js | 0 {lib => src}/tui/panel.js | 0 {lib => src}/tui/result.js | 0 {lib => src}/workspace-preparation.js | 0 test/build.test.mjs | 97 +++++++++++++++++ test/package-source.test.mjs | 9 +- tsconfig.json | 23 ++++ 96 files changed, 408 insertions(+), 36 deletions(-) create mode 100644 AGENTS.md create mode 100644 scripts/build.mjs rename {lib => src}/authority-paths.js (100%) rename {lib => src}/bash-ipc.js (100%) rename {lib => src}/bash-pe.js (100%) rename {lib => src}/cli/provision-shells.js (100%) rename {lib => src}/cli/report.js (100%) rename {lib => src}/cli/uninstall.js (100%) rename {lib => src}/components/background/icon.png (100%) rename {lib => src}/components/background/index.js (100%) rename {lib => src}/components/background/locale/en.json (100%) rename {lib => src}/components/background/locale/zh.json (100%) rename {lib => src}/components/background/package.json (100%) rename {lib => src}/components/file/icon.png (100%) rename {lib => src}/components/file/index.js (100%) rename {lib => src}/components/file/locale/en.json (100%) rename {lib => src}/components/file/locale/zh.json (100%) rename {lib => src}/components/file/package.json (100%) rename {lib => src}/components/hide-official/icon.png (100%) rename {lib => src}/components/hide-official/index.js (100%) rename {lib => src}/components/hide-official/locale/en.json (100%) rename {lib => src}/components/hide-official/locale/zh.json (100%) rename {lib => src}/components/hide-official/package.json (100%) rename {lib => src}/components/panel/icon.png (100%) rename {lib => src}/components/panel/index.js (100%) rename {lib => src}/components/panel/locale/en.json (100%) rename {lib => src}/components/panel/locale/zh.json (100%) rename {lib => src}/components/panel/package.json (100%) rename {lib => src}/components/ptc/icon.png (100%) rename {lib => src}/components/ptc/index.js (100%) rename {lib => src}/components/ptc/locale/en.json (100%) rename {lib => src}/components/ptc/locale/zh.json (100%) rename {lib => src}/components/ptc/package.json (100%) rename {lib => src}/components/shell/icon.png (100%) rename {lib => src}/components/shell/index.js (100%) rename {lib => src}/components/shell/locale/en.json (100%) rename {lib => src}/components/shell/locale/zh.json (100%) rename {lib => src}/components/shell/package.json (100%) rename {lib => src}/components/tui/dsh-plugin.json (100%) rename {lib => src}/components/tui/icon.png (100%) rename {lib => src}/components/tui/index.js (100%) rename {lib => src}/components/tui/locale/en.json (100%) rename {lib => src}/components/tui/locale/zh.json (100%) rename {lib => src}/components/tui/package.json (100%) rename {lib => src}/config-updates.js (100%) rename {lib => src}/config.js (100%) rename {lib => src}/credential-policy.js (100%) rename {lib => src}/file/contract.js (100%) rename {lib => src}/file/runtime.js (100%) rename {lib => src}/file/worker.js (100%) rename {lib => src}/index.js (100%) rename {lib => src}/official-tool-catalog.js (100%) rename {lib => src}/official-tool-hiding.js (100%) rename {lib => src}/panel/collect.js (100%) rename {lib => src}/panel/panel.html (100%) rename {lib => src}/panel/render.js (100%) rename {lib => src}/paths.js (100%) rename {lib => src}/permissions.js (100%) rename {lib => src}/policy.js (100%) rename {lib => src}/profile-install.js (100%) rename {lib => src}/ptc/contract.js (100%) rename {lib => src}/ptc/index.js (100%) rename {lib => src}/ptc/runtime.js (100%) rename {lib => src}/ptc/worker.js (100%) rename {lib => src}/rust.js (100%) rename {lib => src}/session-pwsh.js (100%) rename {lib => src}/session-shells.js (100%) rename {lib => src}/session-temp.js (100%) rename {lib => src}/shells.js (100%) rename {lib => src}/shells/argument-error.js (100%) rename {lib => src}/shells/constants.js (100%) rename {lib => src}/shells/paths.js (100%) rename {lib => src}/shells/pins.js (100%) rename {lib => src}/shells/publish.js (100%) rename {lib => src}/shells/resolve.js (100%) rename {lib => src}/shells/spawn-error.js (100%) rename {lib => src}/shells/tool.js (100%) rename {lib => src}/teardown.js (100%) rename {lib => src}/tui/activity.js (100%) rename {lib => src}/tui/attention.js (100%) rename {lib => src}/tui/panel.js (100%) rename {lib => src}/tui/result.js (100%) rename {lib => src}/workspace-preparation.js (100%) create mode 100644 test/build.test.mjs create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e36058e8..ec5db2e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,15 +2,15 @@ name: Cross-platform validation on: # A merge must validate itself. Without this, a branch was only ever checked when someone # remembered to dispatch the workflow by hand — which is how a stale number shipped in #134. - # `0.3.2` is this cycle's release branch: what lands there is what ships, so it validates on - # push exactly like `main`. + # Release branches validate on push exactly like `main`; each 0.4.0 slice lands on its + # staging branch before the final release PR. push: - branches: [main, 0.3.2] + branches: [main, 0.3.2, release/0.4.0] # And a pull request has to show its result before it is merged, not after. GitHub reads this # file from the pull request's own head, so listing the release branch here is what makes a - # pull request against `0.3.2` run the gate at all. + # pull request against a release branch run the gate at all. pull_request: - branches: [main, 0.3.2] + branches: [main, 0.3.2, release/0.4.0] # A merge queue builds the queued commits before they reach `main`, and it only accepts a group # whose required checks passed there. Without this trigger the workflow simply never runs for a # queued group, so enabling a merge queue would silently un-gate every merge. @@ -79,18 +79,11 @@ jobs: run: sudo apt-get update && sudo apt-get install -y acl - name: Install locked dependencies run: npm ci --ignore-scripts --registry=https://registry.npmjs.org - - name: Build and test native worker - shell: bash - run: | - cargo test --locked --manifest-path native/Cargo.toml - npm run build:ptc - - name: Windows IPC build - if: runner.os == 'Windows' - run: npm run build:bash-ipc # The gate, not `npm run verify`: it runs the same suites with `DSH_OPS_REQUIRE_RUNTIME=1` # on POSIX (a runtime-absence skip is a failure, not a pass), refuses any skip outside this # platform's known set, fails when a suite file on disk was never collected, and checks the - # packed artifact. Build success is not behaviour. + # packed artifact. It builds the plugin before its CLI, then builds/tests the worker and + # Windows IPC payloads. Build success is not behaviour. - name: Functional gate (skips, suite inventory, packed artifact) shell: bash run: node scripts/verify-gate.mjs --skip-install @@ -136,6 +129,8 @@ jobs: restore-keys: ops-031-${{ runner.os }}-${{ runner.arch }}- - name: Install locked dependencies run: npm ci --ignore-scripts --registry=https://registry.npmjs.org + - name: Build the plugin + run: npm run build - name: Build the native worker shell: bash run: npm run build:ptc @@ -186,6 +181,8 @@ jobs: restore-keys: ops-031-${{ runner.os }}-${{ runner.arch }}- - name: Install locked dependencies run: npm ci --ignore-scripts --registry=https://registry.npmjs.org + - name: Build the plugin + run: npm run build - name: Build the native worker shell: bash run: npm run build:ptc @@ -276,11 +273,6 @@ jobs: run: rustup toolchain install 1.97.1 --profile minimal && rustup default 1.97.1 - name: Install locked dependencies run: npm ci --ignore-scripts --registry=https://registry.npmjs.org - - name: Build and test native worker - shell: bash - run: | - cargo test --locked --manifest-path native/Cargo.toml - npm run build:ptc - name: Functional gate (skips, suite inventory, packed artifact) shell: bash run: node scripts/verify-gate.mjs --skip-install diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e92773b..d533c4d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,13 +82,13 @@ jobs: run: rustup target add ${{ matrix.triple }} - name: Install locked dependencies - # Only the Windows job needs node_modules: the IPC proxies link against the - # pinned shell payload. The other two targets build from the committed Rust - # sources alone, so they skip the install. `--ignore-scripts` keeps an install - # hook out of the release path, matching ci.yml. - if: matrix.target == 'win32-x64' + # Every target builds the host JS consumed by the runtime staging CLI. + # Windows also needs the pinned shell payload for its IPC proxies. run: npm ci --ignore-scripts --registry=https://registry.npmjs.org + - name: Build the plugin + run: npm run build + - name: Build the native worker shell: bash run: cargo build --release --locked --manifest-path native/Cargo.toml --target ${{ matrix.triple }} diff --git a/.gitignore b/.gitignore index 7f550f15..b4bf0160 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ native/target/ # Dependencies node_modules/ +# Compiled plugin source (edit src/ and run npm run build) +/lib/ + # Scratch trees used by the suites and by hand .tmp-* *.scratch.* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..27f0c19a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,98 @@ +# AGENTS.md + +Instructions for AI coding agents working in this repository. Humans: see +[docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) (中文:[docs/贡献指南.md](docs/贡献指南.md)), which this file +summarizes and never overrides. + +## What dsh-ops is + +A DSH tool-enhancement plugin. Its purpose is to save the model's tokens, contain context growth, +shorten task time and make the model more capable — by giving it low-cognitive-load tools with small +inputs/outputs, honest feedback that stops retry loops, and progressive disclosure of advanced +capabilities. + +- **Minimal and low-intrusion.** It only adds `ops_*` tools (including `ops_bash` on Windows) and can + optionally replace some official tools. It does not change host logic, rewrite the shared tool + registry, or depend on other plugins; integration code with other plugins lives apart from the core. +- **Native and PTC are separate projections of one kernel.** Native mode output is read by the model + (compact, line-numbered text); PTC output is read by sandboxed code (strict typed data). Do not + make one shape serve both. See issue #261 for the 0.4.0 design. + +## Hard rules + +1. **No code file over 1000 lines.** `node scripts/check-file-size.mjs` (`npm run check:size`) runs + in CI over every git-tracked `.js .mjs .cjs .ts .mts .cts .rs .ps1 .sh .py .cpp .h .hpp .cc` file + outside `node_modules/ native/target/ runtime/ licenses/ docs/ dist/`. When a file approaches the + limit, split it into modules and group them in a folder (see `src/shells/`, + `scripts/lib/build/`, `bench/lib/fixtures/`). Do not raise the limit or add exclusions. +2. **A PR adds at most ~1000 implementation lines** (tests and byte-identical moves excluded) and + touches only the files its issue names. Large work is split into slices on a staging branch + (for 0.4.0: `release/0.4.0`). +3. **No AI attribution** in commits, PRs, issues or comments: no `Co-authored-by:` lines naming an + agent/model/bot, no "Generated with/Made with" footers. Commits use the user's own identity. +4. **Every new behaviour has an assertion** that fails when the change is reverted. No silent skips, + no retries to hide flaky concurrency — fix the cause. +5. **Do not edit generated output.** `lib/` is built from `src/`; `runtime/` is staged worker output. + +## Layout + +| Path | Contents | +| --- | --- | +| `src/` | Plugin source (TypeScript and not-yet-migrated JavaScript). Compiled by `tsc` to `lib/` | +| `lib/` | Build output that is shipped and imported by tests — gitignored, never edited by hand | +| `src/components//` | One directory per DSH component row (`shell`, `file`, `ptc`, `panel`, `tui`, `hide-official`, …): entry, `package.json`, `locale/`, `icon.png` | +| `native/` | Rust worker (`dsh-ops-worker`); one JSON request on stdin, one JSON result on stdout, spawned per call through the host subprocess/sandbox | +| `test/` | One `*.test.mjs` file = one suite = one process; shared helpers in `test/lib/` (never named `*.test.mjs`) | +| `bench/` | Repository-only benchmarks; never shipped, never part of the default gate | +| `scripts/`, `bin/` | Gate, manifest validation, packaging, CLI | +| `docs/` | Bilingual docs: every document exists in English and Chinese | + +## Commands + +```console +npm ci --ignore-scripts # install (locked) +npm run build # src/ -> lib/ (tsc + assets) +cargo test --locked --manifest-path native/Cargo.toml # Rust unit tests +npm run build:ptc # build + stage the native worker (needed by most suites) +npm run build:bash-ipc # Windows only +npm run verify # build + manifest check + all suites +node test/run.mjs # only suites whose file name contains +npm run check:size # the 1000-line gate +node scripts/verify-gate.mjs --skip-install # the full release gate, exactly what CI runs +``` + +- Suites without a built worker **skip**; a skip is not a pass. On Linux/macOS the gate sets + `DSH_OPS_REQUIRE_RUNTIME=1`, which turns those skips into failures. +- `DSH_HOME` must point into the system temp directory when running a suite directly; the harness + refuses anything else. `node test/run.mjs` creates one per suite. + +## Code conventions + +- English for code, comments, CLI output and commit messages; user-facing docs in both languages, + updated in the same PR. +- Tool names always come from `publicToolName()` in `src/policy.js`; never hand-write `ops_`. +- Config is validated in `src/config.js`; unknown keys are errors that name the key. Defaults in + `cordis.patch.yml` must equal the defaults in code. +- The host half must not import `@deepseek-ai/*` values at load time; use `ctx.inject` / `ctx.get`. + Publish tools only through this plugin's own scoped `ctx.tools.register`. +- Tool failures are returned values (`status`, `error.code`, `effectState`), never thrown. Never + report a partial or early-stopped result as complete. +- Do not remove tool contracts (argument semantics, defaults, path base, paging completeness, + permission boundaries) to save schema bytes. +- New dependencies need a reason in the PR, an exact version published at least 7 days earlier, and + no change to security settings. + +## Commits and PRs + +- Conventional, imperative English subjects, e.g. `fix(file): classify oversized grep files`. +- A PR links a claimed issue, fills `.github/PULL_REQUEST_TEMPLATE.md`, and puts real command output + (pass/skip/fail counts) in its Validation section. +- Add a `docs/CHANGELOG.md` `## [Unreleased]` entry when behaviour or shipped docs change. +- Never force-push shared branches, rewrite pushed history, or merge PRs without the maintainers. + +## Windows notes + +- Run `npm`/`git` from PowerShell if the Git Bash environment resolves `npm` to WSL or lacks the git + identity/CA bundle. +- A local `tar` may fail to unpack the packed tarball on non-ASCII doc filenames; CI is the reference + for that gate step. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7d16e646..b31e5efb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,15 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Build plugin source into the existing JavaScript package layout (#261) + +- Move plugin source to `src/` and introduce the TypeScript build with JavaScript migration + support. `lib/` becomes generated output; package exports and component entry paths stay the + same. Component assets are copied alongside the emitted JavaScript. +- Build before verification and packaging, and document the build step for direct suite runs + and source installations in both contributor guides. Published packages keep shipping `lib/` + without `src/`; this toolchain slice does not change tool behaviour. + ### File-tool discoverability, honest grep pages and compact inspect rendering - All eleven file argument schemas render concrete types in the pinned host SDK. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index d1ef7d75..f829652f 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -8,6 +8,7 @@ conventions a pull request is reviewed against. 中文版:[贡献指南.md]( - [What dsh-ops is (and is not)](#what-dsh-ops-is-and-is-not) - [Workflow: issue first, then PR](#workflow-issue-first-then-pr) - [Development environment](#development-environment) +- [Building the plugin and installing from source](#building-the-plugin-and-installing-from-source) - [Building the native worker](#building-the-native-worker) - [The verification gate](#the-verification-gate) - [Writing tests](#writing-tests) @@ -49,10 +50,40 @@ conventions a pull request is reviewed against. 中文版:[贡献指南.md]( ```console npm ci --ignore-scripts +npm run build # compile src/ to lib/ and copy component assets npm run build:ptc # build and stage the native worker (see below) npm run verify ``` +## Building the plugin and installing from source + +Edit the plugin in `src/`. The TypeScript compiler accepts the existing JavaScript during the +migration and emits JavaScript into `lib/`; `npm run build` recreates that directory and copies +component manifests, locale files, icons and the panel HTML. `lib/` is ignored by Git and must not +be edited by hand. Package exports, component entries and tests continue to load `lib/`. + +Run `npm run build` after changing source or assets and before invoking a suite directly with +`node test/run.mjs `. `npm test`, `npm run test:ptc` and `npm run verify` build first; +`npm run typecheck` checks the compiler configuration and migrated TypeScript without emitting +files. JavaScript type checking remains disabled during the migration. + +For a local or Git-source installation, build and pack a checkout before installing it into DSH: + +```console +npm ci --ignore-scripts +npm run build +npm run build:ptc +npm pack +``` + +On Windows, also run `npm run build:bash-ipc` before packing. Install the resulting +`dsh-ops-.tgz` with the DSH CLI for your target profile (see the README). `npm pack` runs +the build through `prepack`, unless lifecycle scripts are disabled. An unbuilt Git checkout has +no `lib/` and is not ready to load; there is no `prepare` hook. Keep development dependencies +installed while building, since they include the compiler. The published tarball contains +compiled `lib/` and its assets, excludes `src/`, and does not require consumers to compile the +plugin. + ## Building the native worker Most suites drive the real native worker. Without one they cannot answer, and they **skip**: @@ -77,7 +108,8 @@ What a skip means locally: ## The verification gate -- `npm run verify` — `scripts/validate-manifest.mjs`, then `test/run.mjs` (every `test/*.test.mjs` +- `npm run verify` — build `src/` into `lib/`, run `scripts/validate-manifest.mjs`, then + `test/run.mjs` (every `test/*.test.mjs` in its own process with its own temporary `DSH_HOME`). - `node scripts/verify-gate.mjs --skip-install` — **the release gate**, exactly what `.github/workflows/ci.yml` runs on Windows x64, Linux x64 and macOS arm64: worker unit tests, build @@ -112,11 +144,11 @@ CI runs on every pull request and every push to `main`; a PR must be green on al - **Doc pairs:** each document in `docs/` exists in English and Chinese, e.g. `docs/usage.md` and `docs/使用指南.md`. Update both in the same PR. - **File size:** keep new files under about 500 lines. If a file grows past that, split it into - modules and group related modules in a folder, following the existing `lib/components/*` and + modules and group related modules in a folder, following the existing `src/components/*` and `test/lib/` layout. -- **Tool names** always come from `publicToolName()` in `lib/policy.js`; never hand-write the `ops_` +- **Tool names** always come from `publicToolName()` in `src/policy.js`; never hand-write the `ops_` prefix. -- **Config validation** lives in `lib/config.js`; an unknown key is an error that names the key. +- **Config validation** lives in `src/config.js`; an unknown key is an error that names the key. Defaults in `cordis.patch.yml` must match the defaults in code. - **Parsing and probing** happen synchronously in `apply`; `required: true` must refuse activation at load time. diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 48bfab0b..0b841279 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -7,6 +7,7 @@ - [dsh-ops 是什么(以及不是什么)](#dsh-ops-是什么以及不是什么) - [工作流程:先 issue,后 PR](#工作流程先-issue后-pr) - [开发环境](#开发环境) +- [构建插件与源码安装](#构建插件与源码安装) - [构建原生 worker](#构建原生-worker) - [验证门禁](#验证门禁) - [编写测试](#编写测试) @@ -39,10 +40,28 @@ ```console npm ci --ignore-scripts +npm run build # 将 src/ 编译到 lib/ 并复制组件资源 npm run build:ptc # 构建并暂存原生 worker(见下) npm run verify ``` +## 构建插件与源码安装 + +插件源码在 `src/` 中修改。迁移期间 TypeScript 编译器兼容现有 JavaScript,并将 JavaScript 产物输出到 `lib/`;`npm run build` 会重建该目录,并复制组件 manifest、语言文件、图标和面板 HTML。`lib/` 已被 Git 忽略,不得手动修改。包导出、组件入口和测试仍然加载 `lib/`。 + +修改源码或资源后,先运行 `npm run build`,再通过 `node test/run.mjs <过滤词>` 直接运行套件。`npm test`、`npm run test:ptc` 与 `npm run verify` 会先构建;`npm run typecheck` 检查编译器配置和已迁移的 TypeScript,不生成产物。迁移期间暂不启用 JavaScript 类型检查。 + +从本地目录或 Git 源码安装时,先在检出的仓库中构建并打包,再安装到 DSH: + +```console +npm ci --ignore-scripts +npm run build +npm run build:ptc +npm pack +``` + +Windows 上还需在打包前运行 `npm run build:bash-ipc`。使用目标 profile 对应的 DSH CLI 安装生成的 `dsh-ops-.tgz`(见 README)。除非禁用生命周期脚本,`npm pack` 会通过 `prepack` 执行构建。未构建的 Git 检出没有 `lib/`,不能直接加载;项目没有 `prepare` 钩子。构建时须保留开发依赖,其中包含编译器。发布包包含编译后的 `lib/` 及其资源,不包含 `src/`,使用者无需编译插件。 + ## 构建原生 worker 大多数套件驱动的是真实的原生 worker;没有 worker 时它们无法作答,会**跳过**: @@ -62,7 +81,7 @@ npm run build:bash-ipc # 仅 Windows:Bash IPC ## 验证门禁 -- `npm run verify` —— 先 `scripts/validate-manifest.mjs`,再 `test/run.mjs`(每个 `test/*.test.mjs` 独立进程、独立临时 `DSH_HOME`)。 +- `npm run verify` —— 先将 `src/` 构建到 `lib/`,再运行 `scripts/validate-manifest.mjs` 和 `test/run.mjs`(每个 `test/*.test.mjs` 独立进程、独立临时 `DSH_HOME`)。 - `node scripts/verify-gate.mjs --skip-install` —— **发布门禁**,即 `.github/workflows/ci.yml` 在 Windows x64、Linux x64、macOS arm64 上运行的同一脚本:worker 单元测试、构建与暂存、manifest 下限、全部套件、允许跳过的集合、磁盘上的套件清单,以及打包产物。 - `node test/run.mjs <过滤词>` 只运行文件名包含该词的套件,例如 `node test/run.mjs mount file-operations`。 @@ -80,9 +99,9 @@ npm run build:bash-ipc # 仅 Windows:Bash IPC - **语言**:代码、注释、CLI 输出、提交信息一律英文;对外文档中英双语;面向维护者的说明可用中文。 - **文档成对**:`docs/` 下同一文档中英各一份,例如 `docs/usage.md` 与 `docs/使用指南.md`,同一个 PR 里一起更新。 -- **文件规模**:新文件保持在约 500 行以内。文件超过这个规模时,拆成模块,并把相关模块归到一个文件夹中,参照现有的 `lib/components/*` 与 `test/lib/` 布局。 -- **工具名**:一律经 `lib/policy.js` 的 `publicToolName()` 生成,不要手写 `ops_` 前缀。 -- **配置校验**:放在 `lib/config.js`,未知键必须报错并点名;`cordis.patch.yml` 里的默认值必须与代码默认值一致。 +- **文件规模**:新文件保持在约 500 行以内。文件超过这个规模时,拆成模块,并把相关模块归到一个文件夹中,参照现有的 `src/components/*` 与 `test/lib/` 布局。 +- **工具名**:一律经 `src/policy.js` 的 `publicToolName()` 生成,不要手写 `ops_` 前缀。 +- **配置校验**:放在 `src/config.js`,未知键必须报错并点名;`cordis.patch.yml` 里的默认值必须与代码默认值一致。 - **解析与探测**:同步放在 `apply` 里;`required: true` 必须在加载期拒绝激活。 ## 提交与 PR diff --git a/package-lock.json b/package-lock.json index c4694cb9..256ea210 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "@deepseek-ai/dsh-tool-fs-search": "0.2.0-rc.2", "@deepseek-ai/dsh-tools": "0.2.0-rc.2", "@dsh-std/manifest": "0.1.1-rc.3", + "typescript": "6.0.3", "yaml": "^2.9.1" }, "engines": { @@ -1317,6 +1318,20 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", diff --git a/package.json b/package.json index 9a405086..942036b4 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,11 @@ "native/bash-ipc" ], "scripts": { + "build": "node scripts/build.mjs", + "prepack": "node scripts/build.mjs", + "pretest": "node scripts/build.mjs", + "pretest:ptc": "node scripts/build.mjs", + "typecheck": "tsc -p . --noEmit", "build:bash-ipc": "node bin/build-bash-ipc.mjs", "build:ptc": "node bin/dsh-ops.mjs build-ptc", "stage:ptc": "node bin/native-runtime.mjs stage", @@ -118,7 +123,7 @@ "check:size": "node scripts/check-file-size.mjs", "test": "node test/run.mjs", "verify:release": "npm run verify && node bin/check-release.mjs", - "verify": "node scripts/validate-manifest.mjs && node test/run.mjs", + "verify": "node scripts/build.mjs && node scripts/validate-manifest.mjs && node test/run.mjs", "bench:cli": "node -e \"const fs=require('node:fs');if(!fs.existsSync('bench/cli.mjs')){console.error('bench/ is repository-only and never shipped in the npm package (bench 只存在于仓库,不随包发布); run npm run bench:* from a dsh-ops checkout');process.exit(2)};process.exit(require('node:child_process').spawnSync(process.execPath,['bench/cli.mjs',...process.argv.slice(1)],{stdio:'inherit'}).status??1)\" --", "bench:setup": "node -e \"const fs=require('node:fs');if(!fs.existsSync('bench/cli.mjs')){console.error('bench/ is repository-only and never shipped in the npm package (bench 只存在于仓库,不随包发布); run npm run bench:* from a dsh-ops checkout');process.exit(2)};process.exit(require('node:child_process').spawnSync(process.execPath,['bench/cli.mjs','setup',...process.argv.slice(1)],{stdio:'inherit'}).status??1)\" --", "bench:ab": "node -e \"const fs=require('node:fs');if(!fs.existsSync('bench/cli.mjs')){console.error('bench/ is repository-only and never shipped in the npm package (bench 只存在于仓库,不随包发布); run npm run bench:* from a dsh-ops checkout');process.exit(2)};process.exit(require('node:child_process').spawnSync(process.execPath,['bench/cli.mjs','ab',...process.argv.slice(1)],{stdio:'inherit'}).status??1)\" --", @@ -156,6 +161,7 @@ "@deepseek-ai/dsh-tool-fs-search": "0.2.0-rc.2", "@deepseek-ai/dsh-tools": "0.2.0-rc.2", "@dsh-std/manifest": "0.1.1-rc.3", + "typescript": "6.0.3", "yaml": "^2.9.1" }, "engines": { diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 00000000..c5293885 --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** Compile source to the published lib paths, then copy component and panel assets. */ +import { spawnSync } from 'node:child_process' +import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const source = path.join(root, 'src') +const output = path.join(root, 'lib') +const compiler = path.join(root, 'node_modules/typescript/lib/tsc.js') +const codeFile = /\.(?:[cm]?[jt]s|[jt]sx)$/i + +function copyAssets(directory) { + let count = 0 + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const from = path.join(directory, entry.name) + if (entry.isDirectory()) count += copyAssets(from) + else if (entry.isFile() && !codeFile.test(entry.name)) { + const to = path.join(output, path.relative(source, from)) + mkdirSync(path.dirname(to), { recursive: true }) + copyFileSync(from, to) + count++ + } else if (!entry.isFile()) { + throw new Error(`Unsupported source entry: ${path.relative(root, from)}`) + } + } + return count +} + +try { + if (!existsSync(compiler)) throw new Error('TypeScript is missing; run npm ci --ignore-scripts first.') + // Both paths are fixed children of this checkout. Remove only generated output so deleted + // source modules cannot remain importable after a successful rebuild. + rmSync(output, { recursive: true, force: true }) + const result = spawnSync(process.execPath, [compiler, '-p', path.join(root, 'tsconfig.json')], { + cwd: root, stdio: 'inherit', windowsHide: true, + }) + if (result.error) throw result.error + if (result.status !== 0) { + process.exitCode = result.status ?? 1 + console.error('build: TypeScript compilation failed; assets were not copied.') + } else { + const assets = copyAssets(source) + console.log(`build: compiled src/ to lib/ and copied ${assets} assets`) + } +} catch (error) { + console.error(`build: ${error.message}`) + process.exitCode = 1 +} diff --git a/scripts/check-workflows.mjs b/scripts/check-workflows.mjs index 6c46ae53..2b3e0209 100644 --- a/scripts/check-workflows.mjs +++ b/scripts/check-workflows.mjs @@ -16,6 +16,24 @@ const notes = [] const failures = [] const check = (ok, message) => (ok ? notes.push(message) : failures.push(message)) +// These entry points import generated lib modules. A fresh checkout must build before +// reaching them; the release gate and verify commands perform that build themselves. +function checkPluginBuild(file, jobs, ids) { + for (const id of ids) { + const commands = (jobs[id]?.steps ?? []).flatMap(step => String(step.run ?? '').split(/\r?\n/)) + let built = false + const premature = [] + for (const command of commands) { + if (/^\s*npm run (?:build|verify(?::release)?)(?:\s|$)/.test(command) + || /^\s*node scripts\/verify-gate\.mjs(?:\s|$)/.test(command)) built = true + const consumesPlugin = /^\s*(?:node (?:bin\/|scripts\/(?:tools-trial|validate-manifest|build-platform-packages)\.mjs|test\/run\.mjs|bench\/cli\.mjs)|npm run (?:build:|stage:|check:ptc-runtime|validate:manifest))/.test(command) + if (consumesPlugin && !built) premature.push(command.trim()) + } + check(built, `${file}: job ${id} builds the plugin directly or through verification`) + check(premature.length === 0, `${file}: job ${id} builds before lib consumers (${premature.join('; ') || 'ordered'})`) + } +} + const files = (await readdir(dir).catch(() => [])).filter((name) => /\.ya?ml$/.test(name)).sort() check(files.length > 0, 'at least one workflow exists') @@ -53,7 +71,11 @@ if (!ci) { check(triggers?.push !== undefined, 'ci.yml: runs on push to the protected branch') check(triggers?.pull_request !== undefined, 'ci.yml: runs on pull_request before a merge') check(triggers?.merge_group !== undefined, 'ci.yml: runs on merge_group, so a merge queue can use it') + for (const event of ['push', 'pull_request']) { + check(triggers?.[event]?.branches?.includes('release/0.4.0'), `ci.yml: ${event} includes the 0.4.0 staging branch`) + } const jobs = ci.doc.jobs ?? {} + checkPluginBuild('ci.yml', jobs, ['validate', 'node-floor', 'tools-trial', 'bench-smoke']) const entry = Object.entries(jobs).find(([, job]) => job?.name === 'CI required') check(entry !== undefined, 'ci.yml: exposes one aggregate check named "CI required"') if (entry) { @@ -72,6 +94,7 @@ if (!ci) { // pinning the *new* shape is what makes this check harden the moment the rewrite lands. const release = workflows.get('release.yml') if (release) { + checkPluginBuild('release.yml', release.doc.jobs ?? {}, ['native', 'gate']) if (release.doc.jobs?.publish) { check(!/\$\{\{\s*secrets\./.test(release.text), 'release.yml: the gated publish path consumes no repository secrets') } else { diff --git a/scripts/verify-gate.mjs b/scripts/verify-gate.mjs index f2c4d3ec..668cbea7 100644 --- a/scripts/verify-gate.mjs +++ b/scripts/verify-gate.mjs @@ -179,6 +179,8 @@ async function main() { process.stdout.write('\n=== install locked dependencies\n(skipped: node_modules is present; pass --skip-install to make this explicit)\n') } + // The native build/staging CLI imports lib, just like the validator and suites. + await run('build the plugin', 'npm', ['run', 'build']) await run('native worker unit tests', 'cargo', ['test', '--locked', '--manifest-path', 'native/Cargo.toml']) await run('build and stage the native worker', 'npm', ['run', 'build:ptc']) diff --git a/lib/authority-paths.js b/src/authority-paths.js similarity index 100% rename from lib/authority-paths.js rename to src/authority-paths.js diff --git a/lib/bash-ipc.js b/src/bash-ipc.js similarity index 100% rename from lib/bash-ipc.js rename to src/bash-ipc.js diff --git a/lib/bash-pe.js b/src/bash-pe.js similarity index 100% rename from lib/bash-pe.js rename to src/bash-pe.js diff --git a/lib/cli/provision-shells.js b/src/cli/provision-shells.js similarity index 100% rename from lib/cli/provision-shells.js rename to src/cli/provision-shells.js diff --git a/lib/cli/report.js b/src/cli/report.js similarity index 100% rename from lib/cli/report.js rename to src/cli/report.js diff --git a/lib/cli/uninstall.js b/src/cli/uninstall.js similarity index 100% rename from lib/cli/uninstall.js rename to src/cli/uninstall.js diff --git a/lib/components/background/icon.png b/src/components/background/icon.png similarity index 100% rename from lib/components/background/icon.png rename to src/components/background/icon.png diff --git a/lib/components/background/index.js b/src/components/background/index.js similarity index 100% rename from lib/components/background/index.js rename to src/components/background/index.js diff --git a/lib/components/background/locale/en.json b/src/components/background/locale/en.json similarity index 100% rename from lib/components/background/locale/en.json rename to src/components/background/locale/en.json diff --git a/lib/components/background/locale/zh.json b/src/components/background/locale/zh.json similarity index 100% rename from lib/components/background/locale/zh.json rename to src/components/background/locale/zh.json diff --git a/lib/components/background/package.json b/src/components/background/package.json similarity index 100% rename from lib/components/background/package.json rename to src/components/background/package.json diff --git a/lib/components/file/icon.png b/src/components/file/icon.png similarity index 100% rename from lib/components/file/icon.png rename to src/components/file/icon.png diff --git a/lib/components/file/index.js b/src/components/file/index.js similarity index 100% rename from lib/components/file/index.js rename to src/components/file/index.js diff --git a/lib/components/file/locale/en.json b/src/components/file/locale/en.json similarity index 100% rename from lib/components/file/locale/en.json rename to src/components/file/locale/en.json diff --git a/lib/components/file/locale/zh.json b/src/components/file/locale/zh.json similarity index 100% rename from lib/components/file/locale/zh.json rename to src/components/file/locale/zh.json diff --git a/lib/components/file/package.json b/src/components/file/package.json similarity index 100% rename from lib/components/file/package.json rename to src/components/file/package.json diff --git a/lib/components/hide-official/icon.png b/src/components/hide-official/icon.png similarity index 100% rename from lib/components/hide-official/icon.png rename to src/components/hide-official/icon.png diff --git a/lib/components/hide-official/index.js b/src/components/hide-official/index.js similarity index 100% rename from lib/components/hide-official/index.js rename to src/components/hide-official/index.js diff --git a/lib/components/hide-official/locale/en.json b/src/components/hide-official/locale/en.json similarity index 100% rename from lib/components/hide-official/locale/en.json rename to src/components/hide-official/locale/en.json diff --git a/lib/components/hide-official/locale/zh.json b/src/components/hide-official/locale/zh.json similarity index 100% rename from lib/components/hide-official/locale/zh.json rename to src/components/hide-official/locale/zh.json diff --git a/lib/components/hide-official/package.json b/src/components/hide-official/package.json similarity index 100% rename from lib/components/hide-official/package.json rename to src/components/hide-official/package.json diff --git a/lib/components/panel/icon.png b/src/components/panel/icon.png similarity index 100% rename from lib/components/panel/icon.png rename to src/components/panel/icon.png diff --git a/lib/components/panel/index.js b/src/components/panel/index.js similarity index 100% rename from lib/components/panel/index.js rename to src/components/panel/index.js diff --git a/lib/components/panel/locale/en.json b/src/components/panel/locale/en.json similarity index 100% rename from lib/components/panel/locale/en.json rename to src/components/panel/locale/en.json diff --git a/lib/components/panel/locale/zh.json b/src/components/panel/locale/zh.json similarity index 100% rename from lib/components/panel/locale/zh.json rename to src/components/panel/locale/zh.json diff --git a/lib/components/panel/package.json b/src/components/panel/package.json similarity index 100% rename from lib/components/panel/package.json rename to src/components/panel/package.json diff --git a/lib/components/ptc/icon.png b/src/components/ptc/icon.png similarity index 100% rename from lib/components/ptc/icon.png rename to src/components/ptc/icon.png diff --git a/lib/components/ptc/index.js b/src/components/ptc/index.js similarity index 100% rename from lib/components/ptc/index.js rename to src/components/ptc/index.js diff --git a/lib/components/ptc/locale/en.json b/src/components/ptc/locale/en.json similarity index 100% rename from lib/components/ptc/locale/en.json rename to src/components/ptc/locale/en.json diff --git a/lib/components/ptc/locale/zh.json b/src/components/ptc/locale/zh.json similarity index 100% rename from lib/components/ptc/locale/zh.json rename to src/components/ptc/locale/zh.json diff --git a/lib/components/ptc/package.json b/src/components/ptc/package.json similarity index 100% rename from lib/components/ptc/package.json rename to src/components/ptc/package.json diff --git a/lib/components/shell/icon.png b/src/components/shell/icon.png similarity index 100% rename from lib/components/shell/icon.png rename to src/components/shell/icon.png diff --git a/lib/components/shell/index.js b/src/components/shell/index.js similarity index 100% rename from lib/components/shell/index.js rename to src/components/shell/index.js diff --git a/lib/components/shell/locale/en.json b/src/components/shell/locale/en.json similarity index 100% rename from lib/components/shell/locale/en.json rename to src/components/shell/locale/en.json diff --git a/lib/components/shell/locale/zh.json b/src/components/shell/locale/zh.json similarity index 100% rename from lib/components/shell/locale/zh.json rename to src/components/shell/locale/zh.json diff --git a/lib/components/shell/package.json b/src/components/shell/package.json similarity index 100% rename from lib/components/shell/package.json rename to src/components/shell/package.json diff --git a/lib/components/tui/dsh-plugin.json b/src/components/tui/dsh-plugin.json similarity index 100% rename from lib/components/tui/dsh-plugin.json rename to src/components/tui/dsh-plugin.json diff --git a/lib/components/tui/icon.png b/src/components/tui/icon.png similarity index 100% rename from lib/components/tui/icon.png rename to src/components/tui/icon.png diff --git a/lib/components/tui/index.js b/src/components/tui/index.js similarity index 100% rename from lib/components/tui/index.js rename to src/components/tui/index.js diff --git a/lib/components/tui/locale/en.json b/src/components/tui/locale/en.json similarity index 100% rename from lib/components/tui/locale/en.json rename to src/components/tui/locale/en.json diff --git a/lib/components/tui/locale/zh.json b/src/components/tui/locale/zh.json similarity index 100% rename from lib/components/tui/locale/zh.json rename to src/components/tui/locale/zh.json diff --git a/lib/components/tui/package.json b/src/components/tui/package.json similarity index 100% rename from lib/components/tui/package.json rename to src/components/tui/package.json diff --git a/lib/config-updates.js b/src/config-updates.js similarity index 100% rename from lib/config-updates.js rename to src/config-updates.js diff --git a/lib/config.js b/src/config.js similarity index 100% rename from lib/config.js rename to src/config.js diff --git a/lib/credential-policy.js b/src/credential-policy.js similarity index 100% rename from lib/credential-policy.js rename to src/credential-policy.js diff --git a/lib/file/contract.js b/src/file/contract.js similarity index 100% rename from lib/file/contract.js rename to src/file/contract.js diff --git a/lib/file/runtime.js b/src/file/runtime.js similarity index 100% rename from lib/file/runtime.js rename to src/file/runtime.js diff --git a/lib/file/worker.js b/src/file/worker.js similarity index 100% rename from lib/file/worker.js rename to src/file/worker.js diff --git a/lib/index.js b/src/index.js similarity index 100% rename from lib/index.js rename to src/index.js diff --git a/lib/official-tool-catalog.js b/src/official-tool-catalog.js similarity index 100% rename from lib/official-tool-catalog.js rename to src/official-tool-catalog.js diff --git a/lib/official-tool-hiding.js b/src/official-tool-hiding.js similarity index 100% rename from lib/official-tool-hiding.js rename to src/official-tool-hiding.js diff --git a/lib/panel/collect.js b/src/panel/collect.js similarity index 100% rename from lib/panel/collect.js rename to src/panel/collect.js diff --git a/lib/panel/panel.html b/src/panel/panel.html similarity index 100% rename from lib/panel/panel.html rename to src/panel/panel.html diff --git a/lib/panel/render.js b/src/panel/render.js similarity index 100% rename from lib/panel/render.js rename to src/panel/render.js diff --git a/lib/paths.js b/src/paths.js similarity index 100% rename from lib/paths.js rename to src/paths.js diff --git a/lib/permissions.js b/src/permissions.js similarity index 100% rename from lib/permissions.js rename to src/permissions.js diff --git a/lib/policy.js b/src/policy.js similarity index 100% rename from lib/policy.js rename to src/policy.js diff --git a/lib/profile-install.js b/src/profile-install.js similarity index 100% rename from lib/profile-install.js rename to src/profile-install.js diff --git a/lib/ptc/contract.js b/src/ptc/contract.js similarity index 100% rename from lib/ptc/contract.js rename to src/ptc/contract.js diff --git a/lib/ptc/index.js b/src/ptc/index.js similarity index 100% rename from lib/ptc/index.js rename to src/ptc/index.js diff --git a/lib/ptc/runtime.js b/src/ptc/runtime.js similarity index 100% rename from lib/ptc/runtime.js rename to src/ptc/runtime.js diff --git a/lib/ptc/worker.js b/src/ptc/worker.js similarity index 100% rename from lib/ptc/worker.js rename to src/ptc/worker.js diff --git a/lib/rust.js b/src/rust.js similarity index 100% rename from lib/rust.js rename to src/rust.js diff --git a/lib/session-pwsh.js b/src/session-pwsh.js similarity index 100% rename from lib/session-pwsh.js rename to src/session-pwsh.js diff --git a/lib/session-shells.js b/src/session-shells.js similarity index 100% rename from lib/session-shells.js rename to src/session-shells.js diff --git a/lib/session-temp.js b/src/session-temp.js similarity index 100% rename from lib/session-temp.js rename to src/session-temp.js diff --git a/lib/shells.js b/src/shells.js similarity index 100% rename from lib/shells.js rename to src/shells.js diff --git a/lib/shells/argument-error.js b/src/shells/argument-error.js similarity index 100% rename from lib/shells/argument-error.js rename to src/shells/argument-error.js diff --git a/lib/shells/constants.js b/src/shells/constants.js similarity index 100% rename from lib/shells/constants.js rename to src/shells/constants.js diff --git a/lib/shells/paths.js b/src/shells/paths.js similarity index 100% rename from lib/shells/paths.js rename to src/shells/paths.js diff --git a/lib/shells/pins.js b/src/shells/pins.js similarity index 100% rename from lib/shells/pins.js rename to src/shells/pins.js diff --git a/lib/shells/publish.js b/src/shells/publish.js similarity index 100% rename from lib/shells/publish.js rename to src/shells/publish.js diff --git a/lib/shells/resolve.js b/src/shells/resolve.js similarity index 100% rename from lib/shells/resolve.js rename to src/shells/resolve.js diff --git a/lib/shells/spawn-error.js b/src/shells/spawn-error.js similarity index 100% rename from lib/shells/spawn-error.js rename to src/shells/spawn-error.js diff --git a/lib/shells/tool.js b/src/shells/tool.js similarity index 100% rename from lib/shells/tool.js rename to src/shells/tool.js diff --git a/lib/teardown.js b/src/teardown.js similarity index 100% rename from lib/teardown.js rename to src/teardown.js diff --git a/lib/tui/activity.js b/src/tui/activity.js similarity index 100% rename from lib/tui/activity.js rename to src/tui/activity.js diff --git a/lib/tui/attention.js b/src/tui/attention.js similarity index 100% rename from lib/tui/attention.js rename to src/tui/attention.js diff --git a/lib/tui/panel.js b/src/tui/panel.js similarity index 100% rename from lib/tui/panel.js rename to src/tui/panel.js diff --git a/lib/tui/result.js b/src/tui/result.js similarity index 100% rename from lib/tui/result.js rename to src/tui/result.js diff --git a/lib/workspace-preparation.js b/src/workspace-preparation.js similarity index 100% rename from lib/workspace-preparation.js rename to src/workspace-preparation.js diff --git a/test/build.test.mjs b/test/build.test.mjs new file mode 100644 index 00000000..ac94d9d3 --- /dev/null +++ b/test/build.test.mjs @@ -0,0 +1,97 @@ +/** The source compiler builds a clean, runnable package without touching this checkout's lib/. */ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { pathToFileURL } from 'node:url' +import { assert, test, report, PACKAGE_ROOT, withTempDir } from './lib/harness.mjs' + +function write(root, name, content) { + const file = path.join(root, name) + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, content) +} + +function fixture(root) { + fs.mkdirSync(path.join(root, 'scripts'), { recursive: true }) + fs.mkdirSync(path.join(root, 'node_modules'), { recursive: true }) + fs.copyFileSync(path.join(PACKAGE_ROOT, 'scripts/build.mjs'), path.join(root, 'scripts/build.mjs')) + fs.copyFileSync(path.join(PACKAGE_ROOT, 'tsconfig.json'), path.join(root, 'tsconfig.json')) + // Copies also work in Windows sandboxes where directory junctions are unavailable. + fs.cpSync(path.join(PACKAGE_ROOT, 'node_modules/typescript'), + path.join(root, 'node_modules/typescript'), { recursive: true }) + write(root, 'package.json', JSON.stringify({ name: 'build-fixture', private: true, type: 'module' })) +} + +function build(root) { + // The caller's current directory must not determine which package is rebuilt. + const result = spawnSync(process.execPath, [path.join(root, 'scripts/build.mjs')], { + cwd: PACKAGE_ROOT, encoding: 'utf8', timeout: 30_000, + }) + assert.ifError(result.error) + return { status: result.status, output: `${result.stdout}${result.stderr}` } +} + +await test('build emits runnable JavaScript and TypeScript at their existing package paths', () => + withTempDir('source-build-modules', async root => { + fixture(root) + write(root, 'src/index.ts', "import { prefix } from './policy.js'\nexport const answer: string = `${prefix}:42`\n") + write(root, 'src/policy.js', "export const prefix = 'ops'\n") + write(root, 'src/nested/module.mts', 'export const count: number = 3\n') + write(root, 'src/nested/module.cts', 'export const count: number = 4\n') + write(root, 'src/types.d.ts', 'export interface Config { enabled: boolean }\n') + const result = build(root) + assert.equal(result.status, 0, result.output) + assert.equal((await import(pathToFileURL(path.join(root, 'lib/index.js')).href)).answer, 'ops:42') + assert.equal((await import(pathToFileURL(path.join(root, 'lib/nested/module.mjs')).href)).count, 3) + assert.equal((await import(pathToFileURL(path.join(root, 'lib/nested/module.cjs')).href)).count, 4) + for (const name of ['index.ts', 'nested/module.mts', 'nested/module.cts', 'types.d.ts']) { + assert.equal(fs.existsSync(path.join(root, 'lib', name)), false, `uncompiled source copied: ${name}`) + } + })) + +await test('rebuild removes retired modules and assets while preserving every asset byte', () => + withTempDir('source-build-assets', root => { + fixture(root) + write(root, 'src/index.js', 'export const enabled = true\n') + write(root, 'src/retired.js', 'export default false\n') + write(root, 'src/panel/retired.html', '

retired

\n') + const assets = { + 'components/panel/package.json': Buffer.from('{"type":"module"}\n'), + 'components/panel/locale/en-US.json': Buffer.from('{"name":"Panel"}\r\n'), + 'components/panel/icon.png': Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 255]), + 'components/tui/dsh-plugin.json': Buffer.from('{"id":"dsh-ops/tui"}\n'), + 'panel/panel.html': Buffer.from('\r\n

面板

\r\n'), + } + for (const [name, bytes] of Object.entries(assets)) write(root, `src/${name}`, bytes) + const initial = build(root) + assert.equal(initial.status, 0, initial.output) + assert.ok(fs.existsSync(path.join(root, 'lib/retired.js'))) + assert.ok(fs.existsSync(path.join(root, 'lib/panel/retired.html'))) + fs.unlinkSync(path.join(root, 'src/retired.js')) + fs.unlinkSync(path.join(root, 'src/panel/retired.html')) + write(root, 'lib/obsolete/nested.txt', 'old build output') + const rebuilt = build(root) + assert.equal(rebuilt.status, 0, rebuilt.output) + for (const name of ['retired.js', 'panel/retired.html', 'obsolete']) { + assert.equal(fs.existsSync(path.join(root, 'lib', name)), false, `stale output remains: ${name}`) + } + for (const [name, bytes] of Object.entries(assets)) { + assert.deepEqual(fs.readFileSync(path.join(root, 'lib', name)), bytes, `asset changed: ${name}`) + } + assert.equal(fs.readFileSync(path.join(root, 'src/index.js'), 'utf8'), 'export const enabled = true\n') + })) + +await test('compiler errors fail the build with diagnostics and no stale or partially copied package', () => + withTempDir('source-build-error', root => { + fixture(root) + write(root, 'src/index.ts', "export const count: number = 'wrong type'\n") + write(root, 'src/panel/panel.html', '

must not be published

\n') + write(root, 'lib/index.js', 'export const count = 42\n') + const result = build(root) + assert.notEqual(result.status, 0, result.output) + assert.match(result.output, /error TS2322/u) + assert.equal(fs.existsSync(path.join(root, 'lib/index.js')), false) + assert.equal(fs.existsSync(path.join(root, 'lib/panel/panel.html')), false) + })) + +report('build') diff --git a/test/package-source.test.mjs b/test/package-source.test.mjs index fc35f69e..5cb1706b 100644 --- a/test/package-source.test.mjs +++ b/test/package-source.test.mjs @@ -1,19 +1,22 @@ -/** npm source closure: path crates must survive packing, without native caches. */ +/** npm source closure: compiled host code and path crates survive packing, without build caches. */ import fs from 'node:fs' import path from 'node:path' import { spawnSync } from 'node:child_process' import { assert, test, report, PACKAGE_ROOT } from './lib/harness.mjs' -await test('npm pack includes native workspace/path-crate sources, never build caches', () => { +await test('npm pack includes compiled host code and native sources, never host sources or build caches', () => { const result = spawnSync(process.platform === 'win32' ? 'cmd.exe' : 'npm', process.platform === 'win32' ? ['/d', '/s', '/c', 'npm.cmd pack --dry-run --json --ignore-scripts'] : ['pack', '--dry-run', '--json', '--ignore-scripts'], { cwd: PACKAGE_ROOT, encoding: 'utf8', timeout: 30000 }) assert.equal(result.status, 0, result.stderr) const files = JSON.parse(result.stdout)[0].files.map(file => file.path) - const required = ['native/Cargo.toml', 'native/Cargo.lock', 'native/src/lib.rs', + const required = ['lib/index.js', 'lib/policy.js', 'lib/components/panel/index.js', + 'lib/components/panel/package.json', 'lib/components/panel/icon.png', 'lib/panel/panel.html', + 'lib/components/tui/dsh-plugin.json', 'native/Cargo.toml', 'native/Cargo.lock', 'native/src/lib.rs', 'native/windows-metadata/Cargo.toml', 'native/windows-metadata/src/lib.rs', 'native/darwin-metadata/Cargo.toml', 'native/darwin-metadata/src/lib.rs'] for (const name of required) assert.ok(files.includes(name), `missing source-build dependency: ${name}`) + assert.equal(files.some(name => name.startsWith('src/')), false, 'host source must not ship beside compiled lib/') assert.equal(files.some(name => /^native\/(?:target[^/]*|(?:windows|darwin)-metadata\/target)\//u.test(name)), false) const manifest = fs.readFileSync(path.join(PACKAGE_ROOT, 'native/Cargo.toml'), 'utf8') for (const match of manifest.matchAll(/^path\s*=\s*"([^"]+)"/gmu)) { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..cc48a6e1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2023", + "lib": ["ES2023"], + "types": [], + "allowJs": true, + "checkJs": false, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "removeComments": false, + "declaration": false, + "sourceMap": false, + "newLine": "lf", + "noEmitOnError": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"] +} From 614eae69e16517a456df958fc446d1208cdaaf29 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:08:58 +0800 Subject: [PATCH 002/118] refactor(native): [4a/16] split the kernel into workspace crates --- AGENTS.md | 2 +- bench/setup/preflight.mjs | 10 +- docs/CHANGELOG.md | 7 + docs/CONTRIBUTING.md | 2 +- docs/ci.md | 4 +- docs/native-kernel.md | 27 ++ docs/verify-locally.md | 2 +- ...37\347\224\237\345\206\205\346\240\270.md" | 24 + ...41\347\214\256\346\214\207\345\215\227.md" | 2 +- native/Cargo.lock | 81 +++- native/Cargo.toml | 19 +- native/crates/ops-batch/Cargo.toml | 15 + .../batch.rs => crates/ops-batch/src/lib.rs} | 21 +- native/crates/ops-files/Cargo.toml | 24 + native/crates/ops-files/src/file.rs | 415 +++++++++++++++++ .../{ => crates/ops-files}/src/file_path.rs | 0 .../ops-files}/src/file_publication.rs | 4 +- .../{ => crates/ops-files}/src/file_single.rs | 0 native/crates/ops-files/src/lib.rs | 51 +++ native/{ => crates/ops-files}/src/paths.rs | 0 native/crates/ops-protocol/Cargo.toml | 12 + .../ops-protocol/src/lib.rs} | 18 +- native/crates/ops-results/Cargo.toml | 18 + native/crates/ops-results/src/lib.rs | 8 + .../{ => crates/ops-results}/src/results.rs | 3 +- native/{ => crates/ops-results}/src/store.rs | 0 native/crates/ops-search/Cargo.toml | 20 + .../file.rs => crates/ops-search/src/lib.rs} | 430 +----------------- native/src/lib.rs | 59 +-- package.json | 1 + scripts/verify-gate.mjs | 2 +- test/bench-setup-preflight.test.mjs | 20 + test/package-source.test.mjs | 20 +- 33 files changed, 801 insertions(+), 520 deletions(-) create mode 100644 docs/native-kernel.md create mode 100644 "docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" create mode 100644 native/crates/ops-batch/Cargo.toml rename native/{src/batch.rs => crates/ops-batch/src/lib.rs} (96%) create mode 100644 native/crates/ops-files/Cargo.toml create mode 100644 native/crates/ops-files/src/file.rs rename native/{ => crates/ops-files}/src/file_path.rs (100%) rename native/{ => crates/ops-files}/src/file_publication.rs (98%) rename native/{ => crates/ops-files}/src/file_single.rs (100%) create mode 100644 native/crates/ops-files/src/lib.rs rename native/{ => crates/ops-files}/src/paths.rs (100%) create mode 100644 native/crates/ops-protocol/Cargo.toml rename native/{src/contract.rs => crates/ops-protocol/src/lib.rs} (94%) create mode 100644 native/crates/ops-results/Cargo.toml create mode 100644 native/crates/ops-results/src/lib.rs rename native/{ => crates/ops-results}/src/results.rs (99%) rename native/{ => crates/ops-results}/src/store.rs (100%) create mode 100644 native/crates/ops-search/Cargo.toml rename native/{src/file.rs => crates/ops-search/src/lib.rs} (54%) diff --git a/AGENTS.md b/AGENTS.md index 27f0c19a..ff1197ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ capabilities. ```console npm ci --ignore-scripts # install (locked) npm run build # src/ -> lib/ (tsc + assets) -cargo test --locked --manifest-path native/Cargo.toml # Rust unit tests +cargo test --workspace --locked --manifest-path native/Cargo.toml # Rust unit tests npm run build:ptc # build + stage the native worker (needed by most suites) npm run build:bash-ipc # Windows only npm run verify # build + manifest check + all suites diff --git a/bench/setup/preflight.mjs b/bench/setup/preflight.mjs index adb2dc31..a77486da 100644 --- a/bench/setup/preflight.mjs +++ b/bench/setup/preflight.mjs @@ -209,7 +209,7 @@ export function inspectModulePath({ modulePath, windows, isDirectory } = {}) { } /** - * The newest Rust input under `native/` — `/src/**` plus the crate manifests — + * The newest Rust input under `native/` — workspace source directories plus the crate manifests — * skipping `native/target`, which is build output rather than input. * @param {string} nativeDir - the crate root. * @returns {{file: string, mtimeMs: number}|null} the newest input, or null when there is none. @@ -220,12 +220,14 @@ function newestRustInput(nativeDir) { let stat try { stat = fs.statSync(file) } catch { return } if (stat.isDirectory()) { - for (const entry of fs.readdirSync(file, { withFileTypes: true })) visit(path.join(file, entry.name)) + for (const entry of fs.readdirSync(file, { withFileTypes: true })) { + if (entry.name !== 'target') visit(path.join(file, entry.name)) + } return } if (!newest || stat.mtimeMs > newest.mtimeMs) newest = { file, mtimeMs: stat.mtimeMs } } - for (const input of ['src', 'Cargo.toml', 'Cargo.lock']) visit(path.join(nativeDir, input)) + for (const input of ['src', 'crates', 'windows-metadata', 'darwin-metadata', 'Cargo.toml', 'Cargo.lock']) visit(path.join(nativeDir, input)) return newest } @@ -319,7 +321,7 @@ export function inspectLocalBuild({ root = REPO_ROOT, platform = process.platfor } else { const rust = newestRustInput(path.join(root, 'native')) if (rust && rust.mtimeMs > fs.statSync(worker).mtimeMs) { - warnings.push(`the staged worker is older than ${path.relative(root, rust.file)}: this sweep may measure a build that predates native/src (rebuild: npm run build:ptc)`) + warnings.push(`the staged worker is older than ${path.relative(root, rust.file)}: this sweep may measure a build that predates the Rust workspace (rebuild: npm run build:ptc)`) } } // The proxies exist for win32-x64 only, and only when the sources shipped with them. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b31e5efb..c0748929 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,13 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Split the native kernel into workspace crates (#261) + +- Extract protocol, file, search, batch, and result-storage crates while retaining the one-shot + worker and existing authority, metadata, no-follow, and audit rules. Test the complete Cargo + workspace and include every path dependency in published source packages. +- Include workspace crates and platform metadata adapters when checking for stale benchmark workers. + ### Build plugin source into the existing JavaScript package layout (#261) - Move plugin source to `src/` and introduce the TypeScript build with JavaScript migration diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index f829652f..6d088ba8 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -89,7 +89,7 @@ plugin. Most suites drive the real native worker. Without one they cannot answer, and they **skip**: ```console -cargo test --locked --manifest-path native/Cargo.toml # worker unit tests +cargo test --workspace --locked --manifest-path native/Cargo.toml # worker unit tests npm run build:ptc # build + stage runtime/-/ npm run build:bash-ipc # Windows only: Bash IPC proxies ``` diff --git a/docs/ci.md b/docs/ci.md index 1c3cb267..b9d8b8a4 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -16,7 +16,7 @@ a reviewer can trust a green PR without re-running anything by hand. | Job | Runner | Blocking? | What it proves | | --- | --- | --- | --- | -| `validate` | `windows-latest` (win32-x64), `ubuntu-24.04` (linux-x64), `macos-latest` (darwin-arm64) | **Yes** | `cargo test --locked`, the native worker builds, then `scripts/verify-gate.mjs --skip-install`: no unauthorised skips (`DSH_OPS_REQUIRE_RUNTIME=1` on POSIX turns a missing runtime into a failure), every suite file on disk was collected, and the packed artifact unpacks and passes `native-runtime check` | +| `validate` | `windows-latest` (win32-x64), `ubuntu-24.04` (linux-x64), `macos-latest` (darwin-arm64) | **Yes** | `cargo test --workspace --locked`, the native worker builds, then `scripts/verify-gate.mjs --skip-install`: no unauthorised skips (`DSH_OPS_REQUIRE_RUNTIME=1` on POSIX turns a missing runtime into a failure), every suite file on disk was collected, and the packed artifact unpacks and passes `native-runtime check` | | `node-floor` | `ubuntu-24.04` | **Yes** | `package.json` promises `^22.19 \|\| >=24`; this runs the same gate on the declared **floor** so the promise is exercised rather than asserted | | `workflow-lint` | `ubuntu-24.04` | **Yes** | `scripts/check-workflows.mjs`: every workflow parses, declares triggers/jobs/permissions, bounds each job with `timeout-minutes`, avoids `write-all` and `pull_request_target`, and keeps `ci.yml`'s aggregate check wired to every other job | | `source-size` | `ubuntu-24.04` | **Yes** | `scripts/check-file-size.mjs`: every tracked code file stays at or under 1000 lines. The file set comes from `git ls-files`, so a new module is in scope the moment it is added | @@ -32,7 +32,7 @@ keeping three names in sync forever, and losing the gate the day a runner label ```bash npm ci --ignore-scripts --registry=https://registry.npmjs.org -cargo test --locked --manifest-path native/Cargo.toml +cargo test --workspace --locked --manifest-path native/Cargo.toml npm run build:ptc # The gate, exactly as CI runs it (POSIX: a skipped runtime suite is a failure, not a pass) diff --git a/docs/native-kernel.md b/docs/native-kernel.md new file mode 100644 index 00000000..27c19481 --- /dev/null +++ b/docs/native-kernel.md @@ -0,0 +1,27 @@ +# Native kernel workspace + +中文版:[原生内核.md](原生内核.md). + +The one-shot `dsh-ops-worker` remains the only executable. Each call enters through DSH's +subprocess and confinement services, rechecks authority, reads one bounded JSON request, +and writes one JSON result. Splitting the kernel does not introduce a persistent process. + +| Crate | Responsibility | Kernel dependencies | +| --- | --- | --- | +| `ops-protocol` | Request, errors, job records, credential and ownership checks | None | +| `ops-files` | No-follow handles, reads, CAS publication, metadata, tree operations, directory sync | protocol | +| `ops-search` | Glob, grep and replacement orchestration over guarded file primitives | protocol, files | +| `ops-results` | Bounded generation store, result pages and immutable job-audit access | protocol, files | +| `ops-batch` | Plans, write-ahead transitions and conservative recovery | protocol, files, results | + +The root crate routes requests and opens audit storage only for batch/result capabilities. +Job ownership checks live with the protocol record so results never depend on batch execution. +The existing Windows and Darwin metadata crates remain separate FFI boundaries; kernel crates +forbid unsafe Rust. Replacement keeps its observation locks until publication settles, and +batch execution persists the unknown state before filesystem effects. Read-only file calls do +not create audit storage. + +Run `cargo test --workspace --locked --manifest-path native/Cargo.toml` to include the tests +moved into the new crates. `npm run build:ptc` still produces the same worker path. Published +packages include `native/crates/` and both metadata adapters so installed source builds work. +The benchmark preflight checks all workspace inputs while ignoring Cargo target directories. diff --git a/docs/verify-locally.md b/docs/verify-locally.md index a974a8a4..01507976 100644 --- a/docs/verify-locally.md +++ b/docs/verify-locally.md @@ -35,7 +35,7 @@ There is no exit code for "green with a surprise in it" — that is `1`. | --- | --- | --- | | versions | `node --version`, `cargo --version` | The toolchain is the pinned one, before anything else can be blamed | | install | `npm ci --ignore-scripts --registry=https://registry.npmjs.org` | Locked dependencies, and no install-time script runs (the Windows shell payloads are optional dependencies; `--ignore-scripts` is what keeps a Linux install from pretending to carry them) | -| worker unit tests | `cargo test --locked --manifest-path native/Cargo.toml` | The Rust worker in isolation | +| worker unit tests | `cargo test --workspace --locked --manifest-path native/Cargo.toml` | The Rust worker in isolation | | stage | `npm run build:ptc` | Builds the worker **and stages it with its provenance receipt** into `runtime/-/`. Skipping this is the most common way to get a misleading failure — see the traps below | | platform payloads | `npm run build:bash-ipc` (Windows) | The Windows IPC proxies | | manifest | `npm run validate:manifest` | The declaration layers agree; asserts a floor of `59` (currently `62`) | diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" new file mode 100644 index 00000000..c374795a --- /dev/null +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -0,0 +1,24 @@ +# 原生内核工作区 + +English: [native-kernel.md](native-kernel.md). + +`dsh-ops-worker` 仍然是唯一的可执行文件。每次调用都经过 DSH 的 subprocess 和权限沙箱, +重新核实权限,读取一次有大小上限的 JSON 请求,再输出一个 JSON 结果。拆分内核不会引入常驻进程。 + +| crate | 职责 | 内核依赖 | +| --- | --- | --- | +| `ops-protocol` | 请求、错误、作业记录、凭据路径和归属校验 | 无 | +| `ops-files` | 禁止跟随链接的句柄、读取、CAS 发布、元数据、树操作、目录同步 | protocol | +| `ops-search` | 基于受保护文件原语的 glob、grep 和替换编排 | protocol、files | +| `ops-results` | 有界的代际存储、结果分页和只读作业审计访问 | protocol、files | +| `ops-batch` | 计划、预写状态转移和保守恢复 | protocol、files、results | + +根 crate 负责分派请求,仅为 batch/result 能力打开审计存储。作业归属校验与协议记录放在一起, +避免结果模块依赖批处理执行模块。Windows 和 Darwin 元数据 crate 仍是独立 FFI 边界;内核 crate +禁止 unsafe Rust。替换操作保留观察锁直到发布完成;批处理在产生文件系统副作用前持久化 unknown +状态。只读文件调用不会创建审计存储。 + +使用 `cargo test --workspace --locked --manifest-path native/Cargo.toml`,确保迁入各 crate 的 +测试全部执行。`npm run build:ptc` 的 worker 输出路径保持不变。发布包包含 `native/crates/` +及两个元数据适配器,支持安装后的源码构建。基准测试预检检查整个工作区的构建输入,并排除 Cargo +的 target 目录。 diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 0b841279..fa9422d2 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -67,7 +67,7 @@ Windows 上还需在打包前运行 `npm run build:bash-ipc`。使用目标 prof 大多数套件驱动的是真实的原生 worker;没有 worker 时它们无法作答,会**跳过**: ```console -cargo test --locked --manifest-path native/Cargo.toml # worker 单元测试 +cargo test --workspace --locked --manifest-path native/Cargo.toml # worker 单元测试 npm run build:ptc # 构建并暂存到 runtime/-/ npm run build:bash-ipc # 仅 Windows:Bash IPC 代理 ``` diff --git a/native/Cargo.lock b/native/Cargo.lock index bb5ae2d5..bf053112 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -127,15 +127,12 @@ version = "0.3.1" dependencies = [ "cap-fs-ext", "cap-std", - "dsh-ops-darwin-metadata", - "dsh-ops-windows-metadata", - "fs2", - "regex", - "rustix", - "serde", + "ops-batch", + "ops-files", + "ops-protocol", + "ops-results", + "ops-search", "serde_json", - "sha2", - "uuid", ] [[package]] @@ -296,6 +293,74 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "ops-batch" +version = "0.3.1" +dependencies = [ + "cap-std", + "ops-files", + "ops-protocol", + "ops-results", + "serde_json", + "uuid", +] + +[[package]] +name = "ops-files" +version = "0.3.1" +dependencies = [ + "cap-fs-ext", + "cap-std", + "dsh-ops-darwin-metadata", + "dsh-ops-windows-metadata", + "fs2", + "ops-protocol", + "rustix", + "serde", + "serde_json", + "sha2", + "uuid", +] + +[[package]] +name = "ops-protocol" +version = "0.3.1" +dependencies = [ + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "ops-results" +version = "0.3.1" +dependencies = [ + "cap-fs-ext", + "cap-std", + "fs2", + "ops-files", + "ops-protocol", + "serde", + "serde_json", + "sha2", + "uuid", +] + +[[package]] +name = "ops-search" +version = "0.3.1" +dependencies = [ + "cap-fs-ext", + "cap-std", + "ops-files", + "ops-protocol", + "regex", + "serde", + "serde_json", + "sha2", + "uuid", +] + [[package]] name = "pin-project-lite" version = "0.2.17" diff --git a/native/Cargo.toml b/native/Cargo.toml index 22768e08..48a3bf18 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -15,23 +15,16 @@ path = "src/main.rs" [dependencies] cap-std = "3.4" cap-fs-ext = "3.4" -fs2 = "0.4.3" -serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -regex = "1.12" -rustix = { version = "1.1", features = ["fs", "process"] } -sha2 = "0.10.9" -uuid = { version = "1.26", features = ["v4"] } +ops-protocol = { path = "crates/ops-protocol" } +ops-files = { path = "crates/ops-files" } +ops-search = { path = "crates/ops-search" } +ops-results = { path = "crates/ops-results" } +ops-batch = { path = "crates/ops-batch" } [lints.rust] unsafe_code = "forbid" -[target.'cfg(windows)'.dependencies.dsh-ops-windows-metadata] -path = "windows-metadata" - -[target.'cfg(target_os = "macos")'.dependencies.dsh-ops-darwin-metadata] -path = "darwin-metadata" - [workspace] -members = ["windows-metadata", "darwin-metadata"] +members = ["windows-metadata", "darwin-metadata", "crates/*"] resolver = "3" diff --git a/native/crates/ops-batch/Cargo.toml b/native/crates/ops-batch/Cargo.toml new file mode 100644 index 00000000..15862b61 --- /dev/null +++ b/native/crates/ops-batch/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ops-batch" +version = "0.3.1" +edition = "2024" +license = "MIT" +rust-version = "1.88" +publish = false + +[dependencies] +ops-protocol = { path = "../ops-protocol" } +ops-files = { path = "../ops-files" } +ops-results = { path = "../ops-results" } +cap-std = "3.4" +serde_json = "1.0" +uuid = { version = "1.26", features = ["v4"] } diff --git a/native/src/batch.rs b/native/crates/ops-batch/src/lib.rs similarity index 96% rename from native/src/batch.rs rename to native/crates/ops-batch/src/lib.rs index 5610eabf..9f26cc5d 100644 --- a/native/src/batch.rs +++ b/native/crates/ops-batch/src/lib.rs @@ -1,7 +1,9 @@ -//! Preflight, durable per-item transitions and conservative recovery. +#![forbid(unsafe_code)] +use ops_protocol as contract; +use ops_files::paths; +use ops_results::{self as results, store}; use crate::{ contract::*, - paths, store::{Store, now}, }; use cap_std::fs::Dir; @@ -144,7 +146,7 @@ pub fn run(root: &Dir, store: &Store, request: &Request) -> Result { .id .ok_or_else(|| Failure::new("INVALID_ARGUMENT", "Resume requires id"))?; let mut job: Job = store.read(&id)?; - authorize(&job, request)?; + job.authorize(request)?; if job.id != id { return Err(Failure::new("POLICY_DENIED", "Manifest identity mismatch")); } @@ -161,19 +163,6 @@ pub fn run(root: &Dir, store: &Store, request: &Request) -> Result { )), } } -pub fn authorize(job: &Job, request: &Request) -> Result<()> { - if job.version != 1 - || job.scope != request.scope - || job.root != request.root - || job.root_identity != request.root_identity - { - return Err(Failure::new( - "POLICY_DENIED", - "Job scope, root or version mismatch", - )); - } - Ok(()) -} fn validate_saved(job: &Job) -> Result<()> { if job.items.is_empty() || job.items.len() > MAX_OPERATIONS || job.protect.len() > 128 { return Err(Failure::new( diff --git a/native/crates/ops-files/Cargo.toml b/native/crates/ops-files/Cargo.toml new file mode 100644 index 00000000..7fbf0eda --- /dev/null +++ b/native/crates/ops-files/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "ops-files" +version = "0.3.1" +edition = "2024" +license = "MIT" +rust-version = "1.88" +publish = false + +[dependencies] +ops-protocol = { path = "../ops-protocol" } +cap-std = "3.4" +cap-fs-ext = "3.4" +fs2 = "0.4.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +rustix = { version = "1.1", features = ["fs", "process"] } +sha2 = "0.10.9" +uuid = { version = "1.26", features = ["v4"] } + +[target.'cfg(windows)'.dependencies.dsh-ops-windows-metadata] +path = "../../windows-metadata" + +[target.'cfg(target_os = "macos")'.dependencies.dsh-ops-darwin-metadata] +path = "../../darwin-metadata" diff --git a/native/crates/ops-files/src/file.rs b/native/crates/ops-files/src/file.rs new file mode 100644 index 00000000..42f9a5bc --- /dev/null +++ b/native/crates/ops-files/src/file.rs @@ -0,0 +1,415 @@ +//! Native file-operation capabilities. All paths are host-resolved absolute paths; +//! this module never invokes a shell and never follows links. +use crate::contract::{Failure, Request, Result}; +use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt}; +use fs2::FileExt; +use serde::Deserialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::fs::{self}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +const MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; +/// Ceiling for one worker response. The host collects worker stdout with +/// `maxBytes: 128 * 1024` and turns a lossy read into `OUTPUT_LIMIT` plus +/// `effectState: "unknown"`, so a response that overflows is reported to the model +/// as an operation whose effect cannot be known — for a pure read, that is both +/// false and useless. Staying under the host cap is what keeps a read honest. +/// The budget is spent on the *serialized* items rather than the payload, because +/// escaping is the multiplier: one NUL byte costs six as `\u0000`, which is how a +/// 32 MiB file once produced 200 MB of stdout. +pub const MAX_OUTPUT_BYTES: usize = 96 * 1024; +/// Non-item fields of the envelope, counted against the same ceiling. +const ENVELOPE_OVERHEAD: usize = 512; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct InspectArgs { + path: String, + #[serde(default = "one")] + offset: usize, + #[serde(default = "two_hundred")] + limit: usize, + #[serde(default = "utf8")] + encoding: String, + #[serde(default = "text")] + view: String, +} +fn one() -> usize { 1 } +fn two_hundred() -> usize { 200 } +fn one_hundred() -> usize { 100 } +fn utf8() -> String { "utf-8".into() } +fn text() -> String { "text".into() } + +pub fn dispatch(request: &Request) -> Result { + if let Some(raw) = request.args.get("path").and_then(Value::as_str) { request.authorize_path(Path::new(raw))?; } + match request.tool.as_str() { + "inspect_local_file" => inspect(request), + "stat" | "diff" | "write_file" | "edit_file" | "copy" | "move" | "remove" => single::run(request), + _ => Err(Failure::new("INVALID_ARGUMENT", "Unknown file capability")), + }.map_err(|mut error| { error.message = format!("{}: {}", request.tool, error.message); error }) +} + +pub fn target(raw: &str, _allow_missing: bool) -> Result { + Ok(crate::file_path::checked(raw)?.display) +} +pub fn relative_display(root: &Path, path: &Path) -> String { + path.strip_prefix(root).unwrap_or(path).to_string_lossy().replace('\\', "/") +} +pub fn read_bytes(path: &Path) -> Result> { + let raw = path.to_str().ok_or_else(|| Failure::new("INVALID_ARGUMENT", "Non-UTF8 path"))?; + let target = crate::file_path::checked(raw).map_err(|mut error| { error.message = format!("read path validation: {}", error.message); error })?; + let mut file = crate::file_path::read_file(&target).map_err(|mut error| { error.message = format!("read open: {}", error.message); error })?; + let mut bytes = Vec::new(); + std::io::Read::by_ref(&mut file).take(MAX_FILE_BYTES + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_FILE_BYTES { return Err(Failure::new("OUTPUT_LIMIT", "File exceeds the bounded read budget")); } + Ok(bytes) +} +/// What one character costs in serde_json's encoding. +fn encoded_cost(character: char) -> usize { + match character { + '"' | '\\' | '\n' | '\r' | '\t' | '\u{8}' | '\u{c}' => 2, + c if (c as u32) < 0x20 => 6, + c => c.len_utf8(), + } +} +/// The longest prefix of `text` whose encoding fits `budget` bytes, cut on a +/// character boundary so the result is still valid UTF-8. +fn truncate_to_encoded(text: &str, budget: usize) -> (String, bool) { + let mut kept = String::new(); let mut used = 0usize; + for character in text.chars() { let cost = encoded_cost(character); if used + cost > budget { return (kept, true); } used += cost; kept.push(character); } + (kept, false) +} +/// `offset` is how many units were skipped, counted from zero the way `glob` and +/// `grep` count their own `offset`. `next_base` is the value a caller must pass +/// back to resume, which is its own paging base rather than the skipped count: +/// `inspect` declares a 1-based `offset` (like its hex rows), so deriving the +/// next page from the skipped count re-read the last returned line on every page. +pub fn result(items: Vec, total: usize, offset: usize, next_base: usize, matched: usize, truncated: bool) -> Value { + // Spend the response budget on the serialized items. Returning more than the + // host will collect is not a bigger answer, it is a failed read: the host + // truncates stdout, reports OUTPUT_LIMIT and declares the effect unknown, so + // the caller loses the content and is told something may have changed. + let budget = MAX_OUTPUT_BYTES.saturating_sub(ENVELOPE_OVERHEAD); + let mut kept: Vec = Vec::new(); let mut used = 0usize; let mut stopped = false; + for item in items { + let size = serde_json::to_string(&item).map_or(0, |encoded| encoded.len()) + 1; + if used + size <= budget { used += size; kept.push(item); continue; } + // This item does not fit whole. Keep a bounded prefix of its text rather + // than returning nothing at all, so a file holding one long line still + // answers with its beginning; every remaining item is omitted and counted + // as such. Reading past the cut is the hex view's job, which pages by + // 16-byte rows. + let whole = item.get("text").and_then(Value::as_str); + let room = budget.saturating_sub(used).saturating_sub(96); + let prefix = whole.map(|text| truncate_to_encoded(text, room)); + if let Some((text, cut)) = prefix { + if !text.is_empty() { + let mut trimmed = item.clone(); + trimmed["text"] = Value::String(text); + trimmed["bytesTotal"] = json!(whole.map_or(0, |text| text.len())); + trimmed["truncatedInLine"] = json!(cut); + kept.push(trimmed); + } + } + stopped = true; + break; + } + let returned = kept.len(); let cut = truncated || stopped; + json!({"status":"completed","phase":"verify","changed":0,"matched":matched,"skipped":offset,"failed":0,"complete":!cut && offset + returned >= total,"truncated":cut,"omitted":total.saturating_sub(offset + returned),"items":kept,"total":total,"nextOffset":next_base + returned}) +} + +fn inspect(request: &Request) -> Result { + let args: InspectArgs = serde_json::from_value(request.args.clone())?; + if args.limit == 0 || args.limit > 10_000 || !args.encoding.eq_ignore_ascii_case("utf-8") { return Err(Failure::new("INVALID_ARGUMENT", "inspect supports UTF-8 and a bounded limit")); } + let path = target(&args.path, false)?; + if !path.is_file() { return Err(Failure::new("INVALID_ARGUMENT", "path is a directory; use ops_glob or ops_grep")); } + let bytes = read_bytes(&path)?; + let facts = content_facts(&bytes); + if bytes.len() as u64 > MAX_FILE_BYTES { return Err(Failure::new("OUTPUT_LIMIT", "file exceeds the inspect size budget")); } + if args.view == "hex" { + // `offset` means the same thing in both views: the 1-based unit to start + // from — line N for text, 16-byte row N for hex. It used to count rows + // from zero here while the text branch counted lines from one, so the + // declared default of 1 skipped the first row, and the declared + // `minimum: 1` left no way to ask for it back: any file of at most 16 + // bytes read back as an empty string. In this view `skipped` and the item + // `offset` are byte counts, not row counts. + let start = args.offset.saturating_sub(1).saturating_mul(16).min(bytes.len()); let end = (start + args.limit.min(1900) * 16).min(bytes.len()); + let text = bytes[start..end].iter().map(|b| format!("{b:02x}")).collect::>().join(" "); + return Ok(json!({"status":"completed","phase":"verify","changed":0,"matched":1,"skipped":start,"failed":0,"complete":end == bytes.len(),"truncated":end < bytes.len(),"nextOffset":end.div_ceil(16)+1,"omitted":bytes.len().saturating_sub(end),"bytesTotal":bytes.len(),"revision":facts["revision"],"bytes":facts["bytes"],"lineCount":facts["lineCount"],"items":[{"path":path,"view":"hex","offset":start,"text":text}]})); + } + if args.view != "text" { return Err(Failure::new("INVALID_ARGUMENT", "view must be text or hex")); } + let text = String::from_utf8(bytes).map_err(|_| Failure::new("INVALID_ARGUMENT", "file is not valid UTF-8"))?; + let lines: Vec<_> = text.lines().enumerate().skip(args.offset.saturating_sub(1)).take(args.limit).map(|(i, line)| json!({"path":path,"line":i+1,"text":line})).collect(); + let mut output = result(lines, text.lines().count(), args.offset.saturating_sub(1), args.offset, text.lines().count(), false); + for key in ["revision", "bytes", "lineCount"] { output[key] = facts[key].clone(); } + Ok(output) +} +/// A POSIX ACL is exposed as this attribute, so preserving ACLs needs no libacl. +#[cfg(unix)] +const ACL_XATTR: &str = "system.posix_acl_access"; + +/// What the file being replaced carried, captured before its replacement exists. +/// +/// `atomic_write` publishes a *new* inode, and a new inode arrives with none of +/// the old one's metadata: the mode, the owner, the POSIX ACL and every extended +/// attribute belong to the file that is about to be unlinked. So a `0600` `.env` +/// came back world-readable, a `0755` script lost its execute bit and an ACL was +/// dropped outright — the replacement was more permissive than what it replaced, +/// which is the one direction a file tool must never drift in silently. +/// +/// Capture and restore are judged separately. A *capture* that cannot be completed +/// — the attribute list, the ACL, or any single value — aborts the call before +/// anything is published: a value that was never read cannot be put back, so the +/// tool would be guessing at what it is about to destroy. Only a *restore* failure +/// is downgraded to a warning, and only for the attributes listed as best-effort +/// below. +/// +/// Policy, by severity: +/// +/// - The mode is **not optional**. If it cannot be reapplied the call fails and +/// nothing is published; guessing at permissions widens access. +/// - A POSIX ACL is not optional either. A file that had one and comes back +/// without it has lost entries, and losing a *deny* entry is a widening change. +/// - On restore, the owner and the remaining extended attributes are best-effort +/// and are reported in `metadataWarnings`; they cannot be used as a substitute +/// for the two above, and a caller can act on a warning in a way it cannot act on +/// a silent change. +#[cfg(unix)] +struct PreservedMetadata { mode: u32, uid: u32, gid: u32, acl: Option>, xattrs: Vec<(String, Vec)> } + +/// Read one extended attribute, or `None` when it is absent or unsupported. +#[cfg(unix)] +fn get_xattr(fd: &impl rustix::fd::AsFd, name: &str) -> Result>> { + let mut probe: [u8; 0] = []; + match rustix::fs::fgetxattr(fd, name, &mut probe[..]) { + Ok(size) => { + let mut buffer = vec![0u8; size]; + let written = rustix::fs::fgetxattr(fd, name, &mut buffer[..]).map_err(|error| Failure::new("ACCESS_DENIED", format!("Read extended attribute {name}: {error}")))?; + buffer.truncate(written); + Ok(Some(buffer)) + } + Err(error) if matches!(error, rustix::io::Errno::NODATA | rustix::io::Errno::NOTSUP) + || (cfg!(target_os = "macos") && error == rustix::io::Errno::from_raw_os_error(93)) => Ok(None), + Err(error) => Err(Failure::new("ACCESS_DENIED", format!("Read extended attribute {name}: {error}"))), + } +} + +/// Every attribute name on the file. An empty list is not an error: a filesystem +/// that carries none answers `ENOTSUP`. +#[cfg(unix)] +fn list_xattrs(fd: &impl rustix::fd::AsFd) -> Result> { + let mut probe: [u8; 0] = []; + let size = match rustix::fs::flistxattr(fd, &mut probe[..]) { + Ok(size) => size, + Err(error) if error == rustix::io::Errno::NOTSUP => return Ok(Vec::new()), + Err(error) => return Err(Failure::new("ACCESS_DENIED", format!("List extended attributes: {error}"))), + }; + let mut buffer = vec![0u8; size]; + let written = rustix::fs::flistxattr(fd, &mut buffer[..]).map_err(|error| Failure::new("ACCESS_DENIED", format!("List extended attributes: {error}")))?; + buffer.truncate(written); + Ok(buffer.split(|byte| *byte == 0).filter(|name| !name.is_empty()).map(|name| String::from_utf8_lossy(name).into_owned()).collect()) +} + +#[cfg(unix)] +impl PreservedMetadata { + fn of(target: &crate::file_path::Target) -> Result { + let file = crate::file_path::read_file(target)?; + let stat = rustix::fs::fstat(&file).map_err(|error| Failure::new("ACCESS_DENIED", format!("Stat {}: {error}", target.display.display())))?; + let mut xattrs = Vec::new(); + for name in list_xattrs(&file)? { if name == ACL_XATTR { continue } if let Some(value) = get_xattr(&file, &name)? { xattrs.push((name, value)) } } + Ok(Self { mode: stat.st_mode as u32, uid: stat.st_uid as u32, gid: stat.st_gid as u32, acl: get_xattr(&file, ACL_XATTR)?, xattrs }) + } + /// Reapply onto the replacement. `Err` forbids publication; `Ok` may still + /// carry warnings for whatever could not be restored. + fn restore(&self, file: &cap_std::fs::File) -> Result<(Value, Vec)> { + let mut warnings = Vec::new(); + let current = rustix::fs::fstat(file).map_err(|error| Failure::new("ACCESS_DENIED", format!("Stat replacement: {error}")))?; + let owner = if (self.uid, self.gid) == (current.st_uid as u32, current.st_gid as u32) { + true + } else { + match rustix::fs::fchown(file, Some(rustix::process::Uid::from_raw(self.uid)), Some(rustix::process::Gid::from_raw(self.gid))) { + Ok(()) => true, + Err(error) => { warnings.push(format!("owner {}:{} was not restored ({error}); the replacement is owned by the current user", self.uid, self.gid)); false } + } + }; + // The mode goes on after the owner, because a successful `chown` clears + // setuid and setgid. This one is fatal if it fails. + rustix::fs::fchmod(file, rustix::fs::Mode::from_bits_truncate((self.mode & 0o7777) as _)).map_err(|error| Failure::new("ACCESS_DENIED", format!("Mode {:04o} could not be preserved ({error}); nothing was published", self.mode & 0o7777)))?; + // The ACL goes on after the mode, because `chmod` rewrites an ACL's mask + // entry; applying the captured attribute last reproduces the original pair. + let acl = match &self.acl { + Some(acl) => { rustix::fs::fsetxattr(file, ACL_XATTR, acl, rustix::fs::XattrFlags::empty()).map_err(|error| Failure::new("ACCESS_DENIED", format!("POSIX ACL could not be preserved ({error}); nothing was published")))?; true } + None => { + // A new inode can inherit a parent's default ACL even when the old + // inode had no access ACL. Do not publish those inherited grants. + #[cfg(target_os = "linux")] + match rustix::fs::fremovexattr(file, ACL_XATTR) { + Ok(()) => (), + Err(error) if matches!(error, rustix::io::Errno::NODATA | rustix::io::Errno::NOTSUP) => (), + Err(error) => return Err(Failure::new("ACCESS_DENIED", format!("Inherited POSIX ACL could not be cleared ({error}); nothing was published"))), + } + false + }, + }; + let mut restored = Vec::new(); + for (name, value) in &self.xattrs { + match rustix::fs::fsetxattr(file, name.as_str(), value, rustix::fs::XattrFlags::empty()) { Ok(()) => restored.push(name.clone()), Err(error) => warnings.push(format!("extended attribute {name} was not restored ({error})")) } + } + let omitted = restored.len().saturating_sub(16); restored.truncate(16); + let omitted_warnings = warnings.len().saturating_sub(16); warnings.truncate(16); + if omitted_warnings > 0 { warnings.push(format!("{omitted_warnings} additional metadata warnings omitted")); } + Ok((json!({"mode": format!("{:04o}", self.mode & 0o7777), "owner": owner, "acl": acl, "xattrs": restored, "xattrsOmitted": omitted}), warnings)) + } +} + +/// Lock the stable per-path sidecar before reading the current target. +/// +/// Target-inode locks are insufficient across atomic rename: a waiter can retain +/// the old inode while the path already names a new one. Cooperating writers all +/// use this persistent lock inode, held until publication and directory sync. +/// Non-cooperating writers remain outside this advisory serialization contract. +/// +/// The sidecar is created *before* the target is read, so an operation refused +/// after the lock was taken leaves it behind. That is the declared behavior +/// (#240 P5), not an accident, and it cannot be removed: unlinking the sidecar +/// on release would let a waiter that already opened the old inode hold a lock +/// on an inode the path no longer names, while the next writer locks a fresh +/// file under the same name — two writers on one target. A zero-byte artifact +/// that can be named, counted and documented is strictly better than that hole. +/// +/// Which refusals have already taken the lock (and so leave the sidecar): a +/// stale `expectedRevision` on write/edit/copy/move/remove, an ambiguous or +/// overlapping `edit_file` edit, and a copy/move refused because the +/// destination exists. `replace` is the broad one: it locks every file its +/// `glob` selects before it knows whether a file holds a match, so a refusal +/// there — the `maxReplacements` ceiling, a `write_roots` fence, a revision +/// conflict while publishing — leaves a sidecar beside every candidate, matching +/// or not, and only a narrower `glob` bounds that. Refusals decided before the +/// lock — read-only mode, the credential fence, path validation — leave nothing. +/// +/// Declared lifecycle: +/// +/// - One file per distinct target *name* (lowercased), beside the target: +/// `.dsh-ops-update-.lock`, created mode 0600. Later operations +/// on the same name reuse that inode, so sidecars do not accumulate; one write +/// may leave at most one per file it locked. +/// - Nothing removes it automatically, and nothing should while a `dsh-ops` +/// writer may be active: deleting it is what re-opens the race above. An +/// operator may delete sidecars in a directory no writer is using; the next +/// write recreates whatever it needs. +/// - It stays invisible to every selection tool. The name starts with `.`, and +/// the walker drops any path with a hidden component (`hidden`) before a glob +/// can match it, so `ops_glob`, `ops_grep` and a directory-wide `ops_replace` +/// never return, count or rewrite one; `ops_batch` resolves its operations +/// from the request, never from a walk. +pub fn lock_and_read_for_update(file: &Path) -> Result<(std::fs::File, Vec)> { + let raw = file.to_str().ok_or_else(|| Failure::new("INVALID_ARGUMENT", "Non-UTF8 path"))?; + let checked = crate::file_path::checked_leaf(raw, true)?; + // The lock inode must survive replacement of the target inode. Keep a + // persistent, hidden sidecar per path; unlinking it after unlock would let + // another process lock a different inode under the same lock name. + let key = format!("{:x}", Sha256::digest(checked.name.to_lowercase().as_bytes())); + let lock_name = format!(".dsh-ops-update-{key}.lock"); + let lock = publication::open_update_lock(&checked.parent, &lock_name)?; + FileExt::try_lock_exclusive(&lock).map_err(|_| Failure::new("REVISION_CONFLICT", "another writer is updating this path; nothing was changed, retry"))?; + let bytes = read_bytes(file)?; + Ok((lock, bytes)) +} +/// Remove `..dsh-ops-` leftovers from earlier writers that were killed +/// before they could publish. +/// +/// A `SIGKILL` cannot run a cleanup path, so the debris cannot be prevented at the +/// point it is created. The next writer of the same file is the only place it can +/// be bounded, and it is a safe place: the caller holds the update lock, so no live +/// writer is using any of *this* file's names. Best-effort — a leftover that cannot +/// be removed must not fail the write that is about to happen. +/// +/// A leftover is identified by the whole shape this writer creates, not by its +/// prefix: the tail must parse as the v4 UUID `atomic_write` generates. The prefix +/// rule deleted files that merely began the same way — an operator's +/// `.notes.txt.dsh-ops-personal-backup`, and, for a target named `foo`, the live +/// temporary file of a *different* target (`foo.dsh-ops-bar`), whose writer holds a +/// lock on that other file and not on this one. Reported by @Haoming9527 in review +/// of #81. +fn sweep_debris(parent: &cap_std::fs::Dir, name: &str) { + let prefix = format!(".{name}.dsh-ops-"); + if let Ok(entries) = parent.entries() { + for entry in entries.flatten() { + if let Ok(leftover) = entry.file_name().into_string() { + let own = leftover.strip_prefix(&prefix).and_then(|tail| uuid::Uuid::parse_str(tail).ok()).is_some_and(|parsed| parsed.get_version_num() == 4); + if own { let _ = parent.remove_file(&leftover); } + } + } + } +} +pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result)>> { + let raw = path.to_str().ok_or_else(|| Failure::new("INVALID_ARGUMENT", "Non-UTF8 path"))?; + let checked = crate::file_path::checked(raw)?; + // Capture what the file carries before creating its replacement: publication is + // a rename, so the new inode arrives with none of it. + #[cfg(unix)] + let preserved = PreservedMetadata::of(&checked)?; + #[cfg(windows)] + let preserved = publication::Preserved::capture(&checked, true)?; + #[cfg(target_os = "macos")] + dsh_ops_darwin_metadata::require_no_extended_acl(&crate::file_path::read_file(&checked)?.into_std()) + .map_err(|error| Failure::new("METADATA_UNSUPPORTED", error))?; + let parent = &checked.parent; + let name = &checked.name; + sweep_debris(parent, name); + let temporary = format!(".{name}.dsh-ops-{}", uuid::Uuid::new_v4()); + let mut file = publication::create_sibling(parent, &temporary, Some(&checked))?; + // Keep content private until final metadata is restored. Creation mode also + // masks inherited ACL grants; never write sensitive bytes to a default 0666 inode. + let prepared = (|| -> Result)>> { + file.write_all(bytes)?; + #[cfg(any(unix, windows))] + let report = Some(preserved.restore(&file)?); + // Both bytes and restored mode/owner/ACL/xattrs must be durable before rename. + file.sync_all()?; + Ok(report) + })(); + let report = match prepared { + Ok(report) => report, + Err(failure) => { publication::discard_sibling(parent, &temporary, file)?; return Err(failure); } + }; + if let Err(failure) = parent.symlink_metadata(name).map_err(Failure::from).and_then(|metadata| crate::file_path::regular(&metadata, false)) { + publication::discard_sibling(parent, &temporary, file)?; + return Err(failure); + } + // Publication stays handle-relative and does not traverse links. + let publish = parent.rename(&temporary, parent, name); + if let Err(error) = publish { + publication::discard_sibling(parent, &temporary, file)?; + return Err(Failure::new("ACCESS_DENIED", format!("Atomic replacement publish: {error}"))); + } + drop(file); + #[cfg(windows)] + preserved.verify(parent, name)?; + #[cfg(unix)] + if let Err(error) = crate::sync_directory(parent) { + // The rename already happened, so the file *is* replaced. Reporting the + // default `effect_state: "none"` here would tell the caller that nothing + // changed, which is the one answer a caller must never get wrong: it would + // invite a repeat of a write that has already been applied. + let mut failure = Failure::new("SYNC_FAILED", format!("Atomic replacement published, but the directory entry could not be synced: {}", error.message)); + failure.phase = "publish".into(); + failure.effect_state = "applied".into(); + failure.next_action = "The replacement is already published; do not repeat it. Verify the file if durability matters.".into(); + return Err(failure); + } + Ok(report) +} + +#[path = "file_publication.rs"] +mod publication; + +#[path = "file_single.rs"] +mod single; +use single::content_facts; + + +pub use publication::{authorize_write, check_revision}; diff --git a/native/src/file_path.rs b/native/crates/ops-files/src/file_path.rs similarity index 100% rename from native/src/file_path.rs rename to native/crates/ops-files/src/file_path.rs diff --git a/native/src/file_publication.rs b/native/crates/ops-files/src/file_publication.rs similarity index 98% rename from native/src/file_publication.rs rename to native/crates/ops-files/src/file_publication.rs index 24a157a9..825ed091 100644 --- a/native/src/file_publication.rs +++ b/native/crates/ops-files/src/file_publication.rs @@ -31,7 +31,7 @@ pub(super) fn open_update_lock(parent: &Dir, name: &str) -> Result, expected: &str) -> Result<()> { +pub fn check_revision(bytes: Option<&[u8]>, expected: &str) -> Result<()> { if expected != "absent" && !(expected.len() == 64 && expected.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))) { return Err(Failure::new("INVALID_ARGUMENT", "expectedRevision must be absent or lowercase SHA-256")); } @@ -40,7 +40,7 @@ pub(super) fn check_revision(bytes: Option<&[u8]>, expected: &str) -> Result<()> Ok(()) } -pub(super) fn authorize_write(request: &Request, raw: &str) -> Result { +pub fn authorize_write(request: &Request, raw: &str) -> Result { if request.read_only { return Err(Failure::policy("read-only", "User authorization is read-only")); } let checked = crate::file_path::checked_leaf(raw, true)?; request.authorize_path(&checked.display)?; diff --git a/native/src/file_single.rs b/native/crates/ops-files/src/file_single.rs similarity index 100% rename from native/src/file_single.rs rename to native/crates/ops-files/src/file_single.rs diff --git a/native/crates/ops-files/src/lib.rs b/native/crates/ops-files/src/lib.rs new file mode 100644 index 00000000..c4749963 --- /dev/null +++ b/native/crates/ops-files/src/lib.rs @@ -0,0 +1,51 @@ +#![forbid(unsafe_code)] +use ops_protocol as contract; +mod file; +pub mod file_path; +pub mod paths; +pub use file::{dispatch, target, relative_display, read_bytes, result, lock_and_read_for_update, atomic_write, authorize_write, check_revision, MAX_OUTPUT_BYTES}; +#[cfg(unix)] +use cap_std::fs::Dir; +#[cfg(all(test, unix))] +use cap_std::ambient_authority; +#[cfg(unix)] +use contract::Result; + +/// Persist a directory's entries after an atomic publish. +/// +/// cap-std hands out `O_PATH` directory descriptors on Linux and `fsync` on an +/// `O_PATH` descriptor fails with `EBADF`. Reopening the same directory through +/// `/proc/self/fd` yields an ordinary descriptor, which is how cap-primitives +/// itself works around `O_PATH` handles elsewhere. +#[cfg(unix)] +pub fn sync_directory(dir: &Dir) -> Result<()> { + let handle = dir.try_clone()?.into_std_file(); + let first = match handle.sync_all() { + Ok(()) => return Ok(()), + Err(error) => error, + }; + #[cfg(target_os = "linux")] + { + use std::os::fd::AsRawFd; + if let Ok(reopened) = std::fs::File::open(format!("/proc/self/fd/{}", handle.as_raw_fd())) { + return reopened.sync_all().map_err(Into::into); + } + } + Err(first.into()) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + /// A crash-safe publish replays the barrier on the directory itself, so the + /// barrier has to work on the handle cap-std hands out. + #[test] + fn directory_sync_succeeds_on_ambient_directories() { + let root = std::env::temp_dir().join(format!("dsh-ops-sync-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&root).expect("create temporary directory"); + let dir = Dir::open_ambient_dir(&root, ambient_authority()).expect("open ambient directory"); + sync_directory(&dir).expect("directory sync"); + std::fs::remove_dir(&root).expect("remove temporary directory"); + } +} diff --git a/native/src/paths.rs b/native/crates/ops-files/src/paths.rs similarity index 100% rename from native/src/paths.rs rename to native/crates/ops-files/src/paths.rs diff --git a/native/crates/ops-protocol/Cargo.toml b/native/crates/ops-protocol/Cargo.toml new file mode 100644 index 00000000..376e32ac --- /dev/null +++ b/native/crates/ops-protocol/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ops-protocol" +version = "0.3.1" +edition = "2024" +license = "MIT" +rust-version = "1.88" +publish = false + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +regex = "1.12" diff --git a/native/src/contract.rs b/native/crates/ops-protocol/src/lib.rs similarity index 94% rename from native/src/contract.rs rename to native/crates/ops-protocol/src/lib.rs index 85d4fc33..eb8df107 100644 --- a/native/src/contract.rs +++ b/native/crates/ops-protocol/src/lib.rs @@ -1,4 +1,4 @@ -//! Typed operation contracts, independent of DSH transport and presentation. +#![forbid(unsafe_code)] use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -215,3 +215,19 @@ mod tests { } } } + +impl Job { + pub fn authorize(&self, request: &Request) -> Result<()> { + if self.version != 1 + || self.scope != request.scope + || self.root != request.root + || self.root_identity != request.root_identity + { + return Err(Failure::new( + "POLICY_DENIED", + "Job scope, root or version mismatch", + )); + } + Ok(()) + } +} diff --git a/native/crates/ops-results/Cargo.toml b/native/crates/ops-results/Cargo.toml new file mode 100644 index 00000000..a0174f40 --- /dev/null +++ b/native/crates/ops-results/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ops-results" +version = "0.3.1" +edition = "2024" +license = "MIT" +rust-version = "1.88" +publish = false + +[dependencies] +ops-protocol = { path = "../ops-protocol" } +ops-files = { path = "../ops-files" } +cap-std = "3.4" +cap-fs-ext = "3.4" +fs2 = "0.4.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10.9" +uuid = { version = "1.26", features = ["v4"] } diff --git a/native/crates/ops-results/src/lib.rs b/native/crates/ops-results/src/lib.rs new file mode 100644 index 00000000..6f34c4ce --- /dev/null +++ b/native/crates/ops-results/src/lib.rs @@ -0,0 +1,8 @@ +#![forbid(unsafe_code)] +use ops_protocol as contract; +use ops_files::paths; +#[cfg(unix)] +use ops_files::sync_directory; +pub mod store; +mod results; +pub use results::{run, referenced_items}; diff --git a/native/src/results.rs b/native/crates/ops-results/src/results.rs similarity index 99% rename from native/src/results.rs rename to native/crates/ops-results/src/results.rs index 5c66f93c..bf577612 100644 --- a/native/src/results.rs +++ b/native/crates/ops-results/src/results.rs @@ -1,6 +1,5 @@ //! One bounded result-reference protocol; no second REPL or query language. use crate::{ - batch, contract::*, store::{Store, now}, }; @@ -92,7 +91,7 @@ pub fn run(store: &Store, request: &Request) -> Result { Store::validate_id(id)?; let (items, expires) = if id.starts_with("j-") { let job: Job = store.read(id)?; - batch::authorize(&job, request)?; + job.authorize(request)?; if args.action == "release" { return Err(Failure::new("INVALID_ARGUMENT", "Job audits are read-only and cannot be released")); } diff --git a/native/src/store.rs b/native/crates/ops-results/src/store.rs similarity index 100% rename from native/src/store.rs rename to native/crates/ops-results/src/store.rs diff --git a/native/crates/ops-search/Cargo.toml b/native/crates/ops-search/Cargo.toml new file mode 100644 index 00000000..7add7be6 --- /dev/null +++ b/native/crates/ops-search/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ops-search" +version = "0.3.1" +edition = "2024" +license = "MIT" +rust-version = "1.88" +publish = false + +[dependencies] +ops-protocol = { path = "../ops-protocol" } +ops-files = { path = "../ops-files" } +cap-std = "3.4" +cap-fs-ext = "3.4" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +regex = "1.12" +sha2 = "0.10.9" + +[dev-dependencies] +uuid = { version = "1.26", features = ["v4"] } diff --git a/native/src/file.rs b/native/crates/ops-search/src/lib.rs similarity index 54% rename from native/src/file.rs rename to native/crates/ops-search/src/lib.rs index bcf5bae0..07189385 100644 --- a/native/src/file.rs +++ b/native/crates/ops-search/src/lib.rs @@ -1,46 +1,31 @@ -//! Native file-operation capabilities. All paths are host-resolved absolute paths; -//! this module never invokes a shell and never follows links. -use crate::contract::{Failure, Request, Result}; -use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt}; -use fs2::FileExt; +//! File search orchestration over the shared no-follow file primitives. +#![forbid(unsafe_code)] +use ops_protocol::{Failure, Request, Result}; +use ops_files::{file_path, target, relative_display, read_bytes, result, lock_and_read_for_update, atomic_write, MAX_OUTPUT_BYTES}; +use ops_files as publication; +use cap_fs_ext::DirExt; use regex::RegexBuilder; use serde::Deserialize; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; -use std::fs::{self}; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; +use std::{fs, path::{Path, PathBuf}}; #[cfg(windows)] use std::os::windows::fs::MetadataExt as WindowsMetadataExt; - const MAX_ENTRIES: usize = 100_000; const MAX_DEPTH: usize = 128; -const MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; -/// Ceiling for one worker response. The host collects worker stdout with -/// `maxBytes: 128 * 1024` and turns a lossy read into `OUTPUT_LIMIT` plus -/// `effectState: "unknown"`, so a response that overflows is reported to the model -/// as an operation whose effect cannot be known — for a pure read, that is both -/// false and useless. Staying under the host cap is what keeps a read honest. -/// The budget is spent on the *serialized* items rather than the payload, because -/// escaping is the multiplier: one NUL byte costs six as `\u0000`, which is how a -/// 32 MiB file once produced 200 MB of stdout. -const MAX_OUTPUT_BYTES: usize = 96 * 1024; -/// Non-item fields of the envelope, counted against the same ceiling. -const ENVELOPE_OVERHEAD: usize = 512; +fn one_hundred() -> usize { 100 } +fn yes() -> bool { true } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct InspectArgs { - path: String, - #[serde(default = "one")] - offset: usize, - #[serde(default = "two_hundred")] - limit: usize, - #[serde(default = "utf8")] - encoding: String, - #[serde(default = "text")] - view: String, +pub fn dispatch(request: &Request) -> Result { + if let Some(raw) = request.args.get("path").and_then(Value::as_str) { request.authorize_path(Path::new(raw))?; } + match request.tool.as_str() { + "glob" => glob(request), + "grep" => grep(request), + "replace" => replace(request), + _ => Err(Failure::new("INVALID_ARGUMENT", "Unknown search capability")), + }.map_err(|mut error| { error.message = format!("{}: {}", request.tool, error.message); error }) } + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct GlobArgs { @@ -92,31 +77,6 @@ struct ReplaceArgs { #[serde(default)] dry_run: bool, } -fn one() -> usize { 1 } -fn two_hundred() -> usize { 200 } -fn one_hundred() -> usize { 100 } -fn yes() -> bool { true } -fn utf8() -> String { "utf-8".into() } -fn text() -> String { "text".into() } - -pub fn dispatch(request: &Request) -> Result { - if let Some(raw) = request.args.get("path").and_then(Value::as_str) { request.authorize_path(Path::new(raw))?; } - match request.tool.as_str() { - "inspect_local_file" => inspect(request), - "glob" => glob(request), - "grep" => grep(request), - "replace" => replace(request), - "stat" | "diff" | "write_file" | "edit_file" | "copy" | "move" | "remove" => single::run(request), - _ => Err(Failure::new("INVALID_ARGUMENT", "Unknown file capability")), - }.map_err(|mut error| { error.message = format!("{}: {}", request.tool, error.message); error }) -} - -fn target(raw: &str, _allow_missing: bool) -> Result { - Ok(crate::file_path::checked(raw)?.display) -} -fn relative_display(root: &Path, path: &Path) -> String { - path.strip_prefix(root).unwrap_or(path).to_string_lossy().replace('\\', "/") -} fn hidden(path: &Path, root: &Path) -> bool { path.strip_prefix(root).ok().into_iter().flat_map(|p| p.components()).any(|c| c.as_os_str().to_string_lossy().starts_with('.')) } @@ -231,15 +191,6 @@ fn segment_match(pattern: &str, value: &str) -> bool { while pi < p.len() && p[pi] == b'*' { pi += 1; } pi == p.len() } -fn read_bytes(path: &Path) -> Result> { - let raw = path.to_str().ok_or_else(|| Failure::new("INVALID_ARGUMENT", "Non-UTF8 path"))?; - let target = crate::file_path::checked(raw).map_err(|mut error| { error.message = format!("read path validation: {}", error.message); error })?; - let mut file = crate::file_path::read_file(&target).map_err(|mut error| { error.message = format!("read open: {}", error.message); error })?; - let mut bytes = Vec::new(); - std::io::Read::by_ref(&mut file).take(MAX_FILE_BYTES + 1).read_to_end(&mut bytes)?; - if bytes.len() as u64 > MAX_FILE_BYTES { return Err(Failure::new("OUTPUT_LIMIT", "File exceeds the bounded read budget")); } - Ok(bytes) -} /// Modification time of `metadata` in whole milliseconds since the Unix epoch, /// or `None` when the platform will not report one. /// @@ -286,87 +237,6 @@ fn sort_by_modified(files: &mut [PathBuf]) { files.sort_by_key(|file| std::cmp:: fn details(file: &Path) -> Value { json!({"path":file,"bytes":fs::symlink_metadata(file).map(|metadata| metadata.len()).unwrap_or(0),"modified":modified(file)}) } -/// What one character costs in serde_json's encoding. -fn encoded_cost(character: char) -> usize { - match character { - '"' | '\\' | '\n' | '\r' | '\t' | '\u{8}' | '\u{c}' => 2, - c if (c as u32) < 0x20 => 6, - c => c.len_utf8(), - } -} -/// The longest prefix of `text` whose encoding fits `budget` bytes, cut on a -/// character boundary so the result is still valid UTF-8. -fn truncate_to_encoded(text: &str, budget: usize) -> (String, bool) { - let mut kept = String::new(); let mut used = 0usize; - for character in text.chars() { let cost = encoded_cost(character); if used + cost > budget { return (kept, true); } used += cost; kept.push(character); } - (kept, false) -} -/// `offset` is how many units were skipped, counted from zero the way `glob` and -/// `grep` count their own `offset`. `next_base` is the value a caller must pass -/// back to resume, which is its own paging base rather than the skipped count: -/// `inspect` declares a 1-based `offset` (like its hex rows), so deriving the -/// next page from the skipped count re-read the last returned line on every page. -fn result(items: Vec, total: usize, offset: usize, next_base: usize, matched: usize, truncated: bool) -> Value { - // Spend the response budget on the serialized items. Returning more than the - // host will collect is not a bigger answer, it is a failed read: the host - // truncates stdout, reports OUTPUT_LIMIT and declares the effect unknown, so - // the caller loses the content and is told something may have changed. - let budget = MAX_OUTPUT_BYTES.saturating_sub(ENVELOPE_OVERHEAD); - let mut kept: Vec = Vec::new(); let mut used = 0usize; let mut stopped = false; - for item in items { - let size = serde_json::to_string(&item).map_or(0, |encoded| encoded.len()) + 1; - if used + size <= budget { used += size; kept.push(item); continue; } - // This item does not fit whole. Keep a bounded prefix of its text rather - // than returning nothing at all, so a file holding one long line still - // answers with its beginning; every remaining item is omitted and counted - // as such. Reading past the cut is the hex view's job, which pages by - // 16-byte rows. - let whole = item.get("text").and_then(Value::as_str); - let room = budget.saturating_sub(used).saturating_sub(96); - let prefix = whole.map(|text| truncate_to_encoded(text, room)); - if let Some((text, cut)) = prefix { - if !text.is_empty() { - let mut trimmed = item.clone(); - trimmed["text"] = Value::String(text); - trimmed["bytesTotal"] = json!(whole.map_or(0, |text| text.len())); - trimmed["truncatedInLine"] = json!(cut); - kept.push(trimmed); - } - } - stopped = true; - break; - } - let returned = kept.len(); let cut = truncated || stopped; - json!({"status":"completed","phase":"verify","changed":0,"matched":matched,"skipped":offset,"failed":0,"complete":!cut && offset + returned >= total,"truncated":cut,"omitted":total.saturating_sub(offset + returned),"items":kept,"total":total,"nextOffset":next_base + returned}) -} - -fn inspect(request: &Request) -> Result { - let args: InspectArgs = serde_json::from_value(request.args.clone())?; - if args.limit == 0 || args.limit > 10_000 || !args.encoding.eq_ignore_ascii_case("utf-8") { return Err(Failure::new("INVALID_ARGUMENT", "inspect supports UTF-8 and a bounded limit")); } - let path = target(&args.path, false)?; - if !path.is_file() { return Err(Failure::new("INVALID_ARGUMENT", "path is a directory; use ops_glob or ops_grep")); } - let bytes = read_bytes(&path)?; - let facts = content_facts(&bytes); - if bytes.len() as u64 > MAX_FILE_BYTES { return Err(Failure::new("OUTPUT_LIMIT", "file exceeds the inspect size budget")); } - if args.view == "hex" { - // `offset` means the same thing in both views: the 1-based unit to start - // from — line N for text, 16-byte row N for hex. It used to count rows - // from zero here while the text branch counted lines from one, so the - // declared default of 1 skipped the first row, and the declared - // `minimum: 1` left no way to ask for it back: any file of at most 16 - // bytes read back as an empty string. In this view `skipped` and the item - // `offset` are byte counts, not row counts. - let start = args.offset.saturating_sub(1).saturating_mul(16).min(bytes.len()); let end = (start + args.limit.min(1900) * 16).min(bytes.len()); - let text = bytes[start..end].iter().map(|b| format!("{b:02x}")).collect::>().join(" "); - return Ok(json!({"status":"completed","phase":"verify","changed":0,"matched":1,"skipped":start,"failed":0,"complete":end == bytes.len(),"truncated":end < bytes.len(),"nextOffset":end.div_ceil(16)+1,"omitted":bytes.len().saturating_sub(end),"bytesTotal":bytes.len(),"revision":facts["revision"],"bytes":facts["bytes"],"lineCount":facts["lineCount"],"items":[{"path":path,"view":"hex","offset":start,"text":text}]})); - } - if args.view != "text" { return Err(Failure::new("INVALID_ARGUMENT", "view must be text or hex")); } - let text = String::from_utf8(bytes).map_err(|_| Failure::new("INVALID_ARGUMENT", "file is not valid UTF-8"))?; - let lines: Vec<_> = text.lines().enumerate().skip(args.offset.saturating_sub(1)).take(args.limit).map(|(i, line)| json!({"path":path,"line":i+1,"text":line})).collect(); - let mut output = result(lines, text.lines().count(), args.offset.saturating_sub(1), args.offset, text.lines().count(), false); - for key in ["revision", "bytes", "lineCount"] { output[key] = facts[key].clone(); } - Ok(output) -} fn glob(request: &Request) -> Result { let args: GlobArgs = serde_json::from_value(request.args.clone())?; if args.limit == 0 || args.limit > 10_000 { return Err(Failure::new("INVALID_ARGUMENT", "glob limit is 1-10000")); } @@ -521,270 +391,6 @@ fn replace(request: &Request) -> Result { output["skippedPaths"] = if serde_json::to_vec(&output)?.len() < MAX_OUTPUT_BYTES / 2 { json!(skipped_paths) } else { json!([]) }; Ok(output) } -/// A POSIX ACL is exposed as this attribute, so preserving ACLs needs no libacl. -#[cfg(unix)] -const ACL_XATTR: &str = "system.posix_acl_access"; - -/// What the file being replaced carried, captured before its replacement exists. -/// -/// `atomic_write` publishes a *new* inode, and a new inode arrives with none of -/// the old one's metadata: the mode, the owner, the POSIX ACL and every extended -/// attribute belong to the file that is about to be unlinked. So a `0600` `.env` -/// came back world-readable, a `0755` script lost its execute bit and an ACL was -/// dropped outright — the replacement was more permissive than what it replaced, -/// which is the one direction a file tool must never drift in silently. -/// -/// Capture and restore are judged separately. A *capture* that cannot be completed -/// — the attribute list, the ACL, or any single value — aborts the call before -/// anything is published: a value that was never read cannot be put back, so the -/// tool would be guessing at what it is about to destroy. Only a *restore* failure -/// is downgraded to a warning, and only for the attributes listed as best-effort -/// below. -/// -/// Policy, by severity: -/// -/// - The mode is **not optional**. If it cannot be reapplied the call fails and -/// nothing is published; guessing at permissions widens access. -/// - A POSIX ACL is not optional either. A file that had one and comes back -/// without it has lost entries, and losing a *deny* entry is a widening change. -/// - On restore, the owner and the remaining extended attributes are best-effort -/// and are reported in `metadataWarnings`; they cannot be used as a substitute -/// for the two above, and a caller can act on a warning in a way it cannot act on -/// a silent change. -#[cfg(unix)] -struct PreservedMetadata { mode: u32, uid: u32, gid: u32, acl: Option>, xattrs: Vec<(String, Vec)> } - -/// Read one extended attribute, or `None` when it is absent or unsupported. -#[cfg(unix)] -fn get_xattr(fd: &impl rustix::fd::AsFd, name: &str) -> Result>> { - let mut probe: [u8; 0] = []; - match rustix::fs::fgetxattr(fd, name, &mut probe[..]) { - Ok(size) => { - let mut buffer = vec![0u8; size]; - let written = rustix::fs::fgetxattr(fd, name, &mut buffer[..]).map_err(|error| Failure::new("ACCESS_DENIED", format!("Read extended attribute {name}: {error}")))?; - buffer.truncate(written); - Ok(Some(buffer)) - } - Err(error) if matches!(error, rustix::io::Errno::NODATA | rustix::io::Errno::NOTSUP) - || (cfg!(target_os = "macos") && error == rustix::io::Errno::from_raw_os_error(93)) => Ok(None), - Err(error) => Err(Failure::new("ACCESS_DENIED", format!("Read extended attribute {name}: {error}"))), - } -} - -/// Every attribute name on the file. An empty list is not an error: a filesystem -/// that carries none answers `ENOTSUP`. -#[cfg(unix)] -fn list_xattrs(fd: &impl rustix::fd::AsFd) -> Result> { - let mut probe: [u8; 0] = []; - let size = match rustix::fs::flistxattr(fd, &mut probe[..]) { - Ok(size) => size, - Err(error) if error == rustix::io::Errno::NOTSUP => return Ok(Vec::new()), - Err(error) => return Err(Failure::new("ACCESS_DENIED", format!("List extended attributes: {error}"))), - }; - let mut buffer = vec![0u8; size]; - let written = rustix::fs::flistxattr(fd, &mut buffer[..]).map_err(|error| Failure::new("ACCESS_DENIED", format!("List extended attributes: {error}")))?; - buffer.truncate(written); - Ok(buffer.split(|byte| *byte == 0).filter(|name| !name.is_empty()).map(|name| String::from_utf8_lossy(name).into_owned()).collect()) -} - -#[cfg(unix)] -impl PreservedMetadata { - fn of(target: &crate::file_path::Target) -> Result { - let file = crate::file_path::read_file(target)?; - let stat = rustix::fs::fstat(&file).map_err(|error| Failure::new("ACCESS_DENIED", format!("Stat {}: {error}", target.display.display())))?; - let mut xattrs = Vec::new(); - for name in list_xattrs(&file)? { if name == ACL_XATTR { continue } if let Some(value) = get_xattr(&file, &name)? { xattrs.push((name, value)) } } - Ok(Self { mode: stat.st_mode as u32, uid: stat.st_uid as u32, gid: stat.st_gid as u32, acl: get_xattr(&file, ACL_XATTR)?, xattrs }) - } - /// Reapply onto the replacement. `Err` forbids publication; `Ok` may still - /// carry warnings for whatever could not be restored. - fn restore(&self, file: &cap_std::fs::File) -> Result<(Value, Vec)> { - let mut warnings = Vec::new(); - let current = rustix::fs::fstat(file).map_err(|error| Failure::new("ACCESS_DENIED", format!("Stat replacement: {error}")))?; - let owner = if (self.uid, self.gid) == (current.st_uid as u32, current.st_gid as u32) { - true - } else { - match rustix::fs::fchown(file, Some(rustix::process::Uid::from_raw(self.uid)), Some(rustix::process::Gid::from_raw(self.gid))) { - Ok(()) => true, - Err(error) => { warnings.push(format!("owner {}:{} was not restored ({error}); the replacement is owned by the current user", self.uid, self.gid)); false } - } - }; - // The mode goes on after the owner, because a successful `chown` clears - // setuid and setgid. This one is fatal if it fails. - rustix::fs::fchmod(file, rustix::fs::Mode::from_bits_truncate((self.mode & 0o7777) as _)).map_err(|error| Failure::new("ACCESS_DENIED", format!("Mode {:04o} could not be preserved ({error}); nothing was published", self.mode & 0o7777)))?; - // The ACL goes on after the mode, because `chmod` rewrites an ACL's mask - // entry; applying the captured attribute last reproduces the original pair. - let acl = match &self.acl { - Some(acl) => { rustix::fs::fsetxattr(file, ACL_XATTR, acl, rustix::fs::XattrFlags::empty()).map_err(|error| Failure::new("ACCESS_DENIED", format!("POSIX ACL could not be preserved ({error}); nothing was published")))?; true } - None => { - // A new inode can inherit a parent's default ACL even when the old - // inode had no access ACL. Do not publish those inherited grants. - #[cfg(target_os = "linux")] - match rustix::fs::fremovexattr(file, ACL_XATTR) { - Ok(()) => (), - Err(error) if matches!(error, rustix::io::Errno::NODATA | rustix::io::Errno::NOTSUP) => (), - Err(error) => return Err(Failure::new("ACCESS_DENIED", format!("Inherited POSIX ACL could not be cleared ({error}); nothing was published"))), - } - false - }, - }; - let mut restored = Vec::new(); - for (name, value) in &self.xattrs { - match rustix::fs::fsetxattr(file, name.as_str(), value, rustix::fs::XattrFlags::empty()) { Ok(()) => restored.push(name.clone()), Err(error) => warnings.push(format!("extended attribute {name} was not restored ({error})")) } - } - let omitted = restored.len().saturating_sub(16); restored.truncate(16); - let omitted_warnings = warnings.len().saturating_sub(16); warnings.truncate(16); - if omitted_warnings > 0 { warnings.push(format!("{omitted_warnings} additional metadata warnings omitted")); } - Ok((json!({"mode": format!("{:04o}", self.mode & 0o7777), "owner": owner, "acl": acl, "xattrs": restored, "xattrsOmitted": omitted}), warnings)) - } -} - -/// Lock the stable per-path sidecar before reading the current target. -/// -/// Target-inode locks are insufficient across atomic rename: a waiter can retain -/// the old inode while the path already names a new one. Cooperating writers all -/// use this persistent lock inode, held until publication and directory sync. -/// Non-cooperating writers remain outside this advisory serialization contract. -/// -/// The sidecar is created *before* the target is read, so an operation refused -/// after the lock was taken leaves it behind. That is the declared behavior -/// (#240 P5), not an accident, and it cannot be removed: unlinking the sidecar -/// on release would let a waiter that already opened the old inode hold a lock -/// on an inode the path no longer names, while the next writer locks a fresh -/// file under the same name — two writers on one target. A zero-byte artifact -/// that can be named, counted and documented is strictly better than that hole. -/// -/// Which refusals have already taken the lock (and so leave the sidecar): a -/// stale `expectedRevision` on write/edit/copy/move/remove, an ambiguous or -/// overlapping `edit_file` edit, and a copy/move refused because the -/// destination exists. `replace` is the broad one: it locks every file its -/// `glob` selects before it knows whether a file holds a match, so a refusal -/// there — the `maxReplacements` ceiling, a `write_roots` fence, a revision -/// conflict while publishing — leaves a sidecar beside every candidate, matching -/// or not, and only a narrower `glob` bounds that. Refusals decided before the -/// lock — read-only mode, the credential fence, path validation — leave nothing. -/// -/// Declared lifecycle: -/// -/// - One file per distinct target *name* (lowercased), beside the target: -/// `.dsh-ops-update-.lock`, created mode 0600. Later operations -/// on the same name reuse that inode, so sidecars do not accumulate; one write -/// may leave at most one per file it locked. -/// - Nothing removes it automatically, and nothing should while a `dsh-ops` -/// writer may be active: deleting it is what re-opens the race above. An -/// operator may delete sidecars in a directory no writer is using; the next -/// write recreates whatever it needs. -/// - It stays invisible to every selection tool. The name starts with `.`, and -/// the walker drops any path with a hidden component (`hidden`) before a glob -/// can match it, so `ops_glob`, `ops_grep` and a directory-wide `ops_replace` -/// never return, count or rewrite one; `ops_batch` resolves its operations -/// from the request, never from a walk. -fn lock_and_read_for_update(file: &Path) -> Result<(std::fs::File, Vec)> { - let raw = file.to_str().ok_or_else(|| Failure::new("INVALID_ARGUMENT", "Non-UTF8 path"))?; - let checked = crate::file_path::checked_leaf(raw, true)?; - // The lock inode must survive replacement of the target inode. Keep a - // persistent, hidden sidecar per path; unlinking it after unlock would let - // another process lock a different inode under the same lock name. - let key = format!("{:x}", Sha256::digest(checked.name.to_lowercase().as_bytes())); - let lock_name = format!(".dsh-ops-update-{key}.lock"); - let lock = publication::open_update_lock(&checked.parent, &lock_name)?; - FileExt::try_lock_exclusive(&lock).map_err(|_| Failure::new("REVISION_CONFLICT", "another writer is updating this path; nothing was changed, retry"))?; - let bytes = read_bytes(file)?; - Ok((lock, bytes)) -} -/// Remove `..dsh-ops-` leftovers from earlier writers that were killed -/// before they could publish. -/// -/// A `SIGKILL` cannot run a cleanup path, so the debris cannot be prevented at the -/// point it is created. The next writer of the same file is the only place it can -/// be bounded, and it is a safe place: the caller holds the update lock, so no live -/// writer is using any of *this* file's names. Best-effort — a leftover that cannot -/// be removed must not fail the write that is about to happen. -/// -/// A leftover is identified by the whole shape this writer creates, not by its -/// prefix: the tail must parse as the v4 UUID `atomic_write` generates. The prefix -/// rule deleted files that merely began the same way — an operator's -/// `.notes.txt.dsh-ops-personal-backup`, and, for a target named `foo`, the live -/// temporary file of a *different* target (`foo.dsh-ops-bar`), whose writer holds a -/// lock on that other file and not on this one. Reported by @Haoming9527 in review -/// of #81. -fn sweep_debris(parent: &cap_std::fs::Dir, name: &str) { - let prefix = format!(".{name}.dsh-ops-"); - if let Ok(entries) = parent.entries() { - for entry in entries.flatten() { - if let Ok(leftover) = entry.file_name().into_string() { - let own = leftover.strip_prefix(&prefix).and_then(|tail| uuid::Uuid::parse_str(tail).ok()).is_some_and(|parsed| parsed.get_version_num() == 4); - if own { let _ = parent.remove_file(&leftover); } - } - } - } -} -fn atomic_write(path: &Path, bytes: &[u8]) -> Result)>> { - let raw = path.to_str().ok_or_else(|| Failure::new("INVALID_ARGUMENT", "Non-UTF8 path"))?; - let checked = crate::file_path::checked(raw)?; - // Capture what the file carries before creating its replacement: publication is - // a rename, so the new inode arrives with none of it. - #[cfg(unix)] - let preserved = PreservedMetadata::of(&checked)?; - #[cfg(windows)] - let preserved = publication::Preserved::capture(&checked, true)?; - #[cfg(target_os = "macos")] - dsh_ops_darwin_metadata::require_no_extended_acl(&crate::file_path::read_file(&checked)?.into_std()) - .map_err(|error| Failure::new("METADATA_UNSUPPORTED", error))?; - let parent = &checked.parent; - let name = &checked.name; - sweep_debris(parent, name); - let temporary = format!(".{name}.dsh-ops-{}", uuid::Uuid::new_v4()); - let mut file = publication::create_sibling(parent, &temporary, Some(&checked))?; - // Keep content private until final metadata is restored. Creation mode also - // masks inherited ACL grants; never write sensitive bytes to a default 0666 inode. - let prepared = (|| -> Result)>> { - file.write_all(bytes)?; - #[cfg(any(unix, windows))] - let report = Some(preserved.restore(&file)?); - // Both bytes and restored mode/owner/ACL/xattrs must be durable before rename. - file.sync_all()?; - Ok(report) - })(); - let report = match prepared { - Ok(report) => report, - Err(failure) => { publication::discard_sibling(parent, &temporary, file)?; return Err(failure); } - }; - if let Err(failure) = parent.symlink_metadata(name).map_err(Failure::from).and_then(|metadata| crate::file_path::regular(&metadata, false)) { - publication::discard_sibling(parent, &temporary, file)?; - return Err(failure); - } - // Publication stays handle-relative and does not traverse links. - let publish = parent.rename(&temporary, parent, name); - if let Err(error) = publish { - publication::discard_sibling(parent, &temporary, file)?; - return Err(Failure::new("ACCESS_DENIED", format!("Atomic replacement publish: {error}"))); - } - drop(file); - #[cfg(windows)] - preserved.verify(parent, name)?; - #[cfg(unix)] - if let Err(error) = crate::sync_directory(parent) { - // The rename already happened, so the file *is* replaced. Reporting the - // default `effect_state: "none"` here would tell the caller that nothing - // changed, which is the one answer a caller must never get wrong: it would - // invite a repeat of a write that has already been applied. - let mut failure = Failure::new("SYNC_FAILED", format!("Atomic replacement published, but the directory entry could not be synced: {}", error.message)); - failure.phase = "publish".into(); - failure.effect_state = "applied".into(); - failure.next_action = "The replacement is already published; do not repeat it. Verify the file if durability matters.".into(); - return Err(failure); - } - Ok(report) -} - -#[path = "file_publication.rs"] -mod publication; - -#[path = "file_single.rs"] -mod single; -use single::content_facts; - #[cfg(test)] mod tests { use super::*; diff --git a/native/src/lib.rs b/native/src/lib.rs index 78a23ba7..f3938bb0 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -1,54 +1,9 @@ #![forbid(unsafe_code)] -mod batch; -pub mod contract; -mod file; -mod file_path; -mod paths; -mod results; -mod store; +pub use ops_protocol as contract; use cap_std::{ambient_authority, fs::Dir}; use contract::*; use serde_json::Value; -/// Persist a directory's entries after an atomic publish. -/// -/// cap-std hands out `O_PATH` directory descriptors on Linux and `fsync` on an -/// `O_PATH` descriptor fails with `EBADF`. Reopening the same directory through -/// `/proc/self/fd` yields an ordinary descriptor, which is how cap-primitives -/// itself works around `O_PATH` handles elsewhere. -#[cfg(unix)] -pub(crate) fn sync_directory(dir: &Dir) -> Result<()> { - let handle = dir.try_clone()?.into_std_file(); - let first = match handle.sync_all() { - Ok(()) => return Ok(()), - Err(error) => error, - }; - #[cfg(target_os = "linux")] - { - use std::os::fd::AsRawFd; - if let Ok(reopened) = std::fs::File::open(format!("/proc/self/fd/{}", handle.as_raw_fd())) { - return reopened.sync_all().map_err(Into::into); - } - } - Err(first.into()) -} - -#[cfg(all(test, unix))] -mod tests { - use super::*; - - /// A crash-safe publish replays the barrier on the directory itself, so the - /// barrier has to work on the handle cap-std hands out. - #[test] - fn directory_sync_succeeds_on_ambient_directories() { - let root = std::env::temp_dir().join(format!("dsh-ops-sync-{}", uuid::Uuid::new_v4())); - std::fs::create_dir(&root).expect("create temporary directory"); - let dir = Dir::open_ambient_dir(&root, ambient_authority()).expect("open ambient directory"); - sync_directory(&dir).expect("directory sync"); - std::fs::remove_dir(&root).expect("remove temporary directory"); - } -} - pub fn dispatch(request: Request) -> Result { if request.scope.is_empty() || request.scope.len() > 256 @@ -72,7 +27,11 @@ pub fn dispatch(request: Request) -> Result { // from mutation audit records. if matches!(request.tool.as_str(), "inspect_local_file" | "glob" | "grep" | "replace" | "write_file" | "edit_file" | "diff" | "stat" | "copy" | "move" | "remove") { request.root_identity = String::new(); - return file::dispatch(&request); + return if matches!(request.tool.as_str(), "glob" | "grep" | "replace") { + ops_search::dispatch(&request) + } else { + ops_files::dispatch(&request) + }; } let dir = Dir::open_ambient_dir(root, ambient_authority())?; use cap_fs_ext::MetadataExt; @@ -87,10 +46,10 @@ pub fn dispatch(request: Request) -> Result { "Read-only requests cannot mutate audit or result storage", )); } - let store = store::Store::open(&dir, request.read_only)?; + let store = ops_results::store::Store::open(&dir, request.read_only)?; match request.tool.as_str() { - "batch" => batch::run(&dir, &store, &request), - "result" => results::run(&store, &request), + "batch" => ops_batch::run(&dir, &store, &request), + "result" => ops_results::run(&store, &request), _ => Err(Failure::new("INVALID_ARGUMENT", "Unknown capability")), } } diff --git a/package.json b/package.json index 942036b4..61febc8e 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "native/Cargo.toml", "native/Cargo.lock", "native/src", + "native/crates", "native/windows-metadata", "native/darwin-metadata", "native/bash-ipc" diff --git a/scripts/verify-gate.mjs b/scripts/verify-gate.mjs index 668cbea7..0158a96d 100644 --- a/scripts/verify-gate.mjs +++ b/scripts/verify-gate.mjs @@ -181,7 +181,7 @@ async function main() { // The native build/staging CLI imports lib, just like the validator and suites. await run('build the plugin', 'npm', ['run', 'build']) - await run('native worker unit tests', 'cargo', ['test', '--locked', '--manifest-path', 'native/Cargo.toml']) + await run('native worker unit tests', 'cargo', ['test', '--workspace', '--locked', '--manifest-path', 'native/Cargo.toml']) await run('build and stage the native worker', 'npm', ['run', 'build:ptc']) if (TARGET === 'win32-x64') await run('Windows IPC payloads', 'npm', ['run', 'build:bash-ipc']) diff --git a/test/bench-setup-preflight.test.mjs b/test/bench-setup-preflight.test.mjs index eee51e8f..ebaf7e49 100644 --- a/test/bench-setup-preflight.test.mjs +++ b/test/bench-setup-preflight.test.mjs @@ -255,6 +255,26 @@ await test('the local-build preflight names the one rebuild command and judges w assert.match(stale.warnings[0], /native[\\/]src[\\/]lib\.rs/) assert.match(stale.warnings[0], /npm run build:ptc/) + // Workspace crates and platform adapters are equally capable of making a staged worker stale. + for (const relative of ['crates/ops-files/src/lib.rs', 'windows-metadata/src/lib.rs', 'darwin-metadata/src/lib.rs']) { + const file = path.join(dir, 'native', relative) + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, '// workspace source\n') + const future = new Date(Date.now() + 60000) + fs.utimesSync(file, future, future) + const changed = inspectLocalBuild({ root: dir, platform: 'linux', arch: 'x64' }) + assert.ok(changed.warnings.some(warning => warning.includes(path.relative(dir, file)))) + fs.utimesSync(file, past, past) + } + + fs.utimesSync(path.join(dir, 'native', 'src', 'lib.rs'), past, past) + const cache = path.join(dir, 'native', 'crates', 'ops-files', 'target', 'debug', 'cache') + fs.mkdirSync(path.dirname(cache), { recursive: true }) + fs.writeFileSync(cache, 'build output') + const staged = new Date('2020-01-01T00:00:00Z') + fs.utimesSync(path.join(dir, 'runtime', 'linux-x64', workerName('linux')), staged, staged) + assert.deepEqual(inspectLocalBuild({ root: dir, platform: 'linux', arch: 'x64' }).warnings, []) + // An unsupported target is named as unsupported, not reported as a missing build. assert.match(inspectLocalBuild({ root: dir, platform: 'win32', arch: 'arm64' }).problems[0].what, /support matrix/) diff --git a/test/package-source.test.mjs b/test/package-source.test.mjs index 5cb1706b..ce242689 100644 --- a/test/package-source.test.mjs +++ b/test/package-source.test.mjs @@ -18,11 +18,21 @@ await test('npm pack includes compiled host code and native sources, never host for (const name of required) assert.ok(files.includes(name), `missing source-build dependency: ${name}`) assert.equal(files.some(name => name.startsWith('src/')), false, 'host source must not ship beside compiled lib/') assert.equal(files.some(name => /^native\/(?:target[^/]*|(?:windows|darwin)-metadata\/target)\//u.test(name)), false) - const manifest = fs.readFileSync(path.join(PACKAGE_ROOT, 'native/Cargo.toml'), 'utf8') - for (const match of manifest.matchAll(/^path\s*=\s*"([^"]+)"/gmu)) { - // src/lib.rs is a file; the adapter dependency is a directory with a manifest. - const relative = `native/${match[1]}` - assert.ok(files.includes(relative) || files.includes(`${relative}/Cargo.toml`), `unpacked Cargo path is missing: ${relative}`) + const pending = ['native/Cargo.toml'], visited = new Set() + while (pending.length) { + const name = pending.pop() + if (visited.has(name)) continue + visited.add(name) + assert.ok(files.includes(name), `unpacked Cargo manifest is missing: ${name}`) + const manifest = fs.readFileSync(path.join(PACKAGE_ROOT, name), 'utf8') + const source = path.posix.join(path.posix.dirname(name), 'src/lib.rs') + assert.ok(files.includes(source), `unpacked crate source is missing: ${source}`) + for (const match of manifest.matchAll(/\bpath\s*=\s*"([^"]+)"/gu)) { + const relative = path.posix.normalize(path.posix.join(path.posix.dirname(name), match[1])) + assert.ok(relative.startsWith('native/'), 'path dependencies stay within the native workspace') + if (fs.statSync(path.join(PACKAGE_ROOT, relative)).isDirectory()) pending.push(`${relative}/Cargo.toml`) + else assert.ok(files.includes(relative), `unpacked Cargo path is missing: ${relative}`) + } } }) report('package-source') From e6379270b9d1aa710570d927edae86b614903cbe Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:10:03 +0800 Subject: [PATCH 003/118] refactor: centralize typed capability and outcome contracts --- AGENTS.md | 2 +- docs/CHANGELOG.md | 9 +++ docs/CONTRIBUTING.md | 10 ++- ...41\347\214\256\346\214\207\345\215\227.md" | 4 +- package-lock.json | 18 +++++ package.json | 1 + .../contract.js => capabilities/file.ts} | 58 +++------------ src/{ptc/contract.js => capabilities/ptc.ts} | 23 +++--- src/capabilities/registry.ts | 41 +++++++++++ src/capabilities/types.ts | 24 +++++++ src/file/contract.ts | 49 +++++++++++++ src/kernel/errors.ts | 43 +++++++++++ src/kernel/outcome.ts | 47 ++++++++++++ src/{policy.js => policy.ts} | 29 +++++--- src/ptc/contract.ts | 15 ++++ src/shells/constants.js | 4 +- test/build.test.mjs | 6 +- test/capabilities.test.mjs | 72 +++++++++++++++++++ test/kernel-errors.test.mjs | 23 ++++++ test/kernel-types.test.mjs | 10 +++ test/types/kernel.ts | 41 +++++++++++ test/types/tsconfig.json | 10 +++ tsconfig.json | 2 +- 23 files changed, 460 insertions(+), 81 deletions(-) rename src/{file/contract.js => capabilities/file.ts} (75%) rename src/{ptc/contract.js => capabilities/ptc.ts} (83%) create mode 100644 src/capabilities/registry.ts create mode 100644 src/capabilities/types.ts create mode 100644 src/file/contract.ts create mode 100644 src/kernel/errors.ts create mode 100644 src/kernel/outcome.ts rename src/{policy.js => policy.ts} (86%) create mode 100644 src/ptc/contract.ts create mode 100644 test/capabilities.test.mjs create mode 100644 test/kernel-errors.test.mjs create mode 100644 test/kernel-types.test.mjs create mode 100644 test/types/kernel.ts create mode 100644 test/types/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 27f0c19a..c123430b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ node scripts/verify-gate.mjs --skip-install # the full release gate - English for code, comments, CLI output and commit messages; user-facing docs in both languages, updated in the same PR. -- Tool names always come from `publicToolName()` in `src/policy.js`; never hand-write `ops_`. +- Tool names always come from `publicToolName()` in `src/policy.ts`; never hand-write `ops_`. - Config is validated in `src/config.js`; unknown keys are errors that name the key. Defaults in `cordis.patch.yml` must equal the defaults in code. - The host half must not import `@deepseek-ai/*` values at load time; use `ctx.inject` / `ctx.get`. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b31e5efb..2971cf5f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,15 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Centralize capability metadata and prepare typed kernel contracts (#261) + +- Move policy and file/PTC contracts to TypeScript, with one capability catalogue for names, + ownership, presentation availability, concurrency and schemas. Existing registrations, tool + descriptions and output rendering remain compatible. +- Add typed Outcome, search coverage and result-reference contracts plus explicit error/retry + policies for subsequent adapter slices. Incomplete searches cannot claim a known total. +- Pin Node 22 declarations and check valid/invalid contracts in the regular test runner. + ### Build plugin source into the existing JavaScript package layout (#261) - Move plugin source to `src/` and introduce the TypeScript build with JavaScript migration diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index f829652f..a5ae00fd 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -137,6 +137,14 @@ CI runs on every pull request and every push to `main`; a PR must be green on al - **No retries to hide flakes.** A concurrency assertion that fails intermittently is a bug in the implementation; fix the cause. Retries in the gate (#164) only ever cover suite timeouts. +Capability names, component ownership, presentation modes, mutation/concurrency flags and file/PTC +schemas live in `src/capabilities/`. Keep new capability metadata there; adapters own registration +and rendering. `src/kernel/` defines the 0.4 `Outcome`, coverage/search/reference types and +error-to-retry policies. Their later adapter migrations will change wire results; this foundation +preserves existing output. `node test/run.mjs capabilities kernel-errors kernel-types` checks the +catalogue, retry rules and positive/negative TypeScript contracts. Node declarations are pinned to +`@types/node@22.20.5` (published 2026-10-01) to type the supported Node 22 API without runtime code. + ## Code and documentation conventions - **Language:** code, comments, CLI output and commit messages are English. User-facing docs are @@ -146,7 +154,7 @@ CI runs on every pull request and every push to `main`; a PR must be green on al - **File size:** keep new files under about 500 lines. If a file grows past that, split it into modules and group related modules in a folder, following the existing `src/components/*` and `test/lib/` layout. -- **Tool names** always come from `publicToolName()` in `src/policy.js`; never hand-write the `ops_` +- **Tool names** always come from `publicToolName()` in `src/policy.ts`; never hand-write the `ops_` prefix. - **Config validation** lives in `src/config.js`; an unknown key is an error that names the key. Defaults in `cordis.patch.yml` must match the defaults in code. diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 0b841279..3905f33e 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -95,12 +95,14 @@ npm run build:bash-ipc # 仅 Windows:Bash IPC - **不得静默跳过。** 整个套件只能通过 `test/lib/harness.mjs` 的 `skipSuite()` 跳过,且必须在允许清单内。某条检查的夹具在某宿主上无法建立时,要打印具名的 `... fixture unavailable: <原因>`,而不是悄悄通过。 - **不得靠重试掩盖偶发。** 时好时坏的并发断言是实现的缺陷,要修根因。门禁的重试(#164)只覆盖套件超时。 +能力名称、所属组件、呈现模式、变更/并发标记以及文件与 PTC schema 统一存放在 `src/capabilities/`;新增能力元数据应在此维护,适配层负责注册与渲染。`src/kernel/` 定义 0.4 的 `Outcome`、覆盖率/搜索/引用类型及错误重试策略,后续适配层迁移才会调整线上返回结构,本次基础切片保留现有输出。运行 `node test/run.mjs capabilities kernel-errors kernel-types` 验证目录、重试规则和 TypeScript 正反类型断言。Node 声明固定为 `@types/node@22.20.5`(2026-10-01 发布),用于受支持的 Node 22 API 类型,不引入运行时代码。 + ## 代码与文档约定 - **语言**:代码、注释、CLI 输出、提交信息一律英文;对外文档中英双语;面向维护者的说明可用中文。 - **文档成对**:`docs/` 下同一文档中英各一份,例如 `docs/usage.md` 与 `docs/使用指南.md`,同一个 PR 里一起更新。 - **文件规模**:新文件保持在约 500 行以内。文件超过这个规模时,拆成模块,并把相关模块归到一个文件夹中,参照现有的 `src/components/*` 与 `test/lib/` 布局。 -- **工具名**:一律经 `src/policy.js` 的 `publicToolName()` 生成,不要手写 `ops_` 前缀。 +- **工具名**:一律经 `src/policy.ts` 的 `publicToolName()` 生成,不要手写 `ops_` 前缀。 - **配置校验**:放在 `src/config.js`,未知键必须报错并点名;`cordis.patch.yml` 里的默认值必须与代码默认值一致。 - **解析与探测**:同步放在 `apply` 里;`required: true` 必须在加载期拒绝激活。 diff --git a/package-lock.json b/package-lock.json index 256ea210..74dfb0f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "@deepseek-ai/dsh-tool-fs-search": "0.2.0-rc.2", "@deepseek-ai/dsh-tools": "0.2.0-rc.2", "@dsh-std/manifest": "0.1.1-rc.3", + "@types/node": "22.20.5", "typescript": "6.0.3", "yaml": "^2.9.1" }, @@ -1043,6 +1044,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@vscode/ripgrep": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.18.0.tgz", @@ -1343,6 +1354,13 @@ "node": ">=22.19.0" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/yaml": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", diff --git a/package.json b/package.json index 942036b4..146497e6 100644 --- a/package.json +++ b/package.json @@ -161,6 +161,7 @@ "@deepseek-ai/dsh-tool-fs-search": "0.2.0-rc.2", "@deepseek-ai/dsh-tools": "0.2.0-rc.2", "@dsh-std/manifest": "0.1.1-rc.3", + "@types/node": "22.20.5", "typescript": "6.0.3", "yaml": "^2.9.1" }, diff --git a/src/file/contract.js b/src/capabilities/file.ts similarity index 75% rename from src/file/contract.js rename to src/capabilities/file.ts index e688d94d..a147c273 100644 --- a/src/file/contract.js +++ b/src/capabilities/file.ts @@ -1,15 +1,16 @@ -import { publicToolName, FILE_TOOLS, FILE_MUTATIONS } from '../policy.js' +/** File capability data; no host services or registration side effects. */ +import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import type { ParameterSchema } from './types.js' -const string = { type: 'string' } -const integer = { type: 'integer', description: 'Nonnegative integer (0-based).' } -const range = (min, max, description = '') => ({ type: 'integer', description: `${min}-${max} inclusive.${description ? ` ${description}` : ''}` }) -const bounded = (properties, required = []) => ({ type: 'object', properties, required, additionalProperties: false }) +const string: JsonSchemaNode = { type: 'string' } +const integer: JsonSchemaNode = { type: 'integer', description: 'Nonnegative integer (0-based).' } +const range = (min: number, max: number, description = ''): JsonSchemaNode => ({ type: 'integer', description: `${min}-${max} inclusive.${description ? ` ${description}` : ''}` }) +const bounded = (properties: Record, required: string[] = []): ParameterSchema => ({ type: 'object', properties, required, additionalProperties: false }) -export { FILE_TOOLS } // The pinned host SDK supports a schema subset: express bounds in descriptions; // the native worker remains authoritative for validation, including raw PTC calls. -const revision = { type: 'string', description: '64 lowercase hex SHA-256 revision returned by inspect/stat; absent is create-only, never an overwrite wildcard.' } -const existingRevision = { type: 'string', description: '64 lowercase hex SHA-256 revision returned by inspect/stat; absent is not accepted.' } +const revision: JsonSchemaNode = { type: 'string', description: '64 lowercase hex SHA-256 revision returned by inspect/stat; absent is create-only, never an overwrite wildcard.' } +const existingRevision: JsonSchemaNode = { type: 'string', description: '64 lowercase hex SHA-256 revision returned by inspect/stat; absent is not accepted.' } export const PARAMETERS = Object.freeze({ inspect_local_file: bounded({ @@ -57,7 +58,7 @@ export const PARAMETERS = Object.freeze({ }, ['pattern', 'replacement']), }) -export const OUTPUT = { +export const OUTPUT: JsonSchemaNode = { type: 'object', properties: { revision: string, bytes: { type: 'integer' }, lineCount: { oneOf: [{ type: 'integer' }, { type: 'null' }] }, @@ -86,42 +87,3 @@ export const DESCRIPTIONS = Object.freeze({ remove: 'Unlink ONE regular file with required expectedRevision from inspect/stat. No recursion/directories/links. Atomic unlink; no trash. Read-only denies all mutations.' + RESULT, replace: 'Replace LITERAL text (pattern is not a regex) in UTF-8 files under path; glob filters files as in ops_grep. dryRun observes only; writes use revision checks and atomic replacement. Read-only sessions are rejected. Darwin nonempty extended ACLs fail closed with METADATA_UNSUPPORTED. Windows preserves owner/group/ordered DACL and supported basic attributes/timestamps; readonly overwrite is refused and unsupported streams/EA/special storage fail with METADATA_UNSUPPORTED. SACL/audit labels are not preserved.' + RESULT, }) - -/** Drop unused empty placeholders strict adapters send for optional fields; never mutate the caller's object. */ -export function normalizeArguments(rawName, args) { - if (args === null || typeof args !== 'object' || Array.isArray(args)) return args - const schema = PARAMETERS[rawName] - if (!schema) return { ...args } - const required = new Set(schema.required) - const result = {} - for (const [key, value] of Object.entries(args)) { - if (value === undefined || value === null) continue - const field = schema.properties[key] - if (field && !required.has(key)) { - if (field.type === 'string' && typeof value === 'string' && value.trim() === '') continue - if (field.type === 'array' && Array.isArray(value) && value.length === 0) continue - } - result[key] = value - } - return result -} - -// Compact only homogeneous inspect pages. Keep every non-path field and the -// complete execution value for PTC/program consumers; never mutate worker data. -function modelView(rawName, value) { - if (rawName !== 'inspect_local_file' || value?.status !== 'completed' || !Array.isArray(value.items) || value.items.length === 0) return value - const path = value.items[0]?.path - if (typeof path !== 'string' || !value.items.every(item => item?.path === path)) return value - return { ...value, path, items: value.items.map(({ path: _path, ...item }) => item) } -} - -export function definition(rawName, execute) { - return { - name: publicToolName(rawName), - description: DESCRIPTIONS[rawName], - parameters: PARAMETERS[rawName], - ...(!FILE_MUTATIONS.includes(rawName) ? { isConcurrencySafe: () => true } : {}), - output: { schema: OUTPUT, render: (_args, value) => [{ type: 'text', text: JSON.stringify(modelView(rawName, value)) }] }, - execute, - } -} diff --git a/src/ptc/contract.js b/src/capabilities/ptc.ts similarity index 83% rename from src/ptc/contract.js rename to src/capabilities/ptc.ts index b002ff1d..239fd207 100644 --- a/src/ptc/contract.js +++ b/src/capabilities/ptc.ts @@ -1,10 +1,10 @@ -/** Single native contract drives host schemas, SDK types and callable names. */ -import { publicToolName } from '../policy.js' -const string = { type: 'string' } -const integer = { type: 'integer' } -const object = (properties, required = []) => ({ type: 'object', properties, required, additionalProperties: false }) -export const PTC_TOOLS = ['batch', 'result'] -export const PTC_SECTION = 'dsh-ops:repository-tooling:ptc' +/** Batch and result capability data; publication remains scoped to PTC-only agents. */ +import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import type { ParameterSchema } from './types.js' + +const string: JsonSchemaNode = { type: 'string' } +const integer: JsonSchemaNode = { type: 'integer' } +const object = (properties: Record, required: string[] = []): ParameterSchema => ({ type: 'object', properties, required, additionalProperties: false }) export const PARAMETERS = { batch: object({ root: string, @@ -25,7 +25,7 @@ export const PARAMETERS = { }, ['action']), } // Open JSON values exist only in the data plane (caller-owned rows), not in operation inputs. -export const OUTPUT = { +export const OUTPUT: JsonSchemaNode = { type: 'object', properties: { id: string, root: string, status: string, phase: string, done: integer, total: integer, failed: integer, unknown: integer, logicalBytes: integer, byteSemantics: string, @@ -42,10 +42,3 @@ export const DESCRIPTIONS = { batch: 'Preflight and verify copy/move/remove. action=plan requires operations and creates a no-effect audit plan; execute that exact plan with action=resume plus id (do not change it to run). action=run requires operations and executes immediately. root, when supplied, must be an absolute path: a relative root is rejected at preflight with INVALID_ARGUMENT. Omit root to use the session workspace root; an absolute root follows DSH permissions (unrestricted in full access). Operations use relative / paths, no overwrite or link traversal. Page detailsRef with ops_result, passing the same root whenever the plan root differs from the session workspace root. Parameter errors return structured error objects.', result: 'Session-owned object rows: put items (up to 10000; 2 MiB request/store cap), page, release. Pages default to offset=0, limit=64, maxBytes=16384; follow nextOffset until complete. equals filters top-level exact values before fields projection; offset/total/omitted count filtered rows. sourceTotal/contentHash identify all ordered rows before filtering/projection (SHA-256 of worker compact JSON with sorted object keys); compare hashes across pages to detect changed audits, never as authority. An oversized row returns OUTPUT_LIMIT: project fewer fields or raise maxBytes (max 65536). Result expiry is 24h, returned as Unix seconds; audit pages have expires=null and cannot be released. operationsRef in ops_batch accepts only owned unexpired result rows. Pass the same root as the batch when paging detailsRef, or expect NOT_FOUND. Failures are structured values, not thrown errors. In run_code, process pages locally and return only needed summaries.', } -export function definition(tool, execute) { - return { name: publicToolName(tool), description: DESCRIPTIONS[tool], parameters: PARAMETERS[tool], - timeoutMs: 300000, - output: { schema: OUTPUT, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] }, - execute, - } -} diff --git a/src/capabilities/registry.ts b/src/capabilities/registry.ts new file mode 100644 index 00000000..d3d80019 --- /dev/null +++ b/src/capabilities/registry.ts @@ -0,0 +1,41 @@ +/** One catalogue for owned tools. This module never registers tools or imports host values. */ +import { PARAMETERS as fileParameters, DESCRIPTIONS as fileDescriptions, OUTPUT as fileOutput } from './file.js' +import { PARAMETERS as ptcParameters, DESCRIPTIONS as ptcDescriptions, OUTPUT as ptcOutput } from './ptc.js' +import type { CapabilityMetadata, Component, SchemaCapability } from './types.js' + +export type FileToolName = keyof typeof fileParameters +export type PtcToolName = keyof typeof ptcParameters +// Preserve the published registration order independently of schema object key order. +const fileOrder = ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove'] as const satisfies readonly FileToolName[] +const fileMutations = new Set(['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove']) +const allPresentations = Object.freeze(['native', 'ptc', 'both'] as const) + +export const FILE_CAPABILITIES = Object.freeze(fileOrder.map(rawName => Object.freeze({ + rawName, component: 'file' as const, presentations: allPresentations, + availability: 'resident' as const, mutates: fileMutations.has(rawName), + concurrencySafe: !fileMutations.has(rawName), + parameters: fileParameters[rawName], description: fileDescriptions[rawName], output: fileOutput, +} satisfies SchemaCapability))) +export const PTC_CAPABILITIES = Object.freeze((Object.keys(ptcParameters) as PtcToolName[]).map(rawName => Object.freeze({ + rawName, component: 'ptc' as const, presentations: Object.freeze(['ptc'] as const), + availability: 'resident' as const, mutates: true, concurrencySafe: false, + parameters: ptcParameters[rawName], description: ptcDescriptions[rawName], output: ptcOutput, +} satisfies SchemaCapability))) +// The shell owns its host-dependent schema. Its name and ownership still come from this catalogue. +export const SHELL_CAPABILITIES = Object.freeze([Object.freeze({ + rawName: 'bash', component: 'shell', presentations: allPresentations, + availability: 'resident', mutates: true, concurrencySafe: false, +} as const satisfies CapabilityMetadata)]) +export const CAPABILITIES = Object.freeze([...FILE_CAPABILITIES, ...PTC_CAPABILITIES, ...SHELL_CAPABILITIES]) +export type Capability = typeof CAPABILITIES[number] +export type ToolName = Capability['rawName'] +const byName = new Map(CAPABILITIES.map(capability => [capability.rawName, capability])) + +export function capability(rawName: string): Capability | undefined { return byName.get(rawName) } +export function capabilitiesFor(component: Component): readonly Capability[] { + return CAPABILITIES.filter(capability => capability.component === component) +} +export const FILE_TOOLS = Object.freeze(FILE_CAPABILITIES.map(capability => capability.rawName)) +export const FILE_MUTATIONS = Object.freeze(FILE_CAPABILITIES.filter(capability => capability.mutates).map(capability => capability.rawName)) +export const PTC_TOOLS = PTC_CAPABILITIES.map(capability => capability.rawName) +export const IN_PROCESS_TOOLS = Object.freeze(SHELL_CAPABILITIES.map(capability => capability.rawName)) diff --git a/src/capabilities/types.ts b/src/capabilities/types.ts new file mode 100644 index 00000000..e2f3320e --- /dev/null +++ b/src/capabilities/types.ts @@ -0,0 +1,24 @@ +import type { JsonSchemaNode, ObjectJsonSchema, ToolPresentationMode } from '@deepseek-ai/dsh-tools' + +export type Component = 'file' | 'ptc' | 'shell' +export type ParameterSchema = ObjectJsonSchema & Record & { + properties: Record + required: string[] +} + +/** Availability describes today's registrations; discovery changes it in a later slice. */ +export interface CapabilityMetadata { + readonly rawName: string + readonly component: Component + readonly presentations: readonly ToolPresentationMode[] + readonly availability: 'resident' + /** Conservative per-tool flag: some actions (batch plan/result page) remain read-only. */ + readonly mutates: boolean + readonly concurrencySafe: boolean +} + +export interface SchemaCapability extends CapabilityMetadata { + readonly parameters: ParameterSchema + readonly description: string + readonly output: JsonSchemaNode +} diff --git a/src/file/contract.ts b/src/file/contract.ts new file mode 100644 index 00000000..0f331f1a --- /dev/null +++ b/src/file/contract.ts @@ -0,0 +1,49 @@ +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import { publicToolName } from '../policy.js' +import { capability } from '../capabilities/registry.js' +import type { FileToolName } from '../capabilities/registry.js' +import { PARAMETERS, DESCRIPTIONS, OUTPUT } from '../capabilities/file.js' +export { FILE_TOOLS } from '../capabilities/registry.js' +export { PARAMETERS, DESCRIPTIONS, OUTPUT } +type OutputValue = Parameters[1] +const record = (value: OutputValue | undefined): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value) + +/** Drop unused empty placeholders strict adapters send for optional fields; never mutate the caller's object. */ +export function normalizeArguments(rawName: string, args: unknown): unknown { + if (args === null || typeof args !== 'object' || Array.isArray(args)) return args + const entry = capability(rawName) + const schema = entry?.component === 'file' ? entry.parameters : undefined + if (!schema) return { ...args } + const required = new Set(schema.required) + const result: Record = {} + for (const [key, value] of Object.entries(args)) { + if (value === undefined || value === null) continue + const field = schema.properties[key] + if (field && !required.has(key)) { + if (field.type === 'string' && typeof value === 'string' && value.trim() === '') continue + if (field.type === 'array' && Array.isArray(value) && value.length === 0) continue + } + result[key] = value + } + return result +} + +// Compact only homogeneous inspect pages. Keep every non-path field and the +// complete execution value for PTC/program consumers; never mutate worker data. +function modelView(rawName: FileToolName, value: OutputValue): OutputValue { + if (rawName !== 'inspect_local_file' || !record(value) || value.status !== 'completed' || !Array.isArray(value.items) || value.items.length === 0) return value + const path = (record(value.items[0]) ? value.items[0].path : undefined) + if (typeof path !== 'string' || !value.items.every(item => record(item) && item.path === path)) return value + return { ...value, path, items: value.items.map(item => { const { path: _path, ...rest } = item as Record; return rest }) } +} + +export function definition(rawName: FileToolName, execute: ToolDefinition['execute']): ToolDefinition { + return { + name: publicToolName(rawName), + description: DESCRIPTIONS[rawName], + parameters: PARAMETERS[rawName], + ...(capability(rawName)?.concurrencySafe ? { isConcurrencySafe: () => true } : {}), + output: { schema: OUTPUT, render: (_args, value) => [{ type: 'text', text: JSON.stringify(modelView(rawName, value)) }] }, + execute, + } +} diff --git a/src/kernel/errors.ts b/src/kernel/errors.ts new file mode 100644 index 00000000..7a83e707 --- /dev/null +++ b/src/kernel/errors.ts @@ -0,0 +1,43 @@ +import type { EffectState, FailureStatus, RetryPolicy } from './outcome.js' + +export interface ErrorPolicy { readonly status: FailureStatus, readonly retry: RetryPolicy } +const policy = (status: FailureStatus, retry: RetryPolicy): ErrorPolicy => Object.freeze({ status, retry }) +const rejected = policy('rejected', 'never') +const invalid = policy('error', 'after-input-change') +const changed = policy('error', 'after-state-change') +const settled = policy('error', 'never') +const unknown = policy('unknown', 'never') + +/** Stable native/host codes retain their spelling. This table does not change current wire errors. */ +export const ERROR_POLICIES: Readonly> = Object.freeze({ + POLICY_DENIED: rejected, + CREDENTIAL_DENIED: rejected, + PATH_RESTRICTED: rejected, + REVISION_CONFLICT: policy('conflict', 'after-state-change'), + INVALID_ARGUMENT: invalid, + EDIT_CONFLICT: invalid, + NOT_FOUND: invalid, + IS_DIRECTORY: invalid, + OUTPUT_LIMIT: invalid, + CROSS_DEVICE: invalid, + ERR_INVALID_ENCODING: invalid, + REFERENCE_EXPIRED: changed, + AUTHORITY_CHANGED: changed, + ACCESS_DENIED: changed, + EXECUTOR_UNAVAILABLE: changed, + JOB_BUSY: policy('error', 'bounded-transient'), + METADATA_UNSUPPORTED: settled, + IO_ERROR: settled, + SYNC_FAILED: settled, + CLEANUP_FAILED: settled, + RESULT_PARTIAL: settled, + EXECUTION_RESTRICTED: unknown, + RESULT_UNKNOWN: unknown, +}) + +/** Effects dominate retry advice: a failed receipt never licenses replaying a possible write. */ +export function errorPolicy(code: string, effectState: EffectState = 'none'): ErrorPolicy { + if (effectState === 'unknown' || code === 'RESULT_UNKNOWN' || code === 'EXECUTION_RESTRICTED') return unknown + if (effectState !== 'none') return settled + return Object.hasOwn(ERROR_POLICIES, code) ? ERROR_POLICIES[code]! : settled +} diff --git a/src/kernel/outcome.ts b/src/kernel/outcome.ts new file mode 100644 index 00000000..b4c5f811 --- /dev/null +++ b/src/kernel/outcome.ts @@ -0,0 +1,47 @@ +/** Shared 0.4 result contracts. Existing adapters adopt these in their own migration slices. */ +export type EffectState = 'none' | 'applied' | 'partial' | 'unknown' +export type RetryPolicy = 'never' | 'after-input-change' | 'after-state-change' | 'bounded-transient' +export type FailureStatus = 'rejected' | 'conflict' | 'error' | 'unknown' + +export interface OpsErrorPayload { + code: string + effectState: EffectState + retry: RetryPolicy + message: string + nextAction?: string +} +export interface CoverageMeta { + scannedFiles: number + matchedCount: number + isComplete: boolean + truncatedBy?: 'scan-budget' | 'output-budget' | 'time-limit' | 'row-limit' +} +export type Outcome = + | { status: 'completed', data: T, coverage?: CoverageMeta & { isComplete: true } } + | { status: 'partial', data: T, coverage: CoverageMeta & { isComplete: false }, continuation: string } + | { status: FailureStatus, error: OpsErrorPayload, data?: T } + +interface SearchReportBase { + pageComplete: boolean + scannedFiles: number + skippedFiles: number + skipReasons: Record + matchedObserved: number + truncatedBy: 'scan-budget' | 'output-budget' | 'time' | 'row-limit' | 'none' + continuation?: string +} +/** An early-stopped scan may report observed matches, never a claimed full-scan total. */ +export type SearchExecutionReport = SearchReportBase & ( + | { scanComplete: true, matchedTotal: number } + | { scanComplete: false, matchedTotal?: never } +) +export interface StorageReference { + refId: `ref://${string}/${string}/${string}` + createdAt: number + ttlMs: number + schemaVersion: string + producerTool: string + totalItems: number + totalBytes: number + preview: string +} diff --git a/src/policy.js b/src/policy.ts similarity index 86% rename from src/policy.js rename to src/policy.ts index ea42e723..ffe5272b 100644 --- a/src/policy.js +++ b/src/policy.ts @@ -1,18 +1,18 @@ +import { FILE_TOOLS, IN_PROCESS_TOOLS } from './capabilities/registry.js' +export { FILE_TOOLS, FILE_MUTATIONS, IN_PROCESS_TOOLS } from './capabilities/registry.js' + /** Model-facing routing, kept in one section. No shared registry mutation. */ export const TOOLING_SECTION = 'dsh-ops:repository-tooling' // Legacy section name kept for consumers; the plugin no longer publishes it. export const HOST_SHELL_SECTION = 'dsh-ops:host-shell-policy' export const SECTION_ORDERS = Object.freeze({ [TOOLING_SECTION]: 3250 }) export const TOOL_PREFIX = 'ops_' -export const FILE_TOOLS = Object.freeze(['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove']) -export const FILE_MUTATIONS = Object.freeze(['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove']) // The plugin's second layer executes POSIX commands; pwsh7 uses the host tool. -export const IN_PROCESS_TOOLS = Object.freeze(['bash']) -export function publicToolName(rawName) { return `${TOOL_PREFIX}${rawName}` } -export const BASH_TOOL = publicToolName('bash') +export function publicToolName(rawName: string) { return `${TOOL_PREFIX}${rawName}` } +export const BASH_TOOL = publicToolName(IN_PROCESS_TOOLS[0]) /** Resolve the actual command capability, not the presence of a shell executable. */ -export function ladderLevels({ published, pwsh = false }) { +export function ladderLevels({ published, pwsh = false }: { published: Iterable, pwsh?: boolean }) { const names = new Set(published) return { files: FILE_TOOLS.some(name => names.has(publicToolName(name))), @@ -21,8 +21,17 @@ export function ladderLevels({ published, pwsh = false }) { } } +interface ToolingPolicyOptions { + published?: Iterable + mounted?: boolean + levels?: { files?: boolean, bash?: boolean, pwsh?: boolean } + pwsh7?: boolean + shellComponent?: boolean + extraGuidance?: string +} + /** A single compact table; absent tools never get advertised. */ -export function renderToolingPolicy({ published, mounted, levels, pwsh7 = false, shellComponent = true, extraGuidance = '' }) { +export function renderToolingPolicy({ published, mounted, levels, pwsh7 = false, shellComponent = true, extraGuidance = '' }: ToolingPolicyOptions) { const names = new Set(published ?? (mounted || levels?.files ? [...FILE_TOOLS, ...(levels?.bash ? IN_PROCESS_TOOLS : [])].map(publicToolName) : [])) if (levels?.pwsh) names.add('pwsh') @@ -34,7 +43,7 @@ export function renderToolingPolicy({ published, mounted, levels, pwsh7 = false, const heading = hasBash ? '# Shell routing: bash → PowerShell 7' : hasFiles ? '# Repository file tools' : '# Windows-native shell' const lines = [heading, '', '| Task | Tool |', '| --- | --- |'] - const row = (raw, task) => { + const row = (raw: string, task: string) => { if (names.has(publicToolName(raw))) lines.push(`| ${task} | ${publicToolName(raw)} |`) } row('inspect_local_file', 'Bounded UTF-8 lines / hex') @@ -78,10 +87,10 @@ export function renderToolingPolicy({ published, mounted, levels, pwsh7 = false, export function renderHostShellPolicy() { return '' } /** Explicit deny policy applies even without an ops command capability. */ -export function hostShellEnforcement({ shellPolicy }) { +export function hostShellEnforcement({ shellPolicy }: { shellPolicy?: string }) { return { hide: shellPolicy === 'deny-host-shell', reason: undefined } } -export function hostShellRefusal({ toolName, denied }) { +export function hostShellRefusal({ toolName, denied }: { toolName: string, denied: ReadonlySet }) { if (!denied.has(toolName)) return undefined return { kind: 'deny', reason: `Host tool ${toolName} is disabled by dsh-ops shellPolicy. Use an available file tool for file operations; otherwise request an operator policy change.` } } diff --git a/src/ptc/contract.ts b/src/ptc/contract.ts new file mode 100644 index 00000000..6f435c17 --- /dev/null +++ b/src/ptc/contract.ts @@ -0,0 +1,15 @@ +/** Existing host adapter over the shared capability catalogue. */ +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import { publicToolName } from '../policy.js' +import type { PtcToolName } from '../capabilities/registry.js' +import { PARAMETERS, DESCRIPTIONS, OUTPUT } from '../capabilities/ptc.js' +export { PTC_TOOLS } from '../capabilities/registry.js' +export { PARAMETERS, DESCRIPTIONS, OUTPUT } +export const PTC_SECTION = 'dsh-ops:repository-tooling:ptc' +export function definition(tool: PtcToolName, execute: ToolDefinition['execute']): ToolDefinition { + return { name: publicToolName(tool), description: DESCRIPTIONS[tool], parameters: PARAMETERS[tool], + timeoutMs: 300000, + output: { schema: OUTPUT, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] }, + execute, + } +} diff --git a/src/shells/constants.js b/src/shells/constants.js index f9960b98..ea843ed9 100644 --- a/src/shells/constants.js +++ b/src/shells/constants.js @@ -16,10 +16,10 @@ * @module dsh-ops/shells/constants */ -import { publicToolName } from '../policy.js' +import { BASH_TOOL } from '../policy.js' /** Model-facing name of the tool this module publishes for the bash rung. */ -export const BASH_TOOL_NAME = publicToolName('bash') +export const BASH_TOOL_NAME = BASH_TOOL /** Default foreground deadline for one `ops_bash` command, in milliseconds. */ export const DEFAULT_BASH_TIMEOUT_MS = 120_000 diff --git a/test/build.test.mjs b/test/build.test.mjs index ac94d9d3..7f02578a 100644 --- a/test/build.test.mjs +++ b/test/build.test.mjs @@ -17,8 +17,10 @@ function fixture(root) { fs.copyFileSync(path.join(PACKAGE_ROOT, 'scripts/build.mjs'), path.join(root, 'scripts/build.mjs')) fs.copyFileSync(path.join(PACKAGE_ROOT, 'tsconfig.json'), path.join(root, 'tsconfig.json')) // Copies also work in Windows sandboxes where directory junctions are unavailable. - fs.cpSync(path.join(PACKAGE_ROOT, 'node_modules/typescript'), - path.join(root, 'node_modules/typescript'), { recursive: true }) + for (const name of ['typescript', '@types/node', 'undici-types']) { + fs.cpSync(path.join(PACKAGE_ROOT, 'node_modules', name), + path.join(root, 'node_modules', name), { recursive: true }) + } write(root, 'package.json', JSON.stringify({ name: 'build-fixture', private: true, type: 'module' })) } diff --git a/test/capabilities.test.mjs b/test/capabilities.test.mjs new file mode 100644 index 00000000..45b76b1a --- /dev/null +++ b/test/capabilities.test.mjs @@ -0,0 +1,72 @@ +/** Registry migration preserves the complete previously published host definitions. */ +import { createHash } from 'node:crypto' +import { CAPABILITIES, FILE_CAPABILITIES, PTC_CAPABILITIES, SHELL_CAPABILITIES, capability, capabilitiesFor } from '../lib/capabilities/registry.js' +import { FILE_TOOLS, FILE_MUTATIONS, IN_PROCESS_TOOLS, BASH_TOOL, publicToolName } from '../lib/policy.js' +import { definition as fileDefinition, PARAMETERS as fileParameters, DESCRIPTIONS as fileDescriptions } from '../lib/file/contract.js' +import { definition as ptcDefinition, PTC_TOOLS, PARAMETERS as ptcParameters, DESCRIPTIONS as ptcDescriptions } from '../lib/ptc/contract.js' +import { NativeFileTools } from '../lib/file/runtime.js' +import { BASH_TOOL_NAME } from '../lib/shells/constants.js' +import { assert, test, report } from './lib/harness.mjs' + +await test('the catalogue covers every owned file, PTC and shell tool exactly once', () => { + assert.equal(CAPABILITIES.length, 14) + assert.equal(new Set(CAPABILITIES.map(item => item.rawName)).size, CAPABILITIES.length) + assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove']) + assert.deepEqual(FILE_MUTATIONS, ['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove']) + assert.deepEqual(PTC_TOOLS, ['batch', 'result']) + assert.deepEqual(IN_PROCESS_TOOLS, ['bash']) + assert.equal(BASH_TOOL_NAME, BASH_TOOL) + for (const [entries, parameters, descriptions] of [[FILE_CAPABILITIES, fileParameters, fileDescriptions], [PTC_CAPABILITIES, ptcParameters, ptcDescriptions]]) { + assert.deepEqual(entries.map(item => item.rawName).sort(), Object.keys(parameters).sort()) + assert.deepEqual(Object.keys(parameters).sort(), Object.keys(descriptions).sort()) + for (const entry of entries) { + assert.equal(capability(entry.rawName), entry) + assert.equal(entry.parameters, parameters[entry.rawName]) + assert.equal(entry.description, descriptions[entry.rawName]) + } + } + for (const component of ['file', 'ptc', 'shell']) assert.deepEqual(capabilitiesFor(component), CAPABILITIES.filter(item => item.component === component)) + for (const name of ['unknown', 'toString', '__proto__']) assert.equal(capability(name), undefined) +}) + +await test('metadata preserves current mode availability and conservative concurrency', () => { + for (const entry of [...FILE_CAPABILITIES, ...SHELL_CAPABILITIES]) assert.deepEqual(entry.presentations, ['native', 'ptc', 'both']) + for (const entry of PTC_CAPABILITIES) assert.deepEqual(entry.presentations, ['ptc']) + for (const entry of CAPABILITIES) { + assert.equal(entry.availability, 'resident') + assert.equal(entry.concurrencySafe, entry.component === 'file' && !entry.mutates) + } +}) + +const snapshot = (names, definition) => names.map(name => { + const d = definition(name, async () => null) + return { name: d.name, description: d.description, parameters: d.parameters, output: d.output.schema, + concurrent: d.isConcurrencySafe?.({}) ?? false, timeoutMs: d.timeoutMs ?? null } +}) +await test('serialized host schemas, descriptions, ordering and execution metadata match 00237b8', () => { + // Captured before this migration, covering every argument/output contract and published description. + const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') + assert.equal(hash(snapshot(FILE_TOOLS, fileDefinition)), '8b89dff420471db5b3bd13729cdf8a30c07e8e7636e5d22795ab343b5ae8e267') + assert.equal(hash(snapshot(PTC_TOOLS, ptcDefinition)), '74e6a50db799da1aa80c745e18c67be577426cc41441f9d0f8c87673c04f3c43') +}) + +await test('native registration consumes the catalogue while execute values stay unchanged', async () => { + const registered = [], disposed = [] + const runtime = new NativeFileTools({ ctx: { tools: { register(definition) { + registered.push(definition); return () => disposed.push(definition.name) + } } }, config: { workerPath: 'fixture-worker' } }) + assert.deepEqual(await runtime.start(), FILE_CAPABILITIES.map(item => publicToolName(item.rawName))) + assert.deepEqual(registered.map(item => item.name), FILE_TOOLS.map(publicToolName)) + const canonical = { status: 'completed', items: [{ path: '/file', line: 1, text: 'text' }], complete: false } + for (const [names, definition] of [[FILE_TOOLS, fileDefinition], [PTC_TOOLS, ptcDefinition]]) { + for (const name of names) { + const tool = definition(name, async () => canonical) + assert.equal(await tool.execute({}, {}), canonical) + assert.equal(canonical.items[0].path, '/file') + if (name !== 'inspect_local_file') assert.deepEqual(JSON.parse(tool.output.render({}, canonical)[0].text), canonical) + } + } + await runtime.stop() + assert.deepEqual(disposed, registered.map(item => item.name)) +}) +report('capabilities') diff --git a/test/kernel-errors.test.mjs b/test/kernel-errors.test.mjs new file mode 100644 index 00000000..910855b5 --- /dev/null +++ b/test/kernel-errors.test.mjs @@ -0,0 +1,23 @@ +/** Retry policy is explicit and cannot turn uncertain effects into a replay instruction. */ +import { ERROR_POLICIES, errorPolicy } from '../lib/kernel/errors.js' +import { assert, test, report } from './lib/harness.mjs' + +await test('permission, input, revision and transient failures have distinct advice', () => { + for (const code of ['POLICY_DENIED', 'CREDENTIAL_DENIED', 'PATH_RESTRICTED']) assert.deepEqual(errorPolicy(code), { status: 'rejected', retry: 'never' }) + assert.deepEqual(errorPolicy('REVISION_CONFLICT'), { status: 'conflict', retry: 'after-state-change' }) + for (const code of ['INVALID_ARGUMENT', 'EDIT_CONFLICT', 'OUTPUT_LIMIT', 'ERR_INVALID_ENCODING']) assert.deepEqual(errorPolicy(code), { status: 'error', retry: 'after-input-change' }) + assert.deepEqual(errorPolicy('JOB_BUSY'), { status: 'error', retry: 'bounded-transient' }) + for (const code of ['ACCESS_DENIED', 'AUTHORITY_CHANGED', 'EXECUTOR_UNAVAILABLE']) assert.deepEqual(errorPolicy(code), { status: 'error', retry: 'after-state-change' }) + for (const code of ['METADATA_UNSUPPORTED', 'IO_ERROR', 'NEW_CODE', 'toString', '__proto__']) assert.deepEqual(errorPolicy(code), { status: 'error', retry: 'never' }) +}) + +await test('unknown or already-applied effects override every ordinary retry policy', () => { + for (const code of [...Object.keys(ERROR_POLICIES), 'NEW_CODE']) { + assert.deepEqual(errorPolicy(code, 'unknown'), { status: 'unknown', retry: 'never' }) + for (const effect of ['partial', 'applied']) assert.equal(errorPolicy(code, effect).retry, 'never') + } + for (const code of ['RESULT_UNKNOWN', 'EXECUTION_RESTRICTED']) assert.deepEqual(errorPolicy(code), { status: 'unknown', retry: 'never' }) + assert.deepEqual(errorPolicy('REVISION_CONFLICT', 'applied'), { status: 'error', retry: 'never' }) + assert.deepEqual(errorPolicy('OUTPUT_LIMIT', 'unknown'), { status: 'unknown', retry: 'never' }) +}) +report('kernel-errors') diff --git a/test/kernel-types.test.mjs b/test/kernel-types.test.mjs new file mode 100644 index 00000000..51b02e0a --- /dev/null +++ b/test/kernel-types.test.mjs @@ -0,0 +1,10 @@ +/** Negative assertions must keep failing compilation; @ts-expect-error detects accidental widening. */ +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { assert, test, report, PACKAGE_ROOT } from './lib/harness.mjs' +await test('shared result types narrow correctly and reject incomplete or contradictory outcomes', () => { + const result = spawnSync(process.execPath, [path.join(PACKAGE_ROOT, 'node_modules/typescript/lib/tsc.js'), '-p', path.join(PACKAGE_ROOT, 'test/types/tsconfig.json')], { cwd: PACKAGE_ROOT, encoding: 'utf8', timeout: 30_000 }) + assert.ifError(result.error) + assert.equal(result.status, 0, result.stdout + result.stderr) +}) +report('kernel-types') diff --git a/test/types/kernel.ts b/test/types/kernel.ts new file mode 100644 index 00000000..48db414c --- /dev/null +++ b/test/types/kernel.ts @@ -0,0 +1,41 @@ +import type { ReadStream } from 'node:fs' +import type { Outcome, OpsErrorPayload, SearchExecutionReport, StorageReference } from '../../src/kernel/outcome.js' + +const error: OpsErrorPayload = { code: 'INVALID_ARGUMENT', effectState: 'none', retry: 'after-input-change', message: 'Correct path.' } +const complete: Outcome = { status: 'completed', data: 1 } +const partial: Outcome = { status: 'partial', data: 1, coverage: { scannedFiles: 1, matchedCount: 1, isComplete: false }, continuation: 'next' } +const rejected: Outcome = { status: 'rejected', error } +const failedAfterEffects: Outcome = { status: 'error', error: { ...error, effectState: 'applied', retry: 'never' }, data: 1 } +// @ts-expect-error Partial results require explicit coverage and a continuation. +const missingCoverage: Outcome = { status: 'partial', data: 1, continuation: 'next' } +// @ts-expect-error Partial results require a continuation even when coverage is present. +const missingContinuation: Outcome = { status: 'partial', data: 1, coverage: { scannedFiles: 1, matchedCount: 1, isComplete: false } } +// @ts-expect-error A completed result cannot claim incomplete coverage. +const incompleteSuccess: Outcome = { status: 'completed', data: 1, coverage: { scannedFiles: 1, matchedCount: 1, isComplete: false } } +// @ts-expect-error Failed results require the structured error payload. +const missingError: Outcome = { status: 'unknown' } +// @ts-expect-error Retry advice uses the stable vocabulary. +const invalidRetry: OpsErrorPayload = { ...error, retry: 'retry-now' } + +function result(value: Outcome): number | string { + if (value.status === 'completed') return value.data + if (value.status === 'partial') { + // @ts-expect-error Partial is a data/continuation state, not an error state. + void value.error + return value.continuation + } + return value.error.code +} +const base = { pageComplete: true, scannedFiles: 1, skippedFiles: 0, skipReasons: {}, matchedObserved: 2, truncatedBy: 'none' as const } +const full: SearchExecutionReport = { ...base, scanComplete: true, matchedTotal: 2 } +const stopped: SearchExecutionReport = { ...base, scanComplete: false } +// @ts-expect-error An incomplete physical scan must not claim a known total. +const falseTotal: SearchExecutionReport = { ...base, scanComplete: false, matchedTotal: 2 } +// @ts-expect-error A complete physical scan reports its known total. +const missingTotal: SearchExecutionReport = { ...base, scanComplete: true } +const reference: StorageReference = { refId: 'ref://session/tool/id', createdAt: 0, ttlMs: 0, schemaVersion: '1', producerTool: 'tool', totalItems: 2, totalBytes: 20, preview: 'first' } +// @ts-expect-error References use the versioned reference syntax, not filesystem paths. +const invalidRef: StorageReference = { ...reference, refId: '/tmp/result.json' } +// The pinned Node major's declarations resolve without adding host runtime imports. +const streamReadable = (stream: ReadStream): boolean => stream.readable +void [complete, partial, rejected, failedAfterEffects, result, full, stopped, reference, streamReadable] diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json new file mode 100644 index 00000000..126e33e3 --- /dev/null +++ b/test/types/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "../..", + "noEmit": true + }, + "include": [ + "kernel.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json index cc48a6e1..c4d96988 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "moduleResolution": "NodeNext", "target": "ES2023", "lib": ["ES2023"], - "types": [], + "types": ["node"], "allowJs": true, "checkJs": false, "strict": true, From 3247a0a9fe2f98be15f9c1138a2e65cd71b1ba6f Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:20:24 +0800 Subject: [PATCH 004/118] feat(native): [4b/16] version one-shot worker requests --- docs/CHANGELOG.md | 7 + docs/native-kernel.md | 20 +++ ...37\347\224\237\345\206\205\346\240\270.md" | 17 +++ native/crates/ops-protocol/src/envelope.rs | 98 +++++++++++++ .../crates/ops-protocol/src/envelope/tests.rs | 75 ++++++++++ native/crates/ops-protocol/src/lib.rs | 1 + native/src/main.rs | 47 +++--- src/file/runtime.js | 3 +- src/file/worker.js | 6 +- src/ptc/index.js | 3 +- src/ptc/worker.js | 6 +- src/worker-protocol.js | 15 ++ test/authority.test.mjs | 2 +- test/lib/boundary.mjs | 3 +- test/worker-protocol.test.mjs | 134 ++++++++++++++++++ 15 files changed, 406 insertions(+), 31 deletions(-) create mode 100644 native/crates/ops-protocol/src/envelope.rs create mode 100644 native/crates/ops-protocol/src/envelope/tests.rs create mode 100644 src/worker-protocol.js create mode 100644 test/worker-protocol.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c0748929..1cfa6e58 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,13 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Version the one-shot worker request protocol (#261) + +- Send strict v1 requests with per-call IDs, component provenance, and enforced deadline/output + budgets while retaining existing host authority checks and canonical success results. Reject + malformed envelopes before effects; interrupted or oversized mutation receipts never imply no + effects. Keep explicit v0 decoding for benchmark compatibility with bounded transport defaults. + ### Split the native kernel into workspace crates (#261) - Extract protocol, file, search, batch, and result-storage crates while retaining the one-shot diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 27c19481..e68c4c24 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -25,3 +25,23 @@ Run `cargo test --workspace --locked --manifest-path native/Cargo.toml` to inclu moved into the new crates. `npm run build:ptc` still produces the same worker path. Published packages include `native/crates/` and both metadata adapters so installed source builds work. The benchmark preflight checks all workspace inputs while ignoring Cargo target directories. + +## One-shot request protocol + +New bridges send `{ version: 1, requestId, component, budget, request }`. `request` retains the +strict existing capability and host authority fields; `component` is `file` or `ptc` and never +grants authority. The bridge generates a fresh UUID, supplies the authoritative session/root and +policy, then rechecks authority immediately before spawning as before. Success JSON is unchanged. + +`budget.timeoutMs` is 1..2147483647 milliseconds, using the existing 300000ms default and existing +host cancellation. A process-local watchdog also enforces it for direct callers; interruption exits +124 and bridges report unknown effects. `budget.maxBytes` is 1024..131072, defaults to 131072, and +counts serialized JSON plus its newline. The minimum leaves room for a structured error. Oversized +read output returns `OUTPUT_LIMIT` with no effects; a lost mutation receipt reports unknown effects +unless an existing failure already carries a more precise effect state. No automatic retry follows. + +The complete request remains capped at 2 MiB. Explicit legacy unversioned requests remain accepted +for 0.3.x benchmark comparisons, with the same authority checks and bounded default transport budget. +Any envelope marker selects strict v1 decoding: partial envelopes, unknown fields, invalid IDs, +unsupported components/versions and unusable budgets are rejected before filesystem effects. +Request IDs do not change result hashes. Scan/storage/reference budgets are separate later work. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index c374795a..04b3da37 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -22,3 +22,20 @@ English: [native-kernel.md](native-kernel.md). 测试全部执行。`npm run build:ptc` 的 worker 输出路径保持不变。发布包包含 `native/crates/` 及两个元数据适配器,支持安装后的源码构建。基准测试预检检查整个工作区的构建输入,并排除 Cargo 的 target 目录。 + +## 单次请求协议 + +新的宿主桥接发送 `{ version: 1, requestId, component, budget, request }`。`request` 保留严格的 +能力参数和宿主权限字段;`component` 为 `file` 或 `ptc`,不会授予权限。桥接生成新的 UUID, +填入真实会话、根路径和策略,并在启动进程前再次核实权限。成功结果 JSON 保持不变。 + +`budget.timeoutMs` 范围为 1..2147483647 毫秒,沿用现有 300000 毫秒默认值及宿主取消机制。 +进程内的看门狗也会约束直接调用;超时退出码为 124,桥接将副作用报告为 unknown。 +`budget.maxBytes` 范围为 1024..131072,默认 131072,计算序列化 JSON 与末尾换行的总字节数。 +下限保证结构化错误有足够空间。读取输出超限返回 `OUTPUT_LIMIT` 且无副作用;修改回执丢失则 +报告 unknown,除非已有错误携带更精确的副作用状态。不会自动重试。 + +完整请求仍以 2 MiB 为上限。为兼容 0.3.x 基准对比,显式保留旧版无版本请求,采用相同权限检查和 +有界默认传输预算。只要出现任一信封字段,就严格按 v1 解析;缺字段、未知字段、非法 ID、 +不支持的组件或版本、无法使用的预算,均在文件系统副作用前被拒绝。请求 ID 不改变结果哈希。 +扫描、存储和引用预算由后续切片实现。 diff --git a/native/crates/ops-protocol/src/envelope.rs b/native/crates/ops-protocol/src/envelope.rs new file mode 100644 index 00000000..79ae7dac --- /dev/null +++ b/native/crates/ops-protocol/src/envelope.rs @@ -0,0 +1,98 @@ +//! Versioned one-shot transport. Legacy input is an explicit v0 format, never a fallback. +use crate::{Failure, Request, Result}; +use serde::Deserialize; +use serde_json::{Value, json}; + +pub const MAX_REQUEST_BYTES: usize = 2 * 1024 * 1024; +pub const MAX_OUTPUT_BYTES: usize = 128 * 1024; +pub const DEFAULT_TIMEOUT_MS: u64 = 300_000; + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Budget { + pub timeout_ms: u64, + pub max_bytes: usize, +} +impl Default for Budget { + fn default() -> Self { + Self { timeout_ms: DEFAULT_TIMEOUT_MS, max_bytes: MAX_OUTPUT_BYTES } + } +} +impl Budget { + fn validate(self) -> Result { + if self.timeout_ms == 0 || self.timeout_ms > 2_147_483_647 + || !(1024..=MAX_OUTPUT_BYTES).contains(&self.max_bytes) + { + return Err(Failure::new("INVALID_ARGUMENT", "budget requires timeoutMs 1..2147483647 and maxBytes 1024..131072")); + } + Ok(self) + } +} +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Component { File, Ptc } + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Envelope { + version: u32, + request_id: String, + component: Component, + budget: Budget, + request: Request, +} + +pub struct Decoded { + pub request: Request, + pub budget: Budget, + pub request_id: Option, + pub component: Option, +} + +pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > MAX_REQUEST_BYTES { + return Err(Failure::new("OUTPUT_LIMIT", "Request exceeds 2 MiB")); + } + let value: Value = serde_json::from_slice(bytes)?; + // A malformed or unsupported envelope must not be retried as an unversioned request. + if ["version", "requestId", "component", "budget", "request"].iter().any(|key| value.get(key).is_some()) { + let envelope: Envelope = serde_json::from_slice(bytes)?; + if envelope.version != 1 { + return Err(Failure::new("PROTOCOL_VERSION", "Supported worker request version is 1")); + } + if envelope.request_id.is_empty() || envelope.request_id.len() > 128 + || !envelope.request_id.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + { + return Err(Failure::new("INVALID_ARGUMENT", "requestId must contain 1..128 ASCII letters, digits, underscores or hyphens")); + } + Ok(Decoded { request: envelope.request, budget: envelope.budget.validate()?, + request_id: Some(envelope.request_id), component: Some(envelope.component) }) + } else { + Ok(Decoded { request: serde_json::from_slice(bytes)?, budget: Budget::default(), request_id: None, component: None }) + } +} + +/// Size includes the terminating newline. Never misclassify lost mutation receipts as no effects. +pub fn encode_result(value: &Value, budget: Budget, may_mutate: bool) -> Result> { + let budget = budget.validate()?; + let mut bytes = serde_json::to_vec(value)?; + if bytes.len() + 1 > budget.max_bytes { + let effect = value.get("error").and_then(|error| error.get("effectState")).and_then(Value::as_str) + .unwrap_or(if may_mutate { "unknown" } else { "none" }); + let mut failure = if effect == "none" { + Failure::new("OUTPUT_LIMIT", "Serialized result exceeds maxBytes; narrow the request or page the result") + } else { + let mut failure = Failure::unknown("Serialized result exceeds maxBytes; inspect postconditions or the job audit before retrying"); + failure.code = "OUTPUT_LIMIT".into(); + failure.effect_state = effect.into(); + failure + }; + failure.phase = "verify".into(); + bytes = serde_json::to_vec(&json!({"status":"error", "error":failure}))?; + } + bytes.push(b'\n'); + Ok(bytes) +} + +#[cfg(test)] +mod tests; diff --git a/native/crates/ops-protocol/src/envelope/tests.rs b/native/crates/ops-protocol/src/envelope/tests.rs new file mode 100644 index 00000000..84272df9 --- /dev/null +++ b/native/crates/ops-protocol/src/envelope/tests.rs @@ -0,0 +1,75 @@ +use super::*; + +fn legacy() -> Value { json!({"root":"/workspace","scope":"session","tool":"stat","args":{"path":"/workspace/file"},"read_only":true,"deny_roots":["/secret"]}) } +fn versioned() -> Value { json!({"version":1,"requestId":"req-1","component":"file","budget":{"timeoutMs":300000,"maxBytes":131072},"request":legacy()}) } +fn decoded(value: &Value) -> Result { decode(&serde_json::to_vec(value).unwrap()) } + +#[test] +fn formats_preserve_authority_and_use_bounded_budgets() { + let old = decoded(&legacy()).unwrap(); + let new = decoded(&versioned()).unwrap(); + assert!(old.request_id.is_none()); + assert_eq!(new.request_id.as_deref(), Some("req-1")); + assert!(matches!(new.component, Some(Component::File))); + assert_eq!(new.request.root, old.request.root); + assert_eq!(new.request.scope, old.request.scope); + assert_eq!(new.request.deny_roots, ["/secret"]); + assert!(new.request.read_only); + assert_eq!(old.budget.max_bytes, MAX_OUTPUT_BYTES); + assert_eq!(new.budget.timeout_ms, DEFAULT_TIMEOUT_MS); +} +#[test] +fn partial_unknown_and_invalid_envelopes_never_downgrade() { + for key in ["version", "requestId", "component", "budget", "request"] { + let mut value = legacy(); + value[key] = json!(1); + assert!(decoded(&value).is_err(), "marker {key}"); + let mut value = versioned(); + value.as_object_mut().unwrap().remove(key); + assert!(decoded(&value).is_err(), "missing {key}"); + } + let mut value = versioned(); + value["version"] = json!(2); + assert_eq!(decoded(&value).err().unwrap().code, "PROTOCOL_VERSION"); + for (key, invalid) in [("requestId", json!("")), ("requestId", json!("a\nb")), ("requestId", json!("x".repeat(129))), ("component", json!("shell")), ("extra", json!(true))] { + let mut value = versioned(); value[key] = invalid; + assert!(decoded(&value).is_err(), "invalid {key}"); + } + let mut value = versioned(); value["request"]["extra"] = json!(true); + assert!(decoded(&value).is_err()); + // Decode the original bytes so duplicate struct fields cannot be collapsed by Value. + let old = serde_json::to_string(&legacy()).unwrap(); + let new = serde_json::to_string(&versioned()).unwrap(); + for text in [old.replacen("{", "{\"root\":\"/other\",", 1), new.replacen("{", "{\"version\":1,", 1), new.replace("\"timeoutMs\":", "\"timeoutMs\":1,\"timeoutMs\":")] { + assert!(decode(text.as_bytes()).is_err()); + } +} +#[test] +fn budgets_reject_tiny_huge_and_unusable_values_before_execution() { + for (key, value) in [("timeoutMs", json!(0)), ("timeoutMs", json!(-1)), ("timeoutMs", json!(1.5)), ("timeoutMs", json!(2147483648_u64)), ("maxBytes", json!(1023)), ("maxBytes", json!(131073)), ("extra", json!(1))] { + let mut input = versioned(); input["budget"][key] = value; + assert!(decoded(&input).is_err(), "invalid {key}"); + } + assert_eq!(decode(&vec![b' '; MAX_REQUEST_BYTES + 1]).err().unwrap().code, "OUTPUT_LIMIT"); +} +#[test] +fn output_budget_counts_encoded_bytes_and_keeps_effects_honest() { + assert!(encode_result(&json!({}), Budget { max_bytes: 1, ..Budget::default() }, false).is_err()); + let budget = Budget { max_bytes: 1024, ..Budget::default() }; + let value = json!({"status":"completed","text":"\0".repeat(1000)}); + for (mutating, expected) in [(false,"none"),(true,"unknown")] { + let bytes = encode_result(&value,budget,mutating).unwrap(); + assert!(bytes.len() <= 1024); + assert_eq!(bytes.last(), Some(&b'\n')); + let output: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(output["error"]["code"], "OUTPUT_LIMIT"); + assert_eq!(output["error"]["effectState"], expected); + } + for effect in ["none","applied","partial","unknown"] { + let value = json!({"error":{"effectState":effect,"message":"x".repeat(2000)}}); + let output: Value = serde_json::from_slice(&encode_result(&value,budget,true).unwrap()).unwrap(); + assert_eq!(output["error"]["effectState"],effect); + } + let exact = json!({"text":"x".repeat(1012)}); // 1023 serialized bytes plus newline. + assert_eq!(encode_result(&exact,budget,false).unwrap().len(),1024); +} diff --git a/native/crates/ops-protocol/src/lib.rs b/native/crates/ops-protocol/src/lib.rs index eb8df107..102ef829 100644 --- a/native/crates/ops-protocol/src/lib.rs +++ b/native/crates/ops-protocol/src/lib.rs @@ -1,4 +1,5 @@ #![forbid(unsafe_code)] +pub mod envelope; use serde::{Deserialize, Serialize}; use serde_json::Value; diff --git a/native/src/main.rs b/native/src/main.rs index 5f3f3e5d..0dbf8803 100644 --- a/native/src/main.rs +++ b/native/src/main.rs @@ -1,35 +1,36 @@ #![forbid(unsafe_code)] -use dsh_ops_core::{ - contract::{Failure, Request}, - dispatch, -}; +use dsh_ops_core::{contract::{Failure, Request, envelope::{self, Budget}}, dispatch}; use serde_json::json; use std::io::{Read, Write}; + +fn may_mutate(request: &Request) -> bool { + !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "glob" | "grep" | "diff" | "stat") + && !(request.tool == "result" && request.args.get("action").and_then(serde_json::Value::as_str) == Some("page")) +} fn main() { if std::env::args().any(|a| a == "--version") { println!("dsh-ops-worker {}", env!("CARGO_PKG_VERSION")); return; } + let mut budget = Budget::default(); + let mut mutating = false; let value = (|| { let mut bytes = Vec::new(); - std::io::stdin() - .take(2 * 1024 * 1024 + 1) - .read_to_end(&mut bytes) - .map_err(Failure::from)?; - if bytes.len() > 2 * 1024 * 1024 { - return Err(Failure::new("OUTPUT_LIMIT", "Request exceeds 2 MiB")); - } - let request: Request = serde_json::from_slice(&bytes)?; - dispatch(request) + std::io::stdin().take((envelope::MAX_REQUEST_BYTES + 1) as u64) + .read_to_end(&mut bytes).map_err(Failure::from)?; + let decoded = envelope::decode(&bytes)?; + budget = decoded.budget; + mutating = may_mutate(&decoded.request); + // The host also owns cancellation. This process-local deadline covers direct callers; + // interruption exits nonzero so the bridge reports unknown effects, never completion. + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(budget.timeout_ms)); + std::process::exit(124); + }); + dispatch(decoded.request) })(); - let output = match value { - Ok(v) => v, - Err(e) => json!({ "status": "error", "error": e }), - }; - // stdout is a single bounded canonical JSON result. No execution facts depend on presentation. - let encoded = serde_json::to_vec(&output) - .unwrap_or_else(|_| b"{\"status\":\"error\",\"code\":\"OUTPUT_LIMIT\"}".to_vec()); - let mut stdout = std::io::stdout().lock(); - let _ = stdout.write_all(&encoded); - let _ = stdout.write_all(b"\n"); + let output = match value { Ok(value) => value, Err(error) => json!({"status":"error","error":error}) }; + let encoded = envelope::encode_result(&output, budget, mutating) + .unwrap_or_else(|_| b"{\"status\":\"error\",\"error\":{\"code\":\"RESULT_UNKNOWN\",\"effectState\":\"unknown\"}}\n".to_vec()); + let _ = std::io::stdout().lock().write_all(&encoded); } diff --git a/src/file/runtime.js b/src/file/runtime.js index b261fa2e..f8964c2b 100644 --- a/src/file/runtime.js +++ b/src/file/runtime.js @@ -1,3 +1,4 @@ +import { WORKER_TIMEOUT_MS } from '../worker-protocol.js' import { publicToolName } from '../policy.js' import { definition, FILE_TOOLS } from './contract.js' import { safeDisposeAll } from '../teardown.js' @@ -32,7 +33,7 @@ export class NativeFileTools { const abort = () => controller.abort(exec?.signal?.reason) if (exec?.signal?.aborted) abort() else exec?.signal?.addEventListener?.('abort', abort, { once: true }) - const timer = setTimeout(() => controller.abort(new Error('Native file call timed out')), this.config.toolCallTimeoutMs ?? 300000) + const timer = setTimeout(() => controller.abort(new Error('Native file call timed out')), this.config.toolCallTimeoutMs ?? WORKER_TIMEOUT_MS) timer.unref?.() this.active.add(controller) try { return await invoke(this.ctx, this.file, tool, args, exec, controller.signal, this.config) } diff --git a/src/file/worker.js b/src/file/worker.js index 5a7dcce0..afa11a16 100644 --- a/src/file/worker.js +++ b/src/file/worker.js @@ -1,4 +1,5 @@ /** Native file worker bridge. The host owns path resolution and DSH authority. */ +import { workerEnvelope } from '../worker-protocol.js' import path from 'node:path' import { credentialPath, credentialRefusal, credentialRoots } from '../credential-policy.js' import { ensureSessionTemp, sessionTempDir } from '../session-temp.js' @@ -131,7 +132,8 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { // Traversals skip credential directories by identity, so a glob or grep cannot even name a private // key that sits below one. const denyRoots = (config?.denyCredentialReads ?? true) !== false ? credentialRoots() : [] - const input = JSON.stringify({ root: trustedPath(cwd), scope: String(session.id), tool, args: workerArgs, read_only: policy.mode === 'read-only', deny_roots: denyRoots, deny_credential_names: (config?.denyCredentialReads ?? true) !== false, ...(policy.mode === 'workspace-write' ? { write_roots: writeRoots } : {}) }) + const envelope = workerEnvelope('file', { root: trustedPath(cwd), scope: String(session.id), tool, args: workerArgs, read_only: policy.mode === 'read-only', deny_roots: denyRoots, deny_credential_names: (config?.denyCredentialReads ?? true) !== false, ...(policy.mode === 'workspace-write' ? { write_roots: writeRoots } : {}) }, config?.toolCallTimeoutMs) + const input = JSON.stringify(envelope) if (Buffer.byteLength(input) > 2 * 1024 * 1024) return failure('OUTPUT_LIMIT', 'Request exceeds the 2 MiB byte budget.') let spawned = false try { @@ -151,7 +153,7 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { if (mutates && (current.mode === 'read-only' || (current.mode === 'workspace-write' && required === 'danger-full-access'))) return denied(current, tool, required, 'Authority changed during preparation; no files were changed.') return authorityChanged(current, tool) } - const handle = subprocess.spawn({ argv, cwd, stdio: { stdin: { data: input }, stdout: { maxBytes: 128 * 1024 }, stderr: { maxBytes: 16 * 1024 } }, graceMs: 1000, signal }) + const handle = subprocess.spawn({ argv, cwd, stdio: { stdin: { data: input }, stdout: { maxBytes: envelope.budget.maxBytes }, stderr: { maxBytes: 16 * 1024 } }, graceMs: 1000, signal }) spawned = true const outcome = await handle.done const stdout = handle.collected.stdout.readFrom(0) diff --git a/src/ptc/index.js b/src/ptc/index.js index fa1b49c8..d9f2152d 100644 --- a/src/ptc/index.js +++ b/src/ptc/index.js @@ -1,6 +1,7 @@ /** PTC-only component. Public prompt assembly identifies the effective presentation; * never inspect private modeFor/layers, force presentAs, or infer mode from run_code existence. */ +import { WORKER_TIMEOUT_MS } from '../worker-protocol.js' import { PTC_TOOLS, PTC_SECTION, definition } from './contract.js' import { publicToolName } from '../policy.js' import { invoke, resolveWorker, failure } from './worker.js' @@ -77,7 +78,7 @@ export function apply(root, rawConfig) { const controller = new AbortController() const abort = () => controller.abort(exec.signal?.reason) if (exec.signal?.aborted) abort(); else exec.signal?.addEventListener('abort', abort, { once: true }) - const timer = setTimeout(() => controller.abort(new Error('Worker deadline exceeded')), 300000); timer.unref?.() + const timer = setTimeout(() => controller.abort(new Error('Worker deadline exceeded')), WORKER_TIMEOUT_MS); timer.unref?.() const work = monitoredInvoke(ctx, file, tool, args, exec, controller, config) active.set(controller, { owner: agent, promise: work }) try { return await work } diff --git a/src/ptc/worker.js b/src/ptc/worker.js index ab5aee59..c0dbaf79 100644 --- a/src/ptc/worker.js +++ b/src/ptc/worker.js @@ -1,4 +1,5 @@ /** Native worker bridge. Every execution uses DSH policy, confinement and process governance. */ +import { workerEnvelope } from '../worker-protocol.js' import { randomUUID } from 'node:crypto' import path from 'node:path' import fs from 'node:fs' @@ -47,7 +48,8 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { if (policy.mode === 'workspace-write' && requiredMode(policy) === 'danger-full-access') { return denied(policy, `${tool} outside the workspace and session temporary directory`, 'danger-full-access', 'The selected root is outside the user-authorized workspace.') } - const input = JSON.stringify({ root: operationRoot, scope: String(session.id), tool, args: workerArgs, read_only: readOnly, deny_roots: (config?.denyCredentialReads ?? true) !== false ? credentialRoots() : [], deny_credential_names: (config?.denyCredentialReads ?? true) !== false, ...(jobId ? { job_id: jobId } : {}) }) + const envelope = workerEnvelope('ptc', { root: operationRoot, scope: String(session.id), tool, args: workerArgs, read_only: readOnly, deny_roots: (config?.denyCredentialReads ?? true) !== false ? credentialRoots() : [], deny_credential_names: (config?.denyCredentialReads ?? true) !== false, ...(jobId ? { job_id: jobId } : {}) }) + const input = JSON.stringify(envelope) if (Buffer.byteLength(input) > 2 * 1024 * 1024) return failure('OUTPUT_LIMIT', 'Request exceeds the 2 MiB byte budget.') let argv = [file] if (policy.mode !== 'danger-full-access') { @@ -71,7 +73,7 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { return authorityChanged(current, operation) } const handle = subprocess.spawn({ argv, cwd: policy.workspaceRoot, - stdio: { stdin: { data: input }, stdout: { maxBytes: 128 * 1024 }, stderr: { maxBytes: 16 * 1024 } }, + stdio: { stdin: { data: input }, stdout: { maxBytes: envelope.budget.maxBytes }, stderr: { maxBytes: 16 * 1024 } }, graceMs: 1000, signal }) spawned = true const outcome = await handle.done diff --git a/src/worker-protocol.js b/src/worker-protocol.js new file mode 100644 index 00000000..be9fa3fa --- /dev/null +++ b/src/worker-protocol.js @@ -0,0 +1,15 @@ +/** One-shot worker transport. Only trusted bridge code supplies envelope metadata and budgets. */ +import { randomUUID } from 'node:crypto' + +export const WORKER_TIMEOUT_MS = 300000 +export const WORKER_OUTPUT_BYTES = 128 * 1024 + +export function workerEnvelope(component, request, timeoutMs = WORKER_TIMEOUT_MS) { + timeoutMs ??= WORKER_TIMEOUT_MS + const timeout = Math.max(1, Math.trunc(timeoutMs)) + if (!['file', 'ptc'].includes(component) || !Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeout > 2147483647) { + throw new TypeError('Invalid internal worker component or timeout') + } + return { version: 1, requestId: randomUUID(), component, + budget: { timeoutMs: timeout, maxBytes: WORKER_OUTPUT_BYTES }, request } +} diff --git a/test/authority.test.mjs b/test/authority.test.mjs index 979d4953..372810ff 100644 --- a/test/authority.test.mjs +++ b/test/authority.test.mjs @@ -134,7 +134,7 @@ await test('PTC canonical containment rejects an external linked root without re for (const current of ['read-only', 'danger-full-access']) { mode = current value = await ptcCall(ctx, 'unused', 'result', { root: link, action: 'page' }, execution(root), signal()) - assert.equal(value.status, 'completed', JSON.stringify(value)); assert.equal(received.root, link) + assert.equal(value.status, 'completed', JSON.stringify(value)); assert.equal(received.request.root, link) } } finally { await shutdown(ctx) } }) diff --git a/test/lib/boundary.mjs b/test/lib/boundary.mjs index 89f55728..a7ac83d7 100644 --- a/test/lib/boundary.mjs +++ b/test/lib/boundary.mjs @@ -97,7 +97,8 @@ export function callWorker(input, { timeoutMs = 20_000 } = {}) { } /** - * Build a worker request in the exact shape `lib/file/worker.js` sends. + * Build an explicit legacy v0 payload for worker boundary and compatibility tests. + * Current host bridges wrap this payload in the versioned transport envelope. * @param {string} tool - the tool name. * @param {object} args - the tool arguments. * @param {object} [options] - transient request fields. diff --git a/test/worker-protocol.test.mjs b/test/worker-protocol.test.mjs new file mode 100644 index 00000000..dfe23d9b --- /dev/null +++ b/test/worker-protocol.test.mjs @@ -0,0 +1,134 @@ +/** Real one-shot transport checks plus trusted host bridge metadata. */ +import fs from 'node:fs' +import path from 'node:path' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { WORKER, requireWorker, request, callWorker, envelopeOf } from './lib/boundary.mjs' +import { bootHost, shutdown } from './lib/host.mjs' +import { workerEnvelope, WORKER_OUTPUT_BYTES, WORKER_TIMEOUT_MS } from '../lib/worker-protocol.js' +import { invoke as fileCall } from '../lib/file/worker.js' +import { invoke as ptcCall } from '../lib/ptc/worker.js' + +requireWorker('worker-protocol') +const signal = () => new AbortController().signal +const execution = root => ({ agent: { session: { id: 'protocol-session', header: { cwd: root } } } }) +const send = input => callWorker(JSON.stringify(input)) + +await test('bridge budgets keep existing null and fractional timer semantics', () => { + assert.equal(workerEnvelope('file', {}, null).budget.timeoutMs, WORKER_TIMEOUT_MS) + assert.equal(workerEnvelope('file', {}, 0.5).budget.timeoutMs, 1) + assert.equal(workerEnvelope('ptc', {}, 12.9).budget.timeoutMs, 12) + for (const timeout of [0, -1, NaN, Infinity, 2147483648, '100']) { + assert.throws(() => workerEnvelope('file', {}, timeout), TypeError) + } + assert.throws(() => workerEnvelope('shell', {}), TypeError) +}) + +await test('versioned and explicit legacy requests produce the same canonical result', async () => { + await withTempDir('protocol-formats', async root => { + const file = path.join(root, 'notes.txt'); fs.writeFileSync(file, 'one\ntwo\n') + const input = request('inspect_local_file', { path: file }, { root, readOnly: true }) + const legacy = envelopeOf(await send(input), assert) + const versioned = envelopeOf(await send(workerEnvelope('file', input)), assert) + assert.deepEqual(versioned, legacy) + assert.equal(fs.existsSync(path.join(root, '.dsh-ops')), false) + }) +}) + +await test('unsupported and incomplete envelopes fail before creating business or audit files', async () => { + await withTempDir('protocol-invalid', async root => { + const file = path.join(root, 'new.txt') + const base = workerEnvelope('file', request('write_file', { path: file, content: 'new', expectedRevision: 'absent' }, { root })) + const cases = [ + { ...base, version: 2 }, { ...base, version: null }, { ...base, budget: { timeoutMs: 1, maxBytes: 1023 } }, + { ...base, requestId: '' }, { ...base, component: 'shell' }, { ...base, extra: true }, + ] + for (const key of ['version', 'requestId', 'component', 'budget', 'request']) { + const partial = { ...base }; delete partial[key]; cases.push(partial) + } + for (const input of cases) { + const value = envelopeOf(await send(input), assert) + assert.equal(value.status, 'error') + assert.equal(value.error.effectState, 'none') + assert.equal(fs.existsSync(file), false) + assert.equal(fs.existsSync(path.join(root, '.dsh-ops')), false) + } + }) +}) + +await test('serialized output budgets include the error receipt and never hide possible writes', async () => { + await withTempDir('protocol-output', async root => { + const inputFile = path.join(root, 'large.txt'); fs.writeFileSync(inputFile, '\0'.repeat(10000)) + const read = workerEnvelope('file', request('inspect_local_file', { path: inputFile }, { root, readOnly: true })) + read.budget.maxBytes = 1024 + const observed = await send(read) + const refusal = envelopeOf(observed, assert) + assert.ok(Buffer.byteLength(observed.stdout) <= 1024) + assert.equal(refusal.error.code, 'OUTPUT_LIMIT'); assert.equal(refusal.error.effectState, 'none') + fs.unlinkSync(inputFile) + const files = Array.from({ length: 12 }, (_, index) => path.join(root, `file-${index}.txt`)) + for (const file of files) fs.writeFileSync(file, 'TOKEN') + const write = workerEnvelope('file', request('replace', { path: root, pattern: 'TOKEN', replacement: 'DONE' }, { root })) + write.budget.maxBytes = 1024 + const changed = await send(write) + const outcome = envelopeOf(changed, assert) + assert.ok(Buffer.byteLength(changed.stdout) <= 1024) + assert.equal(outcome.error.code, 'OUTPUT_LIMIT'); assert.equal(outcome.error.effectState, 'unknown') + assert.equal(outcome.error.retryable, false) + for (const file of files) assert.equal(fs.readFileSync(file, 'utf8'), 'DONE') + }) +}) + +await test('each bridge emits fresh trusted metadata and collects the advertised byte budget', async () => { + await withTempDir('protocol-bridges', async root => { + const { ctx } = await bootHost(); const calls = [] + try { + await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: () => ({ mode: 'danger-full-access', workspaceRoot: root }) })) + await ctx.plugin(inner => inner.provide('subprocess', { spawn: spec => { + calls.push(spec) + return { done: Promise.resolve({ exitCode: 0 }), collected: { + stdout: { readFrom: () => ({ text: '{"status":"completed"}', lossy: false }) }, stderr: { readFrom: () => ({ text: '', lossy: false }) }, + } } + } })) + for (const [call, tool, args, component, timeout] of [ + [fileCall, 'grep', { path: root, pattern: 'x', requestId: 'model-value' }, 'file', 1234], + [ptcCall, 'result', { action: 'put', items: [] }, 'ptc', WORKER_TIMEOUT_MS], + ]) { + const value = await call(ctx, 'worker', tool, args, execution(root), signal(), { toolCallTimeoutMs: timeout }) + assert.equal(value.status, 'completed', JSON.stringify(value)) + const spec = calls.at(-1), envelope = JSON.parse(spec.stdio.stdin.data) + assert.equal(envelope.version, 1); assert.equal(envelope.component, component) + assert.match(envelope.requestId, /^[a-f0-9-]{36}$/) + assert.equal(envelope.budget.timeoutMs, timeout) + assert.equal(envelope.budget.maxBytes, WORKER_OUTPUT_BYTES) + assert.equal(spec.stdio.stdout.maxBytes, envelope.budget.maxBytes) + assert.equal(envelope.request.scope, 'protocol-session') + assert.equal(envelope.request.tool, tool) + } + assert.notEqual(JSON.parse(calls[0].stdio.stdin.data).requestId, JSON.parse(calls[1].stdio.stdin.data).requestId) + assert.equal(JSON.parse(calls[0].stdio.stdin.data).request.args.requestId, 'model-value') + } finally { await shutdown(ctx) } + }) +}) + +await test('a worker deadline interrupts the one-shot process and the write bridge reports unknown', async () => { + await withTempDir('protocol-deadline', async root => { + const file = path.join(root, 'large.txt') + // Reading and counting every occurrence exceeds 1ms; the watchdog does not rely on host cancellation. + fs.writeFileSync(file, Buffer.alloc(32 * 1024 * 1024, 120)) + const input = workerEnvelope('file', request('replace', { path: file, pattern: 'x', replacement: 'y', maxReplacements: 100000 }, { root }), 1) + const raw = await send(input) + assert.equal(raw.timedOut, false) + assert.equal(raw.exitCode, 124) + assert.equal(raw.stdout, '') + const { ctx } = await bootHost() + try { + await ctx.plugin(Subprocess) + await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: () => ({ mode: 'danger-full-access', workspaceRoot: root }) })) + const value = await fileCall(ctx, WORKER, 'replace', input.request.args, execution(root), signal(), { toolCallTimeoutMs: 1 }) + assert.equal(value.status, 'error'); assert.equal(value.error.code, 'RESULT_UNKNOWN') + assert.equal(value.error.effectState, 'unknown'); assert.equal(value.error.retryable, false) + } finally { await shutdown(ctx) } + }) +}) +report('worker-protocol') From d1ea9af5fa684038b4da663201f4e545a1e27ced Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:30:55 +0800 Subject: [PATCH 005/118] feat(file): [3/16] render bounded Native text results --- docs/CHANGELOG.md | 11 ++ docs/native-ptc.md | 14 +- docs/usage.md | 25 +++ ...77\347\224\250\346\214\207\345\215\227.md" | 20 +++ native/src/contract.rs | 1 + native/src/file.rs | 9 +- native/src/file_single.rs | 4 +- src/file/contract.ts | 14 +- src/modes/native/render.ts | 170 ++++++++++++++++++ test/capabilities.test.mjs | 3 +- test/file-operations.test.mjs | 2 +- test/file-sdk-render.test.mjs | 145 +++++++++++++-- test/native-projection.test.mjs | 93 ++++++++++ 13 files changed, 470 insertions(+), 41 deletions(-) create mode 100644 src/modes/native/render.ts create mode 100644 test/native-projection.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2971cf5f..8571d513 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,17 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Render Native file results as bounded, readable text (#261) + +- Show file headings and physical line numbers, concise mutation receipts, and explicit + completion, paging, metadata, effect and retry notices. PTC execution data stays unchanged. +- Bound Native content blocks to 64 KiB after escaping, with recovery guidance when the + display is clipped. Preserve Unicode pairs, grep context line numbers and glob path results. +- Reject invalid UTF-8/unsupported text encodings with `ERR_INVALID_ENCODING` and binary-reader + guidance; verify identical physical-LF counts and unmodified write bytes across projections. +- Native 文件结果改为带物理行号的有界文本,保留分页、元数据、副作用与重试提示;PTC 数据不变。 + 转义后最多 64 KiB,截断时明确提示;非法编码返回 `ERR_INVALID_ENCODING`,不进行有损替换。 + ### Centralize capability metadata and prepare typed kernel contracts (#261) - Move policy and file/PTC contracts to TypeScript, with one capability catalogue for names, diff --git a/docs/native-ptc.md b/docs/native-ptc.md index 84000730..c954143e 100644 --- a/docs/native-ptc.md +++ b/docs/native-ptc.md @@ -169,11 +169,15 @@ if (result.status === "error" || result.status === "rejected") { File parameter bounds are described in the host-compatible SDK schema and enforced by the native worker, even for direct PTC calls. Required fields, enums, nested edit shapes and strict unknown-field rejection remain discoverable. -Inspect's model rendering shares a homogeneous page's path once at the top level; -raw execution/PTC values retain each item's path and all pagination/truncation -metadata. This reduces rendered bytes, not worker pagination or measured token -billing. Long single-line text continuation is unchanged; use the hex view to -read beyond an in-line cut. +Native file rendering uses `=== FILE: path (Lines a-b of n) ===` and `N | text`, +with concise mutation receipts and explicit paging/effect/retry notices. The raw +execution value returned to PTC remains unchanged. Content blocks are capped at +64 KiB after JSON escaping; clipped displays instruct the caller to recover +undisplayed content without blindly advancing a cursor or repeating a write. +Physical LF defines line counts across projections; bare CR/control separators +are escaped for display. Invalid text encoding returns `ERR_INVALID_ENCODING` +with document/binary-reader guidance. Use hex byte rows to read beyond an in-line +cut. See [usage](usage.md#native-file-results) / [使用指南](使用指南.md#native-文件结果). ## Plan and execution semantics diff --git a/docs/usage.md b/docs/usage.md index d8704fda..e556a299 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -154,3 +154,28 @@ This version starts no long-lived server process: the native file worker is owne - [Component reference](component-reference.md) — components and tools - [Troubleshooting](troubleshooting.md) — errors and diagnosis - [Native PTC](native-ptc.md) — the PTC design and build + + +## Native file results + +Native file tools present readable text: `=== FILE: path (Lines a-b of n) ===` +followed by `N | text`. Paths, observed revisions and paging notices remain visible. +PTC execution values remain structured objects; inspect `status` and completeness +before using them. Grep context labels use the physical source line, while glob +path mode lists one path per line. Stat retains size, revision and metadata facts; +diff shows removed/added lines. Mutation tools return short receipts without +repeating file contents. + +Only physical LF ends a line (CRLF is one boundary). Empty files have zero lines; +a final LF adds no empty line. Bare CR and Unicode separators do not add lines; control separators are escaped +in the display so they cannot break a numbered line. +Writing text preserves its bytes and line endings. Invalid UTF-8 or an unsupported +text encoding returns `ERR_INVALID_ENCODING`, with guidance to use a document/binary +reader or `view: "hex"`; no lossy text replacement is performed. + +Native content blocks are capped at 64 KiB after JSON escaping. A clipped display +says `Display incomplete` / `TruncatedBy: output-budget`; request a smaller original +read range instead of advancing past undisplayed text. A truncated line cannot be +recovered by moving to the next line: use hex byte rows. A clipped mutation receipt +is not permission to repeat a write: inspect current state first. Error notices +show effect state and retry guidance before potentially long diagnostic messages. diff --git "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" index 0bcdc7cf..3fe51e4d 100644 --- "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -152,3 +152,23 @@ Without `--profile`, uninstall only cleans legacy provisioned runtime/shell file - [组件参考](组件参考.md) —— 组件与工具列表 - [常见问题](常见问题.md) —— 报错与排查 - [原生 PTC](native-ptc.md) —— PTC 设计与使用 + + +## Native 文件结果 + +Native 文件工具以 `=== FILE: path (Lines a-b of n) ===` 和 `N | text` +显示内容,并保留路径、已观察到的 revision 与分页提示。PTC 的执行值仍是结构化对象, +使用前须检查 `status` 和完整性。Grep 的上下文行号对应真实源文件;glob 的路径模式 +每行显示一个路径。Stat 保留大小、revision 与元数据,diff 显示删除/添加的行; +修改操作只返回简短回执,不重复输出文件内容。 + +只有物理 LF 才是行边界(CRLF 只算一个)。空文件有零行,末尾 LF 不产生额外空行; +单独 CR 和 Unicode 分隔符不增加行数;显示时转义控制分隔符,避免拆开带行号的一行。写入保留原始字节和换行符。非法 UTF-8 或 +不支持的文本编码返回 `ERR_INVALID_ENCODING`,建议使用专用文档/二进制读取工具或 +`view: "hex"`,不会用乱码替代非法字节。 + +Native 内容块经 JSON 转义后最多 64 KiB。截断显示明确标记 `Display incomplete` / +`TruncatedBy: output-budget`;应缩小原始读取范围,不能跳过尚未显示的内容。 +单行截断无法通过下一行偏移恢复尾部,须用 hex 字节行读取。修改回执显示被截断时, +应先检查当前状态,不能为了获取回执重复写入。错误提示在可能很长的诊断文字之前 +显示副作用状态与重试指引。 diff --git a/native/src/contract.rs b/native/src/contract.rs index 85d4fc33..85648bf6 100644 --- a/native/src/contract.rs +++ b/native/src/contract.rs @@ -72,6 +72,7 @@ impl Failure { retryable: false, effect_state: "none".into(), next_action: match code { + "ERR_INVALID_ENCODING" => "Use a dedicated document/binary reader, or inspect with view=hex; do not replace invalid bytes with lossy text.", "METADATA_UNSUPPORTED" => "Use a filesystem/platform or dedicated tool that preserves the source metadata. Changing the permission selector or blindly retrying cannot repair this limitation.", "REFERENCE_EXPIRED" => "Release the expired result and acquire a new observation; references never grant authority.", "REVISION_CONFLICT" => "Inspect the current revision before preparing a new request; do not blindly retry.", diff --git a/native/src/file.rs b/native/src/file.rs index bcf5bae0..fe3fb772 100644 --- a/native/src/file.rs +++ b/native/src/file.rs @@ -342,7 +342,8 @@ fn result(items: Vec, total: usize, offset: usize, next_base: usize, matc fn inspect(request: &Request) -> Result { let args: InspectArgs = serde_json::from_value(request.args.clone())?; - if args.limit == 0 || args.limit > 10_000 || !args.encoding.eq_ignore_ascii_case("utf-8") { return Err(Failure::new("INVALID_ARGUMENT", "inspect supports UTF-8 and a bounded limit")); } + if args.limit == 0 || args.limit > 10_000 { return Err(Failure::new("INVALID_ARGUMENT", "inspect limit is 1-10000")); } + if !args.encoding.eq_ignore_ascii_case("utf-8") { return Err(Failure::new("ERR_INVALID_ENCODING", "Text inspection supports UTF-8 only")); } let path = target(&args.path, false)?; if !path.is_file() { return Err(Failure::new("INVALID_ARGUMENT", "path is a directory; use ops_glob or ops_grep")); } let bytes = read_bytes(&path)?; @@ -361,7 +362,9 @@ fn inspect(request: &Request) -> Result { return Ok(json!({"status":"completed","phase":"verify","changed":0,"matched":1,"skipped":start,"failed":0,"complete":end == bytes.len(),"truncated":end < bytes.len(),"nextOffset":end.div_ceil(16)+1,"omitted":bytes.len().saturating_sub(end),"bytesTotal":bytes.len(),"revision":facts["revision"],"bytes":facts["bytes"],"lineCount":facts["lineCount"],"items":[{"path":path,"view":"hex","offset":start,"text":text}]})); } if args.view != "text" { return Err(Failure::new("INVALID_ARGUMENT", "view must be text or hex")); } - let text = String::from_utf8(bytes).map_err(|_| Failure::new("INVALID_ARGUMENT", "file is not valid UTF-8"))?; + let text = String::from_utf8(bytes).map_err(|_| Failure::new("ERR_INVALID_ENCODING", "File is not valid UTF-8"))?; + // str::lines splits physical LF/CRLF, never bare CR or Unicode separators. + // Empty files have zero lines; a final LF does not create a phantom line. let lines: Vec<_> = text.lines().enumerate().skip(args.offset.saturating_sub(1)).take(args.limit).map(|(i, line)| json!({"path":path,"line":i+1,"text":line})).collect(); let mut output = result(lines, text.lines().count(), args.offset.saturating_sub(1), args.offset, text.lines().count(), false); for key in ["revision", "bytes", "lineCount"] { output[key] = facts[key].clone(); } @@ -504,7 +507,7 @@ fn replace(request: &Request) -> Result { // The revision is checked against the bytes the lock returned, so the check // and the write cannot be separated by another open — see the lock for why. let expected=item["revision"].as_str().unwrap_or(""); publication::check_revision(Some(&bytes), expected)?; - let text=String::from_utf8(bytes).map_err(|_| Failure::new("INVALID_ARGUMENT", "replace only supports UTF-8 files"))?; let next=text.replace(&args.pattern,&args.replacement); + let text=String::from_utf8(bytes).map_err(|_| Failure::new("ERR_INVALID_ENCODING", "Replace only supports UTF-8 files"))?; let next=text.replace(&args.pattern,&args.replacement); // Report what the replacement kept. The fields are additive, so a caller // that ignores them sees exactly the envelope it saw before. if let Some((metadata,warnings))=atomic_write(&file,next.as_bytes())? { item["metadata"]=metadata; if !warnings.is_empty() { item["metadataWarnings"]=json!(warnings); } } diff --git a/native/src/file_single.rs b/native/src/file_single.rs index d3ff45c1..5a3b1af6 100644 --- a/native/src/file_single.rs +++ b/native/src/file_single.rs @@ -83,7 +83,7 @@ pub(super) fn run(request: &Request) -> Result { let text = std::str::from_utf8(original.ok_or_else(|| { Failure::new("NOT_FOUND", "Edit requires an existing file") })?) - .map_err(|_| Failure::new("INVALID_ARGUMENT", "Edit requires UTF-8"))?; + .map_err(|_| Failure::new("ERR_INVALID_ENCODING", "Edit requires UTF-8"))?; if args.edits.is_empty() || args.edits.len() > 100 { return Err(Failure::new("INVALID_ARGUMENT", "Use 1-100 exact edits")); } @@ -258,7 +258,7 @@ fn diff_file(request: &Request) -> Result { } let bytes = read_bytes(Path::new(&args.path))?; let text = std::str::from_utf8(&bytes) - .map_err(|_| Failure::new("INVALID_ARGUMENT", "Diff requires UTF-8"))?; + .map_err(|_| Failure::new("ERR_INVALID_ENCODING", "Diff requires UTF-8"))?; let old: Vec<_> = text.split_inclusive('\n').collect(); let new: Vec<_> = args.content.split_inclusive('\n').collect(); let prefix = old.iter().zip(&new).take_while(|(a, b)| a == b).count(); diff --git a/src/file/contract.ts b/src/file/contract.ts index 0f331f1a..40d8a232 100644 --- a/src/file/contract.ts +++ b/src/file/contract.ts @@ -5,8 +5,7 @@ import type { FileToolName } from '../capabilities/registry.js' import { PARAMETERS, DESCRIPTIONS, OUTPUT } from '../capabilities/file.js' export { FILE_TOOLS } from '../capabilities/registry.js' export { PARAMETERS, DESCRIPTIONS, OUTPUT } -type OutputValue = Parameters[1] -const record = (value: OutputValue | undefined): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value) +import { renderNative } from '../modes/native/render.js' /** Drop unused empty placeholders strict adapters send for optional fields; never mutate the caller's object. */ export function normalizeArguments(rawName: string, args: unknown): unknown { @@ -28,22 +27,13 @@ export function normalizeArguments(rawName: string, args: unknown): unknown { return result } -// Compact only homogeneous inspect pages. Keep every non-path field and the -// complete execution value for PTC/program consumers; never mutate worker data. -function modelView(rawName: FileToolName, value: OutputValue): OutputValue { - if (rawName !== 'inspect_local_file' || !record(value) || value.status !== 'completed' || !Array.isArray(value.items) || value.items.length === 0) return value - const path = (record(value.items[0]) ? value.items[0].path : undefined) - if (typeof path !== 'string' || !value.items.every(item => record(item) && item.path === path)) return value - return { ...value, path, items: value.items.map(item => { const { path: _path, ...rest } = item as Record; return rest }) } -} - export function definition(rawName: FileToolName, execute: ToolDefinition['execute']): ToolDefinition { return { name: publicToolName(rawName), description: DESCRIPTIONS[rawName], parameters: PARAMETERS[rawName], ...(capability(rawName)?.concurrencySafe ? { isConcurrencySafe: () => true } : {}), - output: { schema: OUTPUT, render: (_args, value) => [{ type: 'text', text: JSON.stringify(modelView(rawName, value)) }] }, + output: { schema: OUTPUT, render: (args, value) => [{ type: 'text', text: renderNative(rawName, args, value) }] }, execute, } } diff --git a/src/modes/native/render.ts b/src/modes/native/render.ts new file mode 100644 index 00000000..94f8eb53 --- /dev/null +++ b/src/modes/native/render.ts @@ -0,0 +1,170 @@ +import { Buffer } from 'node:buffer' +import { errorPolicy } from '../../kernel/errors.js' +import type { EffectState } from '../../kernel/outcome.js' + +export const NATIVE_OUTPUT_BYTES = 64 * 1024 + +type Row = Record +const record = (value: unknown): value is Row => value !== null && typeof value === 'object' && !Array.isArray(value) +const rows = (value: unknown): Row[] => Array.isArray(value) ? value.filter(record) : [] +const count = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +const label = (value: unknown): string => String(value ?? '').replace(/[\r\n\t\x00-\x1f\x7f]/g, char => JSON.stringify(char).slice(1, -1)) +// Keep physical line labels intact in Markdown, including bare CR and Unicode separators. +const contentLine = (value: unknown): string => label(value).replace(/[\u0085\u2028\u2029]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`) +const field = (value: unknown): string => typeof value === 'string' ? label(value) : JSON.stringify(value) +const encodedBytes = (text: string): number => Buffer.byteLength(JSON.stringify([{ type: 'text', text }]), 'utf8') + +/** Bound the final escaped content blocks, not the unescaped source text. */ +export function capNativeText(text: string, maxBytes = NATIVE_OUTPUT_BYTES, recovery = 'Re-read the original range with a smaller limit; do not advance past undisplayed content.'): string { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 512 || maxBytes > NATIVE_OUTPUT_BYTES) { + throw new RangeError('Native output budget must be 512-65536 bytes') + } + if (encodedBytes(text) <= maxBytes) return text + const notice = `\n[Notice: Display incomplete. TruncatedBy: output-budget. ${recovery}]` + let low = 0 + let high = text.length + while (low < high) { + const middle = Math.ceil((low + high) / 2) + if (encodedBytes(text.slice(0, middle) + notice) <= maxBytes) low = middle + else high = middle - 1 + } + // Do not emit half of an astral Unicode character when the budget lands inside a pair. + if (low > 0 && /[\uD800-\uDBFF]/u.test(text[low - 1]!)) low-- + return text.slice(0, low) + notice +} + +function facts(value: Row, keys: string[]): string { + return keys.filter(key => value[key] !== undefined).map(key => `${key}=${field(value[key])}`).join('; ') +} +function notices(value: Row, displayed: number): string[] { + const incomplete = value.complete === false || value.truncated === true || value.status === 'partial' + const details = [ + `${displayed} item${displayed === 1 ? '' : 's'} displayed`, + `complete=${value.complete === undefined ? (incomplete ? 'false' : 'unspecified') : value.complete}`, + `TruncatedBy: ${value.truncatedBy ?? (incomplete ? 'worker-limit' : 'none')}`, + facts(value, ['nextOffset', 'omitted', 'total', 'matched', 'files', 'matches', 'skippedFiles', 'skippedPaths', 'failed', 'continuation']), + ].filter(Boolean) + const output = [`[Notice: ${details.join('; ')}.]`] + for (const item of rows(value.items)) { + if (item.truncatedInLine === true) { + output.push(`[Notice: Line ${label(item.line)} in ${label(item.path)} is incomplete; ${facts(item, ['bytesTotal', 'bytesOmitted'])}. Use a smaller or binary range; the next line offset does not recover its missing suffix.]`) + } + if (item.metadataWarnings !== undefined) output.push(`[Notice: ${label(item.path)} metadataWarnings=${field(item.metadataWarnings)}.]`) + } + return output +} + +function inspect(args: Row, value: Row): string[] { + const items = rows(value.items) + const output: string[] = [] + const groups = new Map() + for (const item of items) { + const path = typeof item.path === 'string' ? item.path : String(args.path ?? value.path ?? '(path unavailable)') + const group = groups.get(path) ?? [] + group.push(item) + groups.set(path, group) + } + if (groups.size === 0) groups.set(String(args.path ?? value.path ?? '(path unavailable)'), []) + for (const [path, group] of groups) { + const hex = group.some(item => item.view === 'hex') || args.view === 'hex' + const numbers = group.map(item => item.line).filter(count) + const span = numbers.length ? `${numbers[0]}-${numbers.at(-1)}` : 'none' + const total = count(value.lineCount) ? value.lineCount : count(value.total) ? value.total : '?' + output.push(`=== FILE: ${label(path)} (${hex ? 'Hex' : `Lines ${span} of ${total}`}) ===`) + const observed = facts(value, ['revision', 'bytes', 'lineCount']) + if (observed) output.push(`[Notice: ${observed}.]`) + for (const item of group) { + if (hex) output.push(`${label(item.offset ?? '?')} | ${contentLine(item.text)}`) + else output.push(`${count(item.line) ? item.line : '?'} | ${contentLine(item.text)}`) + } + if (!group.length) output.push(value.lineCount === 0 ? '(empty file)' : '(no lines in this range)') + } + output.push(...notices(value, items.length)) + return output +} + +function failure(value: Row): string[] { + const error = record(value.error) ? value.error : {} + const code = typeof error.code === 'string' ? error.code : 'UNKNOWN_ERROR' + const effect = ['none', 'applied', 'partial', 'unknown'].includes(String(error.effectState)) + ? error.effectState as EffectState : 'unknown' + const advice = errorPolicy(code, effect) + const status = value.status === 'rejected' && effect === 'none' ? 'rejected' : advice.status + const output = [`${status}: ${label(code)}`, + `[Notice: effectState=${effect}; retry=${advice.retry}; ${facts(error, ['phase', 'reason'])}.]`] + if (typeof error.nextAction === 'string') output.push(`Next: ${label(error.nextAction)}`) + if (error.message) output.push(label(error.message)) + if (rows(value.items).length) output.push('Partial receipts:', ...receiptItems(value)) + return output +} + +const mutations = new Set(['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) +function receiptItems(value: Row): string[] { + return rows(value.items).map(item => `${label(item.path ?? '(path unavailable)')}: ${facts(item, [ + 'removed', 'changed', 'revision', 'revisionBefore', 'revisionAfter', 'bytes', 'bytesBefore', 'bytesAfter', + 'lineCountBefore', 'lineCount', 'replacements', 'metadata', 'metadataWarnings', + ])}`) +} +function receipts(tool: string, value: Row): string[] { + const output = [`${label(tool)}: ${value.dryRun === true ? 'preview' : label(value.status ?? 'unknown')}; ${facts(value, ['changed', 'matched', 'skipped', 'failed', 'revisionBefore'])}`, + ...receiptItems(value)] + output.push(...notices(value, rows(value.items).length)) + return output +} +function stat(args: Row, value: Row): string[] { + return [`${label(value.path ?? args.path ?? '(path unavailable)')}: ${facts(value, [ + 'exists', 'revision', 'bytes', 'lineCount', 'readonly', 'modifiedMs', 'mode', + ])}`, ...notices(value, rows(value.items).length)] +} +function diff(args: Row, value: Row): string[] { + const output = [`=== DIFF: ${label(args.path ?? value.path ?? '(path unavailable)')} ===`] + for (const key of ['before', 'after', 'hunk']) { + if (record(value[key])) output.push(`[Notice: ${key}: ${facts(value[key], Object.keys(value[key]))}.]`) + } + for (const item of rows(value.items)) { + const sign = item.kind === 'remove' ? '-' : item.kind === 'add' ? '+' : '?' + output.push(`${sign} ${label(item.line ?? '?')} | ${contentLine(String(item.text ?? '').replace(/\r?\n$/, ''))}`) + } + output.push(...notices(value, rows(value.items).length)) + return output +} + +function listing(tool: string, args: Row, value: Row): string[] { + const output: string[] = [] + const items = Array.isArray(value.items) ? value.items : [] + for (const item of items) { + if (typeof item === 'string') { output.push(label(item)); continue } + if (!record(item)) continue + const path = label(item.path ?? '(path unavailable)') + if (tool === 'grep' && typeof item.text === 'string') { + const numbered = count(item.line) && item.line > 0 + const start = numbered ? Math.max(1, Number(item.line) - (count(args.beforeContext) ? args.beforeContext : 0)) : 0 + if (numbered && start !== item.line) output.push(`[Notice: Match starts at ${path}:${item.line}; context follows.]`) + for (const [index, line] of item.text.split('\n').entries()) output.push(`${path}${numbered ? `:${start + index}` : ''} | ${contentLine(line)}`) + } else { + const rest = { ...item } + delete rest.path + output.push(`${path}${Object.keys(rest).length ? `: ${facts(rest, Object.keys(rest))}` : ''}`) + } + } + const summary = facts(value, ['exists', 'revision', 'bytes', 'lineCount', 'metadata', 'revisionBefore', 'revisionAfter', 'changed', 'dryRun']) + if (summary) output.unshift(summary) + output.push(...notices(value, items.length)) + return output +} + +/** Native presentation is independent of the canonical execution value returned to PTC. */ +export function renderNative(tool: string, args: unknown, result: unknown): string { + if (!record(result)) return capNativeText('[Notice: No structured result was returned.]') + const input = record(args) ? args : {} + let lines: string[] + if (record(result.error) || ['error', 'rejected', 'conflict', 'unknown', 'failed'].includes(String(result.status))) lines = failure(result) + else if (tool === 'inspect_local_file') lines = inspect(input, result) + else if (mutations.has(tool)) lines = receipts(tool, result) + else if (tool === 'stat') lines = stat(input, result) + else if (tool === 'diff') lines = diff(input, result) + else lines = listing(tool, input, result) + return capNativeText(lines.join('\n'), NATIVE_OUTPUT_BYTES, mutations.has(tool) + ? 'Inspect current state before another mutation; do not repeat the write to recover undisplayed receipts.' + : undefined) +} diff --git a/test/capabilities.test.mjs b/test/capabilities.test.mjs index 45b76b1a..95609f8b 100644 --- a/test/capabilities.test.mjs +++ b/test/capabilities.test.mjs @@ -63,7 +63,8 @@ await test('native registration consumes the catalogue while execute values stay const tool = definition(name, async () => canonical) assert.equal(await tool.execute({}, {}), canonical) assert.equal(canonical.items[0].path, '/file') - if (name !== 'inspect_local_file') assert.deepEqual(JSON.parse(tool.output.render({}, canonical)[0].text), canonical) + if (names === PTC_TOOLS) assert.deepEqual(JSON.parse(tool.output.render({}, canonical)[0].text), canonical) + else assert.equal(typeof tool.output.render({}, canonical)[0].text, 'string') } } await runtime.stop() diff --git a/test/file-operations.test.mjs b/test/file-operations.test.mjs index 04f3b54e..e3e98e51 100644 --- a/test/file-operations.test.mjs +++ b/test/file-operations.test.mjs @@ -81,7 +81,7 @@ await test('precise edits use one original, preserve Unicode/CRLF/BOM and reject fs.writeFileSync(target, 'aaa') failed(await call('edit_file', { path: target, expectedRevision: rev(target), edits: [{ oldText: 'aa', newText: 'b' }] }, root), 'EDIT_CONFLICT') fs.writeFileSync(target, Buffer.from([255])) - failed(await call('edit_file', { path: target, expectedRevision: rev(target), edits: [{ oldText: 'a', newText: '' }] }, root), 'INVALID_ARGUMENT') + failed(await call('edit_file', { path: target, expectedRevision: rev(target), edits: [{ oldText: 'a', newText: '' }] }, root), 'ERR_INVALID_ENCODING') }) }) await test('diff is a bounded read-only replacement hunk with exact before/after facts and continuation', async () => { diff --git a/test/file-sdk-render.test.mjs b/test/file-sdk-render.test.mjs index c7924149..8ff30f75 100644 --- a/test/file-sdk-render.test.mjs +++ b/test/file-sdk-render.test.mjs @@ -2,6 +2,7 @@ import fs from 'node:fs' import { assertSupportedJsonSchema, jsonSchemaToTs } from '@deepseek-ai/dsh-tools' import { FILE_TOOLS, PARAMETERS, definition, DESCRIPTIONS } from '../lib/file/contract.js' +import { capNativeText, NATIVE_OUTPUT_BYTES } from '../lib/modes/native/render.js' import { renderToolingPolicy, publicToolName } from '../lib/policy.js' import { assert, test, report } from './lib/harness.mjs' @@ -23,32 +24,142 @@ await test('all eleven file argument schemas remain discoverable without unknown assert.match(PARAMETERS.copy.properties.expectedRevision.description, /absent is not accepted/) }) -const render = (tool, value) => JSON.parse(definition(tool, async () => value).output.render({}, value)[0].text) -await test('inspect shares a path only in model rendering, retaining all other fields', () => { +const render = (tool, value, args = {}) => definition(tool, async () => value).output.render(args, value)[0].text +await test('inspect prints one file heading and physical line labels without changing the PTC value', async () => { const path = `D:/${'long-directory/'.repeat(7)}source.txt` - const value = { status: 'completed', complete: false, revision: 'a'.repeat(64), nextOffset: 201, - truncated: true, omitted: 1, items: Array.from({ length: 200 }, (_, i) => ({ path, line: i + 1, text: '\"\\中\n' })) } + const value = { status: 'completed', complete: false, revision: 'a'.repeat(64), lineCount: 201, nextOffset: 201, + truncated: true, omitted: 1, items: Array.from({ length: 200 }, (_, i) => ({ path, line: i + 1, text: 'αβ"\\中' })) } value.items[199].truncatedInLine = true value.items[199].bytesTotal = 200000 const original = JSON.stringify(value) const view = render('inspect_local_file', value) - assert.equal(view.path, path) - assert.deepEqual({ ...view, path: undefined, items: view.items.map(item => ({ path, ...item })) }, { ...value, path: undefined }) + assert.ok(view.startsWith(`=== FILE: ${path} (Lines 1-200 of 201) ===`)) + assert.ok(view.includes('1 | αβ"\\中')) + assert.match(view, /revision=a{64}/) + assert.match(view, /complete=false/) + assert.match(view, /nextOffset=201/) + assert.match(view, /Line 200.*incomplete; bytesTotal=200000/) + assert.match(view, /next line offset does not recover/) assert.equal(JSON.stringify(value), original, 'raw PTC/execute data is not mutated') - assert.ok(Buffer.byteLength(JSON.stringify(view)) < Buffer.byteLength(original) / 2) - assert.equal(view.items[199].truncatedInLine, true) - assert.equal(view.nextOffset, 201) + assert.equal(await definition('inspect_local_file', async () => value).execute({}, {}), value) + assert.ok(Buffer.byteLength(view) < Buffer.byteLength(original) / 2) }) -await test('render leaves empty, failed, mixed-path and other tool results untouched', () => { - for (const value of [null, { status: 'error', error: { code: 'IO_ERROR' } }, - { status: 'completed', items: [] }, { status: 'completed', items: [{ text: 'no path' }] }, - { status: 'completed', items: [{ path: '/a', text: 'a' }, { path: '/b', text: 'b' }] }]) { - assert.deepEqual(render('inspect_local_file', value), value) +await test('empty, mixed paths, byte offsets and incomplete pages remain explicit', () => { + const empty = render('inspect_local_file', { status: 'completed', complete: true, lineCount: 0, items: [] }, { path: '/empty' }) + assert.match(empty, /FILE: \/empty \(Lines none of 0\)/) + assert.match(empty, /empty file/) + const mixed = render('inspect_local_file', { complete: false, items: [{ path: '/a', line: 1, text: 'a' }, { path: '/b', line: 2, text: 'b' }] }) + assert.match(mixed, /FILE: \/a \(Lines 1-1 of \?\)/) + assert.match(mixed, /FILE: \/b \(Lines 2-2 of \?\)/) + assert.match(mixed, /complete=false/) + const hex = render('inspect_local_file', { complete: false, nextOffset: 3, items: [{ path: '/a', view: 'hex', offset: 16, text: '00 ff' }] }) + assert.match(hex, /FILE: \/a \(Hex\)/) + assert.match(hex, /16 \| 00 ff/) + assert.match(hex, /nextOffset=3/) + assert.match(render('inspect_local_file', null), /No structured result/) + assert.match(render('inspect_local_file', { items: [] }), /complete=unspecified/) +}) + +await test('escaped output stays within 64 KiB without splitting an astral character or claiming completion', () => { + const value = { complete: true, lineCount: 1, items: [{ path: '/large', line: 1, text: '\u0000"\\中😀'.repeat(30000) }] } + const text = render('inspect_local_file', value) + const blocks = [{ type: 'text', text }] + assert.ok(Buffer.byteLength(JSON.stringify(blocks)) <= NATIVE_OUTPUT_BYTES) + assert.match(text, /Display incomplete.*TruncatedBy: output-budget/) + assert.match(text, /do not advance past undisplayed content/) + assert.doesNotMatch(text, /complete=true/) + assert.equal(text.isWellFormed(), true) + assert.ok(text.startsWith('=== FILE: /large')) + for (let budget = 512; budget < 540; budget++) { + const capped = capNativeText('😀'.repeat(1000), budget) + assert.ok(Buffer.byteLength(JSON.stringify([{ type: 'text', text: capped }])) <= budget) + assert.equal(capped.isWellFormed(), true) + } + assert.throws(() => capNativeText('x', 511), /budget/) + assert.throws(() => capNativeText('x', NATIVE_OUTPUT_BYTES + 1), /budget/) +}) + +await test('short mutation receipts preserve revisions, sizes and metadata without echoing source content', () => { + for (const tool of ['write_file', 'edit_file', 'replace', 'copy', 'move', 'remove']) { + const value = { status: 'completed', complete: true, changed: 1, content: 'DO NOT ECHO', items: [{ + path: '/result', revision: 'a'.repeat(64), revisionBefore: 'b'.repeat(64), bytesBefore: 1, bytesAfter: 3, + lineCount: 1, removed: tool === 'remove', content: 'DO NOT ECHO', metadataWarnings: ['mode unavailable'], + }] } + const text = render(tool, value) + assert.match(text, /completed; changed=1/) + assert.match(text, /revision=a{64}; revisionBefore=b{64}/) + assert.match(text, /bytesBefore=1; bytesAfter=3; lineCount=1/) + assert.match(text, /mode unavailable/) + assert.doesNotMatch(text, /DO NOT ECHO/) + assert.ok(text.length < 800) } - const hex = { status: 'completed', items: [{ path: '/a', view: 'hex', offset: 0, text: '00 ff' }] } - assert.deepEqual(render('inspect_local_file', hex).items, [{ view: 'hex', offset: 0, text: '00 ff' }]) - assert.deepEqual(render('grep', hex), hex) + assert.match(render('write_file', { status: 'completed', dryRun: true, changed: 0, items: [] }), /preview; changed=0/) + const text = render('replace', { status: 'completed', complete: true, items: Array.from({ length: 1000 }, () => ({ path: '/a', revision: 'a'.repeat(64) })) }) + assert.match(text, /do not repeat the write/) + assert.doesNotMatch(text, /Re-read the original range/) +}) + +await test('error guidance respects effects before code and never encourages replaying uncertain writes', () => { + const cases = [ + ['POLICY_DENIED', 'none', 'rejected', 'never'], ['REVISION_CONFLICT', 'none', 'conflict', 'after-state-change'], + ['ERR_INVALID_ENCODING', 'none', 'error', 'after-input-change'], ['IO_ERROR', 'applied', 'error', 'never'], + ['REVISION_CONFLICT', 'unknown', 'unknown', 'never'], ['RESULT_UNKNOWN', 'unknown', 'unknown', 'never'], + ] + for (const [code, effectState, status, retry] of cases) { + const text = render('write_file', { status: 'error', error: { code, effectState, message: 'failure', nextAction: 'Inspect current state.' } }) + assert.ok(text.startsWith(`${status}: ${code}`), text) + assert.ok(text.includes(`effectState=${effectState}; retry=${retry}`), text) + assert.match(text, /Next: Inspect current state/) + } + assert.match(render('write_file', { status: 'error', error: { code: 'IO_ERROR' } }), /effectState=unknown; retry=never/) +}) + +await test('stat, diff and search retain facts needed for follow-up calls', () => { + const stat = render('stat', { exists: true, revision: 'hash', bytes: 4, lineCount: 1, readonly: true, modifiedMs: 123, mode: '0644', complete: true }, { path: '/a' }) + assert.match(stat, /^\/a: exists=true; revision=hash; bytes=4; lineCount=1; readonly=true; modifiedMs=123; mode=0644/) + const diff = render('diff', { before: { revision: 'old' }, after: { revision: 'new' }, hunk: { startLine: 2 }, complete: true, + items: [{ kind: 'remove', line: 2, text: 'old\r\n' }, { kind: 'add', line: 2, text: 'new\n' }] }, { path: '/a' }) + assert.match(diff, /=== DIFF: \/a ===/) + assert.match(diff, /before: revision=old/) + assert.match(diff, /- 2 \| old\n\+ 2 \| new/) + const grep = render('grep', { complete: false, total: 10, skippedFiles: 2, failed: 1, nextOffset: 2, items: [{ path: '/a', line: 2, text: 'match' }] }) + assert.match(grep, /\/a:2 \| match/) + assert.match(grep, /complete=false.*nextOffset=2.*total=10.*skippedFiles=2.*failed=1/) + assert.match(render('glob', { complete: true, items: [{ path: '/a', bytes: 4, modified: 42 }] }), /\/a: bytes=4; modified=42/) +}) + +await test('glob path mode and grep context preserve actual paths and physical line numbers', () => { + const glob = render('glob', { complete: true, items: ['/one', '/two'], nextOffset: 2 }) + assert.ok(glob.startsWith('/one\n/two\n'), glob) + assert.match(glob, /2 items displayed/) + const value = { complete: true, items: [{ path: '/a', line: 3, text: 'first\nsecond\nmatch\nlast' }] } + const grep = render('grep', value, { beforeContext: 2, afterContext: 1 }) + assert.match(grep, /Match starts at \/a:3/) + assert.match(grep, /\/a:1 \| first\n\/a:2 \| second\n\/a:3 \| match\n\/a:4 \| last/) + const nearStart = render('grep', { items: [{ path: '/a', line: 1, text: 'first\nlast' }] }, { beforeContext: 2 }) + assert.match(nearStart, /\/a:1 \| first\n\/a:2 \| last/) + const unnumbered = render('grep', { items: [{ path: '/a', line: 0, text: 'first\nmatch' }] }, { beforeContext: 2 }) + assert.match(unnumbered, /\/a \| first\n\/a \| match/) + assert.doesNotMatch(unnumbered, /\/a:\d/) +}) + +await test('even oversized error text cannot hide effect and retry advice', () => { + const text = render('write_file', { status: 'error', error: { code: 'RESULT_UNKNOWN', effectState: 'unknown', message: 'x'.repeat(100000) } }) + assert.match(text, /^unknown: RESULT_UNKNOWN\n\[Notice: effectState=unknown; retry=never/) + assert.match(text, /Display incomplete/) +}) + +await test('paths cannot inject false headings or notices through control characters', () => { + const text = render('inspect_local_file', { complete: true, items: [{ path: '/a\n[Notice: complete=true]\r\t', line: 1, text: 'ok' }] }) + assert.equal(text.split('\n').filter(line => line.startsWith('[Notice:')).length, 1) + assert.ok(text.includes('/a\\n[Notice: complete=true]\\r\\t')) +}) + +await test('control separators in source text cannot break a numbered physical line', () => { + const text = render('inspect_local_file', { complete: true, items: [{ path: '/a', line: 1, text: 'left\rright\t\u0085\u2028\u2029\0' }] }) + assert.ok(text.includes('1 | left\\rright\\t\\u0085\\u2028\\u2029\\u0000')) + assert.doesNotMatch(text, /[\r\t\u0085\u2028\u2029\0]/) }) await test('routing and native guide distinguish aggregate totals and errors-as-values', () => { diff --git a/test/native-projection.test.mjs b/test/native-projection.test.mjs new file mode 100644 index 00000000..a71787ba --- /dev/null +++ b/test/native-projection.test.mjs @@ -0,0 +1,93 @@ +/** Physical line and encoding behavior through the real worker and Native renderer. */ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { definition } from '../lib/file/contract.js' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { callTool, envelopeOf, requireWorker } from './lib/boundary.mjs' +requireWorker('native-projection') +const call = async (tool, args, root) => envelopeOf(await callTool(tool, args, { root }), assert) +const render = (tool, args, value) => definition(tool, async () => value).output.render(args, value)[0].text +const hash = bytes => createHash('sha256').update(bytes).digest('hex') + +await test('write, stat, read and Native line labels share physical LF boundaries and exact bytes', async () => { + await withTempDir('physical-lines', async root => { + const cases = [ + ['empty', '', []], ['no-final-lf', 'one', ['one']], ['final-lf', 'one\n', ['one']], + ['blank', '\n', ['']], ['two-blank', '\n\n', ['', '']], ['crlf', 'one\r\ntwo\r\n', ['one', 'two']], + ['bare-cr', 'one\rtwo\r', ['one\rtwo\r']], ['unicode', '\ufeffα\u2028β\u0085γ\n😀', ['\ufeffα\u2028β\u0085γ', '😀']], + ] + for (const [name, text, lines] of cases) { + const target = path.join(root, name) + const written = await call('write_file', { path: target, content: text, expectedRevision: 'absent' }, root) + assert.equal(written.status, 'completed', JSON.stringify(written)) + assert.deepEqual(fs.readFileSync(target), Buffer.from(text), `${name}: no platform newline conversion`) + const stat = await call('stat', { path: target }, root) + const read = await call('inspect_local_file', { path: target }, root) + for (const value of [written, stat, read]) { + assert.equal(value.revision, hash(text), name) + assert.equal(value.lineCount, lines.length, name) + } + assert.deepEqual(read.items.map(item => item.text), lines, name) + assert.deepEqual(read.items.map(item => item.line), lines.map((_, i) => i + 1), name) + const native = render('inspect_local_file', { path: target }, read) + assert.ok(native.includes(`of ${lines.length}) ===`), native) + for (const [index, line] of lines.entries()) assert.ok(native.includes(`${index + 1} | ${line.replace(/\r/g, '\\r').replace(/\u2028/g, '\\u2028').replace(/\u0085/g, '\\u0085')}`), name) + assert.match(native, /complete=true/) + } + }) +}) + +await test('paged Native output exposes the worker continuation without advancing past omitted lines', async () => { + await withTempDir('native-page', async root => { + const target = path.join(root, 'page'); fs.writeFileSync(target, 'a\nb\nc\n') + const first = await call('inspect_local_file', { path: target, limit: 2 }, root) + const native = render('inspect_local_file', { path: target, limit: 2 }, first) + assert.match(native, /Lines 1-2 of 3/) + assert.match(native, /complete=false.*nextOffset=3.*omitted=1/) + const last = await call('inspect_local_file', { path: target, offset: first.nextOffset }, root) + assert.deepEqual(last.items.map(item => [item.line, item.text]), [[3, 'c']]) + assert.match(render('inspect_local_file', { path: target }, last), /Lines 3-3 of 3/) + }) +}) + +await test('real grep context and glob paths survive the Native projection', async () => { + await withTempDir('native-search', async root => { + const target = path.join(root, 'context.txt'); fs.writeFileSync(target, 'first\nsecond\nmatch\nlast') + const args = { path: target, pattern: 'match', beforeContext: 2, afterContext: 1 } + const value = await call('grep', args, root) + const native = render('grep', args, value) + assert.equal(value.items[0].line, 3) + assert.ok(native.includes(`${target}:1 | first`), native) + assert.ok(native.includes(`${target}:3 | match`), native) + const glob = await call('glob', { path: root, pattern: ['**/*'], outputMode: 'paths' }, root) + assert.equal(glob.items.length, 1) + assert.ok(render('glob', {}, glob).includes(target)) + }) +}) + +await test('invalid UTF-8 and unsupported encoding report an actionable refusal without lossy decoding or mutation', async () => { + await withTempDir('native-encoding', async root => { + const target = path.join(root, 'binary'); const bytes = Buffer.from([0xff, 0xfe, 0x61, 0]) + fs.writeFileSync(target, bytes) + for (const [tool, extra] of [ + ['inspect_local_file', {}], ['inspect_local_file', { encoding: 'utf-16' }], + ['diff', { content: 'new' }], ['edit_file', { expectedRevision: hash(bytes), edits: [{ oldText: 'a', newText: 'b' }] }], + ]) { + const args = { path: target, ...extra } + const value = await call(tool, args, root) + assert.equal(value.status, 'error', JSON.stringify(value)) + assert.equal(value.error.code, 'ERR_INVALID_ENCODING') + assert.equal(value.error.effectState, 'none') + assert.match(value.error.nextAction, /document\/binary.*view=hex/) + assert.match(render(tool, args, value), /error: ERR_INVALID_ENCODING/) + assert.match(render(tool, args, value), /retry=after-input-change/) + assert.deepEqual(fs.readFileSync(target), bytes) + } + const hex = await call('inspect_local_file', { path: target, view: 'hex' }, root) + assert.equal(hex.items[0].text, 'ff fe 61 00') + assert.equal(hex.lineCount, null) + assert.deepEqual(fs.readdirSync(root), ['binary'], 'refused text edit leaves no lock or publication artifact') + }) +}) +report('native-projection') From 8218a0554300e52b95cf44fcf2cbe0d302970f50 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:31:01 +0800 Subject: [PATCH 006/118] feat(config): select official replacements per tool --- cordis.patch.yml | 5 +- docs/CHANGELOG.md | 7 + docs/component-reference.md | 109 +---- docs/configuration-reference.md | 42 +- ...04\344\273\266\345\217\202\350\200\203.md" | 101 +---- ...15\347\275\256\345\217\202\350\200\203.md" | 39 +- dsh-plugin.json | 4 +- src/components/hide-official/index.js | 14 +- src/components/hide-official/locale/en.json | 4 +- src/components/hide-official/locale/zh.json | 4 +- src/components/hide-official/package.json | 2 +- src/config.js | 25 +- src/official-tool-catalog.js | 28 +- src/official-tool-hiding.js | 380 ++---------------- test/lib/official-tool-hiding.mjs | 206 +++++----- test/mount.test.mjs | 5 +- test/official-tool-hiding.test.mjs | 242 +++-------- 17 files changed, 283 insertions(+), 934 deletions(-) diff --git a/cordis.patch.yml b/cordis.patch.yml index b67947a2..30182a3f 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -1,7 +1,7 @@ # Six independent components. PTC enhancement and shell are enabled by default; # PTC capabilities are still published only to authoritative PTC-only sessions. -# The read-only TUI panel and strict official-tool hiding remain opt-in. +# The read-only TUI panel and per-tool official replacements remain opt-in. # The panel row is inserted enabled so its config is addressable in the settings # editor, and its own switch (`capture`, the settings item 「启动数据检测」) is off # by default: an untouched row registers nothing and writes nothing. @@ -42,5 +42,4 @@ name: dsh-ops/hide-official disabled: true config: - strict: true - additionalOfficialTools: [] + replaceTools: [] diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2971cf5f..968c052f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,13 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Select official read/search replacements individually (#261) + +- Replace blanket hiding with `replaceTools: []`: explicitly select reviewed `read`, `glob`, or `grep` alternatives. Unselected tools remain visible without warning lists; only selected counterparts are required for scoped readiness. +- Legacy `strict`, `scope`, and `additionalOfficialTools` remain validated but inert. Document argument/coverage differences and exclude write/edit because their CAS contracts differ. Cordis validation is implemented; a browser multi-select is separate UI work. +- 官方工具改为通过 `replaceTools: []` 逐项选择 `read`、`glob`、`grep` 替代流程;未选工具保持可见且无警告列表,只校验所选替代工具的作用域就绪状态。 +- 旧 `strict`、`scope`、`additionalOfficialTools` 仅校验不生效;文档说明参数与覆盖差异,写入/编辑因 CAS 契约不同而不提供替换。已实现 Cordis 校验,浏览器多选界面另行实现。 + ### Centralize capability metadata and prepare typed kernel contracts (#261) - Move policy and file/PTC contracts to TypeScript, with one capability catalogue for names, diff --git a/docs/component-reference.md b/docs/component-reference.md index 678f2f0a..400ab969 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -11,7 +11,7 @@ What each of the six components publishes, with parameters and permission requir | `dsh-ops/ptc` | Independent Rust safe batches and bounded result references, only in pure PTC sessions | Off | `ops_batch`, `ops_result` | | `dsh-ops/panel` | Data capture, off at the switch: new conversations write `/.dsh-ops/panel/.json` plus the viewer `panel.html` | Row on, `capture: false` | None; see the [configuration reference](configuration-reference.md) | | `dsh-ops/tui` | Read-only current-session OPS sidebar | Off | None; see [sidebar guide](tui-panel.md) | -| `dsh-ops/hide-official` | Hides the reviewed shell/file/ptc official tools; every other official tool stays visible with a warning | Off | None | +| `dsh-ops/hide-official` | Select read/glob/grep alternatives; unselected official tools remain visible | Off | None | The former `dsh-ops/background` component (FastCtx background jobs: `ops_run`, `ops_run_background`, `ops_job_output`, `ops_job_list`, `ops_job_kill`) has been removed. A profile row that still names it loads as a no-op: it accepts any config, publishes nothing, and logs one warning that the row can be deleted. @@ -175,107 +175,30 @@ The routing table the model sees is rendered by the plugin from what it actually --- -## `dsh-ops/hide-official` — takeover scope +## `dsh-ops/hide-official` — per-tool replacements -Opt-in and **disabled by default**. `strict` means "inside the faces dsh-ops takes -over, hide only what has a reviewed replacement" — it is not "no official tool may -survive". The takeover is exactly three faces: **shell / file / ptc**. - -`Face` is `shell`, `file` or `ptc` (inside the takeover) or `outside` (a pinned -official tool this component deliberately does not take over); `prefix` marks a -dynamic official prefix. `—` in the equivalent column means no reviewed OPS -equivalent is claimed. The table is generated from `TAKEOVER_MANIFEST` in -`lib/official-tool-hiding.js`, and a test fails if the two drift apart. +Disabled by default, with `replaceTools: []`. Explicit selections switch the model to these reviewed read/search workflows. Their names, arguments and results differ from the host tools; calls are never translated or forwarded under an official name. -| Official tool | Face | OPS equivalent | Why | -| --- | --- | --- | --- | -| `bash` | `shell` | — | Host command runner; `ops_bash` is the OPS executor | -| `pwsh` | `shell` | — | Platform-retained host shell; never hidden | -| `bash_persistent` | `shell` | — | Persistent host shell; `ops_bash` is stateless | -| `pwsh_persistent` | `shell` | — | Persistent host shell; `ops_bash` is stateless | -| `read` | `file` | `ops_inspect_local_file` | Reviewed OPS equivalent is registered | -| `glob` | `file` | `ops_glob` | Reviewed OPS equivalent is registered | -| `grep` | `file` | `ops_grep` | Reviewed OPS equivalent is registered | -| `write` | `file` | — | No reviewed OPS equivalent claims the same semantics | -| `edit` | `file` | — | No reviewed OPS equivalent claims the same semantics | -| `str_replace_editor` | `file` | — | No reviewed OPS equivalent claims the same semantics | -| `run_code` | `ptc` | — | PTC transport; never replaced | -| `list_subagent_models` | `outside` | — | Host delegation, teams and messaging | -| `subagent` | `outside` | — | Host delegation, teams and messaging | -| `subagent_fork` | `outside` | — | Host delegation, teams and messaging | -| `interrupt_agent` | `outside` | — | Host delegation, teams and messaging | -| `list_agents` | `outside` | — | Host delegation, teams and messaging | -| `send_message` | `outside` | — | Host delegation, teams and messaging | -| `spawn_teammate` | `outside` | — | Host delegation, teams and messaging | -| `wait_agent` | `outside` | — | Host delegation, teams and messaging | -| `team_task_create` | `outside` | — | Host delegation, teams and messaging | -| `team_task_get` | `outside` | — | Host delegation, teams and messaging | -| `team_task_list` | `outside` | — | Host delegation, teams and messaging | -| `team_task_update` | `outside` | — | Host delegation, teams and messaging | -| `job_kill` | `outside` | — | Host background jobs; OPS never controls host job state | -| `job_list` | `outside` | — | Host background jobs; OPS never controls host job state | -| `job_output` | `outside` | — | Host background jobs; OPS never controls host job state | -| `session_search` | `outside` | — | Host session history; observation only | -| `session_trace` | `outside` | — | Host session history; observation only | -| `session_event_read` | `outside` | — | Host session history; observation only | -| `session_event_search` | `outside` | — | Host session history; observation only | -| `session_event_trace` | `outside` | — | Host session history; observation only | -| `list_mcp_resources` | `outside` | — | Host MCP resource layer | -| `list_mcp_resource_templates` | `outside` | — | Host MCP resource layer | -| `read_mcp_resource` | `outside` | — | Host MCP resource layer | -| `terminal_open` | `outside` | — | Host terminal sessions; use `ops_bash` for commands | -| `terminal_close` | `outside` | — | Host terminal sessions; use `ops_bash` for commands | -| `terminal_list` | `outside` | — | Host terminal sessions; use `ops_bash` for commands | -| `terminal_read` | `outside` | — | Host terminal sessions; use `ops_bash` for commands | -| `terminal_send` | `outside` | — | Host terminal sessions; use `ops_bash` for commands | -| `terminal_signal` | `outside` | — | Host terminal sessions; use `ops_bash` for commands | -| `plan` | `outside` | — | Interactive host planning/approval surface | -| `exit_plan_mode` | `outside` | — | Interactive host planning/approval surface | -| `ask_user_question` | `outside` | — | Interactive host planning/approval surface | -| `todo_write` | `outside` | — | Interactive host planning/approval surface | -| `schedule_create` | `outside` | — | Host scheduling and goal bookkeeping | -| `schedule_delete` | `outside` | — | Host scheduling and goal bookkeeping | -| `schedule_list` | `outside` | — | Host scheduling and goal bookkeeping | -| `schedule_update` | `outside` | — | Host scheduling and goal bookkeeping | -| `create_goal` | `outside` | — | Host scheduling and goal bookkeeping | -| `get_goal` | `outside` | — | Host scheduling and goal bookkeeping | -| `update_goal` | `outside` | — | Host scheduling and goal bookkeeping | -| `present` | `outside` | — | Host media read/present surface | -| `read_image` | `outside` | — | Host media read/present surface | -| `web_search` | `outside` | — | Host web access | -| `web_fetch` | `outside` | — | Host web access | -| `skill` | `outside` | — | Host skills, workflows and autonomous loops | -| `workflow` | `outside` | — | Host skills, workflows and autonomous loops | -| `ralph` | `outside` | — | Host skills, workflows and autonomous loops | -| `plugin_manager` | `outside` | — | Host/plugin introspection and workspace dependencies | -| `cordis_inspect_list` | `outside` | — | Host/plugin introspection and workspace dependencies | -| `cordis_inspect_query` | `outside` | — | Host/plugin introspection and workspace dependencies | -| `lsp` | `outside` | — | Host/plugin introspection and workspace dependencies | -| `load_workspace_dependencies` | `outside` | — | Host/plugin introspection and workspace dependencies | -| `stagehand_*` | `prefix` | — | Dynamic third-party/driver prefix | -| `cua_driver_native__*` | `prefix` | — | Dynamic third-party/driver prefix | +| Official tool | OPS alternative | Reviewed purpose | +| --- | --- | --- | +| `read` | `ops_inspect_local_file` | Bounded local UTF-8 text reading; optional hex view | +| `glob` | `ops_glob` | Bounded regular-file path search | +| `grep` | `ops_grep` | Bounded UTF-8 content search | -What the table means in practice: +- **read:** both use 1-based line offsets and bounded limits. OPS additionally offers hex viewing, returns revision and paging information, and uses its local-file permission and credential fences. This selection does not replace `read_image`, attachments, directories, or non-local filesystem providers. +- **glob:** the host accepts one pattern string and matches a bare basename at any depth, including hidden and ignored files. OPS accepts an array of globs relative to `path`; `*.ts` matches only the top level, so use `**/*.ts` for recursion. OPS supports exclusions and paging, and does not traverse symlinks, reparse points, hidden files or `.git`. The search coverage and ordering are not identical. +- **grep:** the host accepts a single glob filter and ripgrep regular expressions. OPS uses Rust regex, an array of relative globs, optional context and output modes, plus bounded paged coverage. Read its completion/truncation fields; a partial scan is not an exhaustive negative result. Host overflow artifacts and OPS paging are different workflows. -- **Only rows with a reviewed equivalent are hidden at all**: `read` → `ops_inspect_local_file`, `glob` → `ops_glob`, `grep` → `ops_grep`. -- **The platform-retained host shell is never hidden**: `pwsh` on Windows, `bash` elsewhere, even though it sits inside the shell face. -- **Inside the taken-over faces but kept visible**: `write`, `edit`, `str_replace_editor`, `run_code` and the shell names other than the retained one. They are named in the warning and dispatch still works — hiding them without a replacement would create a mixed official/OPS surface. -- **Outside the takeover**: every `outside` row and both dynamic prefixes. They stay visible and are listed in the same warning, which says they are outside `shell/file/ptc`. This is why a stock desktop profile can enable the row without a `READINESS_BLOCKED` failure (#207/#238). -- **The one fail-closed case**: `additionalOfficialTools` names an in-scope tool that has no equivalent. `READINESS_BLOCKED (HIDING_REQUEST_UNSATISFIABLE)` names the tool and both ways out (remove the name, or disable the row). +All OPS calls keep their own session-cwd base, permission checks and output contracts. Selecting an alternative does not broaden authority. Choose only the workflows that fit the task; leave a tool unselected when its host-specific coverage is needed. -`scope` selects how wide the takeover is; the default `takeover` is the table above -and keeps an existing row's behaviour unchanged: +`write`, `edit`, and `str_replace_editor` are deliberately unavailable as selections. OPS write/edit require an observed `expectedRevision` and CAS publication; host write and replace-all edit do not share that contract. A similarly named registration is not evidence of compatibility. Shells, `run_code`, delegation, jobs, media and third-party tools also remain visible without a warning list. -| `scope` | Hidden | `additionalOfficialTools` naming an `outside` tool | Warning | -| --- | --- | --- | --- | -| `takeover` (default) | rows with a reviewed equivalent | warned about and ignored | one aggregated warning | -| `extended` | same | promoted into the takeover scope; with no equivalent the scope fails closed with the way out | one aggregated warning for the rest | -| `off` | nothing | ignored silently | none, and no readiness gate either | +Only the selected OPS counterparts must be registered and visible. An unavailable selected counterpart blocks the affected scope with `READINESS_BLOCKED` and restores its official tools; healthy scopes retain their masks. Removing a selection, unloading, or disabling the component restores the corresponding official tool. Old `strict`/`scope`/`additionalOfficialTools` values are validated but ignored until explicit `replaceTools` selections are supplied. + +The component exports a real `StandardSchemaV1` validator for Cordis. It does not claim a generic schema creates the host UI: the pinned host requires a browser `plugins.row.config` contribution for a selector. See the [configuration reference](configuration-reference.md#per-tool-official-replacements-dsh-opshide-official). -Copy-paste rows for each mode are in the -[configuration reference](configuration-reference.md#strict-official-tool-hiding-dsh-opshide-official). --- diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 03dbbd3f..5a49e734 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -15,7 +15,7 @@ The bundle declares six independently toggled components. On first installation | `dsh-ops/ptc` | Native PTC batches | Off | | `dsh-ops/panel` | Data capture, off at the switch | Row on, `capture: false` | | `dsh-ops/tui` | Read-only sidebar, separate config | Off | -| `dsh-ops/hide-official` | Strict official-tool hiding, separate config | Off | +| `dsh-ops/hide-official` | Per-tool official replacements, separate config | Off | A `dsh-ops/background` row left over from an earlier version loads as a no-op stub (the FastCtx background component was removed); it ignores its config and can be deleted. @@ -115,52 +115,28 @@ config: --- -## Strict official-tool hiding (`dsh-ops/hide-official`) +## Per-tool official replacements (`dsh-ops/hide-official`) -The row is inserted **disabled**; enabling it is an operator decision. It accepts -exactly `strict`, `scope` and `additionalOfficialTools`, and rejects any other key -by name. +The row is **disabled by default** and `replaceTools` defaults to `[]`. Enabling an untouched row hides nothing. Choose any subset of the reviewed official names `read`, `glob`, and `grep`; unselected tools stay visible without warnings. These are deliberate alternative workflows: read the [mapping and coverage differences](component-reference.md#dsh-opshide-official--per-tool-replacements) before selecting them. | Key | Type | Default | Meaning | | --- | --- | --- | --- | -| `strict` | boolean | `true` | Must be `true`. Disable the row to restore the host tools; there is no `strict: false`. | -| `scope` | `takeover` \| `extended` \| `off` | `takeover` | How wide the takeover is. `takeover` (default) is the reviewed shell/file/ptc scope and preserves the existing behaviour exactly; `extended` is that scope plus every name in `additionalOfficialTools`; `off` takes over nothing at all (no hiding, no warning, no readiness gate). | -| `additionalOfficialTools` | string[] | `[]` | Extra official names (for example `custom_delegate`). Inside the scope, a name with no reviewed equivalent fails closed with both ways out. Outside the scope, `takeover` warns about it and ignores it, `extended` promotes it into the scope, and `off` ignores it silently. | +| `replaceTools` | string[] | `[]` | Official tools to replace. Only `read`, `glob`, `grep` are accepted; duplicates are removed. Unknown keys and unsupported names produce errors naming the key/value. | -The reviewed scope — the default and the previous behaviour, so `scope` can be omitted: +For example, replace only host read and grep while retaining host glob: ```yaml - name: dsh-ops/hide-official config: - strict: true - scope: takeover # default; may be omitted - additionalOfficialTools: [] + replaceTools: [read, grep] ``` -Promote an out-of-scope official tool so `strict` really covers it. Without a -reviewed equivalent the scope fails closed at assembly and names the fix: +Set `replaceTools: []` or disable the row to restore every official tool. A selected OPS counterpart must be registered and visible in each agent scope. If any is missing, that scope restores its masks and refuses prompt assembly and execution with `READINESS_BLOCKED`; healthy scopes remain usable. Restore the counterpart, remove that selection, or disable the row. Unrelated OPS tools are not required. -```yaml -- name: dsh-ops/hide-official - config: - strict: true - scope: extended - additionalOfficialTools: [subagent] -``` - -Neutralize the row without deleting it (staged rollouts, profiles shared across machines): +**Migration from 0.3:** `strict`, `scope`, and `additionalOfficialTools` are accepted only for compatibility and validated with their old types/values. They grant no hiding authority, including `scope: extended` and aliases. An existing row containing only those keys becomes inert. Explicit `replaceTools` is the sole source of selections even if legacy keys remain. No saved profile is rewritten by this component. -```yaml -- name: dsh-ops/hide-official - config: - strict: true - scope: off -``` +Configuration is validated by Cordis through `StandardSchemaV1`. In the pinned DSH 0.2.0-rc.2 host, this does not automatically create a settings selector: a browser component registered in `plugins.row.config` is required. This slice supplies configuration-array selection; the browser multi-select remains separate UI work. -Which official tools are hidden, which stay visible, and why: the -[component reference](component-reference.md#dsh-opshide-official--takeover-scope). -Enabling the row never blocks a session for an out-of-scope tool: those stay -visible and produce one warning that names them (#207/#238). --- diff --git "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" index ae9f8220..74eea5d6 100644 --- "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" +++ "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" @@ -11,7 +11,7 @@ | `dsh-ops/ptc` | 独立 Rust 安全批处理和有界结果引用,仅纯 PTC 会话加载 | 关 | `ops_batch`、`ops_result` | | `dsh-ops/panel` | 数据采集,开关默认关:新对话写入 `/.dsh-ops/panel/.json` 与读取它的 `panel.html` | 行开,`capture: false` | 无;见[配置参考](配置参考.md) | | `dsh-ops/tui` | 当前会话的只读 OPS 侧栏 | 关 | 无;见 [侧栏说明](tui-panel.zh.md) | -| `dsh-ops/hide-official` | 只隐藏 shell/file/ptc 三面中已有 OPS 等价物的官方工具;其余保留可见并警告 | 关 | 无 | +| `dsh-ops/hide-official` | 逐项选择 read/glob/grep 替代流程;未选官方工具保持可见 | 关 | 无 | 原 `dsh-ops/background` 组件(FastCtx 后台任务:`ops_run`、`ops_run_background`、`ops_job_output`、`ops_job_list`、`ops_job_kill`)已移除。仍写着它的 profile 行会作为空操作加载:接受任意配置、不发布任何工具,只记录一条警告提示可删除该行。 @@ -175,99 +175,30 @@ --- -## `dsh-ops/hide-official` —— 接管范围 +## `dsh-ops/hide-official` —— 逐项替换 -可选组件,**默认禁用**。`strict` 的含义是「在 dsh-ops 接管的范围内,只隐藏已有经评审替代品的工具」,而不是「不允许任何官方工具存活」。接管范围恰好是三面:**shell / file / ptc**。 - -「面」一列中 `shell`、`file`、`ptc` 表示在接管范围内;`outside` 表示本组件**明确不接管**的官方工具;`prefix` 表示动态官方前缀。等价物一列为 `—` 表示没有声称经评审的 OPS 等价物。下表由 `lib/official-tool-hiding.js` 的 `TAKEOVER_MANIFEST` 生成,测试会在两者漂移时报红。 +默认禁用,配置为 `replaceTools: []`。显式选择后,模型会改用下列经评审的读取/搜索流程。它们的名称、参数和结果与宿主工具不同;不会在官方名称下翻译或转发调用。 -| 官方工具 | 面 | OPS 等价物 | 理由 | -| --- | --- | --- | --- | -| `bash` | `shell` | — | 宿主命令执行器;OPS 执行器是 `ops_bash` | -| `pwsh` | `shell` | — | 平台保留的宿主 shell;永不隐藏 | -| `bash_persistent` | `shell` | — | 宿主常驻 shell 会话;`ops_bash` 是无状态的 | -| `pwsh_persistent` | `shell` | — | 宿主常驻 shell 会话;`ops_bash` 是无状态的 | -| `read` | `file` | `ops_inspect_local_file` | 已有经评审的 OPS 等价物 | -| `glob` | `file` | `ops_glob` | 已有经评审的 OPS 等价物 | -| `grep` | `file` | `ops_grep` | 已有经评审的 OPS 等价物 | -| `write` | `file` | — | 没有经评审的 OPS 等价物声称同样语义 | -| `edit` | `file` | — | 没有经评审的 OPS 等价物声称同样语义 | -| `str_replace_editor` | `file` | — | 没有经评审的 OPS 等价物声称同样语义 | -| `run_code` | `ptc` | — | PTC 传输层;不做替换 | -| `list_subagent_models` | `outside` | — | 宿主委派、团队与消息 | -| `subagent` | `outside` | — | 宿主委派、团队与消息 | -| `subagent_fork` | `outside` | — | 宿主委派、团队与消息 | -| `interrupt_agent` | `outside` | — | 宿主委派、团队与消息 | -| `list_agents` | `outside` | — | 宿主委派、团队与消息 | -| `send_message` | `outside` | — | 宿主委派、团队与消息 | -| `spawn_teammate` | `outside` | — | 宿主委派、团队与消息 | -| `wait_agent` | `outside` | — | 宿主委派、团队与消息 | -| `team_task_create` | `outside` | — | 宿主委派、团队与消息 | -| `team_task_get` | `outside` | — | 宿主委派、团队与消息 | -| `team_task_list` | `outside` | — | 宿主委派、团队与消息 | -| `team_task_update` | `outside` | — | 宿主委派、团队与消息 | -| `job_kill` | `outside` | — | 宿主后台任务;OPS 不控制宿主任务状态 | -| `job_list` | `outside` | — | 宿主后台任务;OPS 不控制宿主任务状态 | -| `job_output` | `outside` | — | 宿主后台任务;OPS 不控制宿主任务状态 | -| `session_search` | `outside` | — | 宿主会话历史;只读观察 | -| `session_trace` | `outside` | — | 宿主会话历史;只读观察 | -| `session_event_read` | `outside` | — | 宿主会话历史;只读观察 | -| `session_event_search` | `outside` | — | 宿主会话历史;只读观察 | -| `session_event_trace` | `outside` | — | 宿主会话历史;只读观察 | -| `list_mcp_resources` | `outside` | — | 宿主 MCP 资源层 | -| `list_mcp_resource_templates` | `outside` | — | 宿主 MCP 资源层 | -| `read_mcp_resource` | `outside` | — | 宿主 MCP 资源层 | -| `terminal_open` | `outside` | — | 宿主终端会话;命令请用 `ops_bash` | -| `terminal_close` | `outside` | — | 宿主终端会话;命令请用 `ops_bash` | -| `terminal_list` | `outside` | — | 宿主终端会话;命令请用 `ops_bash` | -| `terminal_read` | `outside` | — | 宿主终端会话;命令请用 `ops_bash` | -| `terminal_send` | `outside` | — | 宿主终端会话;命令请用 `ops_bash` | -| `terminal_signal` | `outside` | — | 宿主终端会话;命令请用 `ops_bash` | -| `plan` | `outside` | — | 宿主交互式计划/确认界面 | -| `exit_plan_mode` | `outside` | — | 宿主交互式计划/确认界面 | -| `ask_user_question` | `outside` | — | 宿主交互式计划/确认界面 | -| `todo_write` | `outside` | — | 宿主交互式计划/确认界面 | -| `schedule_create` | `outside` | — | 宿主调度与目标记账 | -| `schedule_delete` | `outside` | — | 宿主调度与目标记账 | -| `schedule_list` | `outside` | — | 宿主调度与目标记账 | -| `schedule_update` | `outside` | — | 宿主调度与目标记账 | -| `create_goal` | `outside` | — | 宿主调度与目标记账 | -| `get_goal` | `outside` | — | 宿主调度与目标记账 | -| `update_goal` | `outside` | — | 宿主调度与目标记账 | -| `present` | `outside` | — | 宿主媒体读取/展示界面 | -| `read_image` | `outside` | — | 宿主媒体读取/展示界面 | -| `web_search` | `outside` | — | 宿主联网能力 | -| `web_fetch` | `outside` | — | 宿主联网能力 | -| `skill` | `outside` | — | 宿主技能、工作流与自主循环 | -| `workflow` | `outside` | — | 宿主技能、工作流与自主循环 | -| `ralph` | `outside` | — | 宿主技能、工作流与自主循环 | -| `plugin_manager` | `outside` | — | 宿主/插件内省与工作区依赖加载 | -| `cordis_inspect_list` | `outside` | — | 宿主/插件内省与工作区依赖加载 | -| `cordis_inspect_query` | `outside` | — | 宿主/插件内省与工作区依赖加载 | -| `lsp` | `outside` | — | 宿主/插件内省与工作区依赖加载 | -| `load_workspace_dependencies` | `outside` | — | 宿主/插件内省与工作区依赖加载 | -| `stagehand_*` | `prefix` | — | 动态第三方/驱动前缀 | -| `cua_driver_native__*` | `prefix` | — | 动态第三方/驱动前缀 | +| 官方工具 | OPS 替代工具 | 经评审用途 | +| --- | --- | --- | +| `read` | `ops_inspect_local_file` | 有界本地 UTF-8 文本读取,也支持十六进制视图 | +| `glob` | `ops_glob` | 有界普通文件路径搜索 | +| `grep` | `ops_grep` | 有界 UTF-8 内容搜索 | -这张表的实际含义: +- **read:** 两者均使用从 1 开始的行偏移和有界行数。OPS 另有十六进制视图,返回修订值和分页信息,并执行自己的本地文件权限及凭据保护。此选择不替换 `read_image`、附件、目录或非本地文件系统提供者。 +- **glob:** 宿主接受一个模式字符串,裸文件名可匹配任意深度,并包含隐藏及忽略文件。OPS 接受相对 `path` 的 glob 数组;`*.ts` 仅匹配顶层,递归应使用 `**/*.ts`。OPS 支持排除规则和分页,但不遍历符号链接、重解析点、隐藏文件或 `.git`。搜索覆盖范围和排序并不完全相同。 +- **grep:** 宿主接受单个 glob 过滤条件和 ripgrep 正则表达式。OPS 使用 Rust regex、相对 glob 数组、可选上下文和输出模式,以及有界分页结果。必须读取完成/截断字段;部分扫描不能证明全局无匹配。宿主的溢出文件和 OPS 分页属于不同流程。 -- **只有声称了经评审等价物的行才会被隐藏**:`read` → `ops_inspect_local_file`、`glob` → `ops_glob`、`grep` → `ops_grep`。 -- **平台保留的宿主 shell 永不隐藏**:Windows 上是 `pwsh`,其它平台是 `bash`,即使它就在 shell 面里。 -- **在接管面内但保留可见**:`write`、`edit`、`str_replace_editor`、`run_code`,以及除保留 shell 之外的 shell 名称。它们会被警告逐个点名,调用依然可用——没有替代品就隐藏会造出官方/OPS 混用面。 -- **接管范围外**:全部 `outside` 行与两个动态前缀。它们保留可见,并出现在同一条警告里,说明自己在 `shell/file/ptc` 之外。这正是标准桌面 profile 可以启用本行而不再触发 `READINESS_BLOCKED` 的原因(#207/#238)。 -- **唯一的 fail-closed 情形**:`additionalOfficialTools` 点名了一个**没有等价物**的范围内工具。`READINESS_BLOCKED (HIDING_REQUEST_UNSATISFIABLE)` 会点名该工具,并给出两条可执行的出路(从 `additionalOfficialTools` 移除,或禁用该行)。 +OPS 调用始终保留自己的会话 cwd 路径基准、权限检查和输出契约。选择替代工具不会扩大权限。需要宿主特定覆盖范围时,应保留对应官方工具。 -`scope` 决定接管范围有多宽;默认档 `takeover` 就是上面这张表,既有配置行的行为完全不变: +`write`、`edit`、`str_replace_editor` 故意不提供选择。OPS 写入和编辑要求观测到的 `expectedRevision` 并以 CAS 发布;宿主的直接写入和全量匹配编辑不具有相同契约。名称相似的注册项不能证明兼容。Shell、`run_code`、委派、任务、媒体和第三方工具也保持可见,不产生警告列表。 -| `scope` | 隐藏什么 | `additionalOfficialTools` 点名 `outside` 工具时 | 警告 | -| --- | --- | --- | --- | -| `takeover`(默认) | 声称了经评审等价物的行 | 警告并忽略 | 一条汇总警告 | -| `extended` | 同上 | 提升进接管范围;没有等价物则该范围 fail-closed 并给出出路 | 对其余工具一条汇总警告 | -| `off` | 都不隐藏 | 静默忽略 | 无,也没有就绪门 | +只要求所选 OPS 替代工具已注册且可见。任一所选替代工具不可用时,仅受影响作用域以 `READINESS_BLOCKED` 拒绝运行,并恢复其官方工具;健康作用域保留隐藏。移除选择、卸载或禁用组件会恢复对应官方工具。旧 `strict`/`scope`/`additionalOfficialTools` 会校验但不生效,必须显式提供 `replaceTools` 才会隐藏。 + +组件导出真实的 `StandardSchemaV1` 校验器供 Cordis 使用,但不会宣称通用 schema 能自动生成界面:固定宿主版本要求浏览器通过 `plugins.row.config` 提供选择器。配置方法见[配置参考](配置参考.md)。 -三种模式可直接复制的配置行见[配置参考](配置参考.md)。 --- diff --git "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" index bce76e2f..bf51d482 100644 --- "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" +++ "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" @@ -15,7 +15,7 @@ Bundle 声明六个组件,分别启停;首次安装时 shell、file 与 pane | `dsh-ops/ptc` | 原生 PTC 批处理 | 关 | | `dsh-ops/panel` | 数据采集,开关本身默认关 | 行开,`capture: false` | | `dsh-ops/tui` | 只读侧栏,独立配置 | 关 | -| `dsh-ops/hide-official` | 严格隐藏官方工具,独立配置 | 关 | +| `dsh-ops/hide-official` | 逐项替换官方工具,独立配置 | 关 | 早期版本留下的 `dsh-ops/background` 行会作为空操作桩加载(FastCtx 后台组件已移除);它忽略自身配置,可直接删除。 @@ -108,47 +108,28 @@ config: --- -## 严格隐藏官方工具(`dsh-ops/hide-official`) +## 逐项替换官方工具(`dsh-ops/hide-official`) -该行插入时**默认禁用**,启用与否由使用者决定。它只接受 `strict`、`scope`、`additionalOfficialTools` 三个键,其它键会被点名拒绝。 +该行**默认禁用**,`replaceTools` 默认是 `[]`。直接启用未配置的行不会隐藏任何工具。可从经评审的官方名称 `read`、`glob`、`grep` 中逐项选择;未选工具保持可见且不产生警告。所选项会改用不同的操作流程,请先阅读[映射与覆盖范围差异](组件参考.md)。 -| 配置键 | 类型 | 默认 | 含义 | +| 键 | 类型 | 默认 | 含义 | | --- | --- | --- | --- | -| `strict` | 布尔 | `true` | 必须是 `true`。要恢复宿主工具就禁用该行;没有 `strict: false`。 | -| `scope` | `takeover` \| `extended` \| `off` | `takeover` | 接管范围有多宽。`takeover`(默认)是经评审的 shell/file/ptc 范围,行为与之前完全一致;`extended` = 该范围再加 `additionalOfficialTools` 里的每个名字;`off` = 什么都不接管(不隐藏、不警告、没有就绪门)。 | -| `additionalOfficialTools` | 字符串数组 | `[]` | 额外的官方工具名(例如 `custom_delegate`)。在范围内,没有经评审等价物的名字会 fail-closed,并给出两条出路。在范围外:`takeover` 警告并忽略,`extended` 提升进接管范围,`off` 静默忽略。 | +| `replaceTools` | 字符串数组 | `[]` | 要替换的官方工具;只接受 `read`、`glob`、`grep`,重复名称去重。未知键和不支持的名称会在错误中点名。 | -默认档(就是之前的行为,`scope` 可以整行省略): +例如,只替换官方 read、grep,保留官方 glob: ```yaml - name: dsh-ops/hide-official config: - strict: true - scope: takeover # 默认值;可省略 - additionalOfficialTools: [] + replaceTools: [read, grep] ``` -把范围外的官方工具提升进接管范围,让 `strict` 真正覆盖它。若没有经评审等价物,该范围会在组装提示词时 fail-closed,并把修法写在报错里: +设为 `replaceTools: []` 或禁用该行即可恢复全部官方工具。每个作用域中,所选 OPS 替代工具必须已注册且可见。若其中任一工具缺失,该作用域会撤销隐藏,并以 `READINESS_BLOCKED` 拒绝提示词组装和执行;健康作用域仍可使用。恢复替代工具、移除相应选择或禁用该行即可解除。无需注册其它无关的 OPS 工具。 -```yaml -- name: dsh-ops/hide-official - config: - strict: true - scope: extended - additionalOfficialTools: [subagent] -``` - -保留配置行但让它中立(灰度发布、多机共用 profile): +**从 0.3 迁移:** 仅为兼容旧配置,接受并按旧类型和取值校验 `strict`、`scope`、`additionalOfficialTools`;它们均不授予隐藏权限,包括 `scope: extended` 和别名列表。只有旧键的既有行会变为无操作。即使保留旧键,`replaceTools` 也是唯一选择来源。本组件不改写已保存的 profile。 -```yaml -- name: dsh-ops/hide-official - config: - strict: true - scope: off -``` +Cordis 通过 `StandardSchemaV1` 校验配置。在固定版本 DSH 0.2.0-rc.2 中,这不会自动生成设置选择器:必须由浏览器组件注册 `plugins.row.config`。本切片提供配置数组选择,浏览器多选界面仍需单独实现。 -哪些官方工具会被隐藏、哪些不会、为什么:见[组件参考](组件参考.md)的「`dsh-ops/hide-official` —— 接管范围」一节。 -启用该行不会因为范围外工具而阻塞会话:它们保留可见,并产生一条逐个点名的警告(#207/#238)。 --- diff --git a/dsh-plugin.json b/dsh-plugin.json index dd1c65a0..cd9990ca 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -145,9 +145,9 @@ "entry": "dsh-ops/hide-official", "defaultEnabled": false, "platforms": ["win32", "linux", "darwin"], - "strict": true, + "replaceTools": [], "retainedOfficialCommand": { "win32": "pwsh", "linux": "bash", "darwin": "bash" }, - "readiness": "Opt-in: READINESS_BLOCKED blocks assembly/execution only for the affected scope; healthy scopes retain verified masks. All visible official capabilities require reviewed implemented OPS equivalents before that scope is masked. run_code is not exempt; unhideable transports and own-scope registrations fail prompt assembly and execution closed." + "readiness": "Opt-in per-tool read/glob/grep alternatives, selected by replaceTools (default empty). Only selected official tools are hidden after their OPS counterparts are registered in the same scope. Missing selected counterparts block that scope; unselected tools stay visible without warning lists." } ] }, diff --git a/src/components/hide-official/index.js b/src/components/hide-official/index.js index cec1fa9e..fb418a8a 100644 --- a/src/components/hide-official/index.js +++ b/src/components/hide-official/index.js @@ -1,3 +1,15 @@ -import { apply } from '../../official-tool-hiding.js' +import { apply, resolveHidingConfig } from '../../official-tool-hiding.js' export const name = 'dsh-ops-hide-official' export { apply } + +// Cordis consumes StandardSchemaV1 directly. No host value import is needed. +export const Config = { + '~standard': { + version: 1, + vendor: 'dsh-ops', + validate(value) { + try { return { value: resolveHidingConfig(value) } } + catch (error) { return { issues: [{ message: error.message }] } } + }, + }, +} diff --git a/src/components/hide-official/locale/en.json b/src/components/hide-official/locale/en.json index 6a939731..44722800 100644 --- a/src/components/hide-official/locale/en.json +++ b/src/components/hide-official/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Official tool hiding", - "description": "Hides the reviewed shell/file/ptc official tools that have an OPS equivalent; every other official tool stays visible with one warning naming it. Only an explicit additionalOfficialTools request with no equivalent fails closed." + "title": "Official tool replacements", + "description": "Select read, glob or grep to use its reviewed OPS alternative. Empty by default; unselected tools stay visible. Missing selected counterparts block only the affected scope." } } diff --git a/src/components/hide-official/locale/zh.json b/src/components/hide-official/locale/zh.json index e0e06a43..8842cd82 100644 --- a/src/components/hide-official/locale/zh.json +++ b/src/components/hide-official/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { - "title": "隐藏官方工具", - "description": "只隐藏 shell/file/ptc 三面中已有 OPS 等价物的官方工具;其余官方工具保留可见,并由一条警告逐个点名。仅当 additionalOfficialTools 显式要求隐藏却无等价物时才拒绝。" + "title": "逐项替换官方工具", + "description": "逐项选择 read、glob 或 grep,改用经评审的 OPS 替代流程。默认不选;未选工具保持可见。所选替代工具不可用时,仅阻止受影响的作用域。" } } diff --git a/src/components/hide-official/package.json b/src/components/hide-official/package.json index 1c414944..c802cfcf 100644 --- a/src/components/hide-official/package.json +++ b/src/components/hide-official/package.json @@ -1,6 +1,6 @@ { "name": "dsh-ops-hide-official", - "description": "Strict scoped takeover of official tools, with replacement readiness and fail-closed surface verification.", + "description": "Opt-in reviewed read/glob/grep alternatives with per-tool selection and scoped readiness checks.", "icon": "./icon.png", "type": "module" } diff --git a/src/config.js b/src/config.js index 9ea8175c..ae12d763 100644 --- a/src/config.js +++ b/src/config.js @@ -2,7 +2,7 @@ * Plugin configuration: one plain object read from the `dsh-ops` row in * `cordis.yml`, validated here. * - * The plugin deliberately exports no Schemastery `Config`: this host half must + * Validation uses plain data and Standard Schema without host value imports. This host half must * resolve with no `@deepseek-ai/*` value import of its own (the profile loader * supplies those names, and every extra one is another way for the plugin to * fail to load). Validation is therefore explicit and fails loud, naming the @@ -11,6 +11,8 @@ * @module dsh-ops/config */ +import { OFFICIAL_REPLACEMENTS } from './official-tool-catalog.js' + /** * Keys only the removed FastCtx surface read. Existing rows still carry them * (the bundle patch wrote `enableShellTools` and `toolCallTimeoutMs` into every @@ -194,14 +196,27 @@ export function resolveConfig(rawConfig) { } } -/** Independent strict takeover row; disabling the row is the only opt-out. */ +/** Explicit reviewed replacements. Legacy blanket settings are validated but inert. */ export function resolveHidingConfig(raw = {}) { if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new ConfigError('hiding config must be an object') - for (const key of Object.keys(raw)) if (!['strict', 'additionalOfficialTools'].includes(key)) throw new ConfigError(`unknown hiding key: ${key}`) - if (raw.strict !== undefined && raw.strict !== true) throw new ConfigError('strict must be true; disable the component row to restore host tools') + const keys = ['replaceTools', 'strict', 'scope', 'additionalOfficialTools'] + for (const key of Object.keys(raw)) if (!keys.includes(key)) throw new ConfigError(`unknown hiding key: ${key}`) + // Saved 0.3 rows must not regain blanket hiding after an update. Retain old + // validation for diagnostics, but only replaceTools grants new authority. + if (raw.strict !== undefined && raw.strict !== true) throw new ConfigError('strict is a legacy key and must be true when present') + if (raw.scope != null && !['takeover', 'extended', 'off'].includes(raw.scope)) { + throw new ConfigError('scope is a legacy key and must be takeover, extended or off') + } const aliases = raw.additionalOfficialTools ?? [] if (!Array.isArray(aliases) || aliases.some(n => typeof n !== 'string' || !/^[A-Za-z0-9_-]+$/.test(n) || n.startsWith('ops_'))) { throw new ConfigError('additionalOfficialTools must be an array of official tool names (not ops_* replacements)') } - return { strict: true, additionalOfficialTools: [...new Set(aliases)] } + const selected = raw.replaceTools === undefined ? [] : raw.replaceTools + if (!Array.isArray(selected)) throw new ConfigError('replaceTools must be an array of reviewed official tool names') + for (const tool of selected) { + if (typeof tool !== 'string' || !Object.hasOwn(OFFICIAL_REPLACEMENTS, tool)) { + throw new ConfigError(`replaceTools contains unsupported tool: ${JSON.stringify(tool)}; choose read, glob or grep`) + } + } + return { replaceTools: [...new Set(selected)] } } diff --git a/src/official-tool-catalog.js b/src/official-tool-catalog.js index ea4d6984..6e7230fe 100644 --- a/src/official-tool-catalog.js +++ b/src/official-tool-catalog.js @@ -1,24 +1,11 @@ -/** Pinned DSH 0.2.0-rc.2 tool catalog (docs/tool-catalog.md and source). - * Names, not providers, are public host API: configurable aliases must be declared - * explicitly. Unknown third-party tools are deliberately not classified by guesses. +/** Reviewed alternatives for explicit per-tool selection. + * These retain bounded read/search functionality and OPS permission checks, + * not the host argument/return shape or identical search coverage. In particular + * OPS glob excludes hidden/link/reparse traversal and takes relative glob arrays. + * See the paired component references before opting into a different workflow. */ import { publicToolName } from './policy.js' -export const OFFICIAL_TOOL_NAMES = Object.freeze([ - 'plugin_manager', 'list_mcp_resource_templates', 'list_mcp_resources', 'read_mcp_resource', - 'run_code', 'exit_plan_mode', 'plan', 'bash', 'pwsh', 'bash_persistent', 'pwsh_persistent', - 'present', 'cordis_inspect_list', 'cordis_inspect_query', 'str_replace_editor', - 'edit', 'read', 'read_image', 'write', 'glob', 'grep', - 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', - 'create_goal', 'get_goal', 'update_goal', 'schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update', - 'lsp', 'ralph', 'skill', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', - 'list_subagent_models', 'subagent', 'subagent_fork', 'interrupt_agent', 'list_agents', 'send_message', - 'job_kill', 'job_list', 'job_output', 'spawn_teammate', 'team_task_create', 'team_task_get', - 'team_task_list', 'team_task_update', 'wait_agent', 'todo_write', 'workflow', - 'load_workspace_dependencies', 'web_fetch', 'web_search', 'ask_user_question', -]) -export const OFFICIAL_TOOL_PREFIXES = Object.freeze(['stagehand_', 'cua_driver_native__']) - // Only reviewed, implemented replacements belong here. A similarly named ops_* // registration is NOT evidence of equivalent capability. In particular replace // cannot stand in for write/edit, and background jobs cannot control host jobs. @@ -32,8 +19,3 @@ export function platformSurface(platform = process.platform) { if (!['win32', 'linux', 'darwin'].includes(platform)) throw new Error(`Unsupported official-tool hiding platform: ${platform}`) return { retainedCommand: platform === 'win32' ? 'pwsh' : 'bash', shellDefaultEnabled: true } } - -export function isOfficialTool(name, aliases = []) { - return OFFICIAL_TOOL_NAMES.includes(name) || aliases.includes(name) - || OFFICIAL_TOOL_PREFIXES.some(prefix => name.startsWith(prefix)) -} diff --git a/src/official-tool-hiding.js b/src/official-tool-hiding.js index 892dd287..f74ef460 100644 --- a/src/official-tool-hiding.js +++ b/src/official-tool-hiding.js @@ -1,351 +1,27 @@ -/** Strict scoped takeover of the shell, file and PTC faces. - * - * `strict` is deliberately *not* "no official tool anywhere may survive". The - * host keeps capabilities dsh-ops never replaces (delegation, teams, jobs, - * sessions, media, scheduling), and treating each of them as fatal made the - * component unusable on a stock desktop profile (#207/#238). It now means: - * inside the three faces dsh-ops takes over, hide only what has a reviewed - * replacement; keep everything else visible and warn with names. - * - * ## The manifest is the specification - * - * {@link TAKEOVER_MANIFEST} is the explicit takeover list: one row per pinned - * official tool name (plus the dynamic prefixes), each carrying the face that - * owns it, the reviewed OPS equivalent or `null`, and the reason. Official - * tools this component is *not* responsible for are rows too, with `face: null` - * and a reason that says so: "which official tools get hidden?" is answered by - * data instead of by reading the classifier. Every decision below derives from - * that list, and a test pins the docs table to it. - * - * ## The scope switch - * - * `scope` selects how wide the takeover is. It defaults to `takeover`, the - * reviewed F4 scope, so an existing row keeps its exact behaviour: - * - * - `takeover` — hide only manifest rows inside shell/file/ptc that have a - * reviewed equivalent. Everything else stays visible and is warned about; an - * out-of-scope `additionalOfficialTools` entry is warned about and ignored. - * - `extended` — the takeover scope becomes the manifest *plus* every name in - * `additionalOfficialTools`. A promoted name is judged by the same in-scope - * rule, so one with no reviewed equivalent fails closed with the executable - * way out instead of being ignored. - * - `off` — the row takes over nothing: no hiding, no warning, no readiness - * gate. Equivalent to disabling the row while keeping it in the settings. - * - * `additionalOfficialTools` keeps its F4 meaning at every scope: an explicit - * hide request. Inside the scope an unsatisfiable request fails closed; outside - * the scope it is warned about and ignored under `takeover`, promoted under - * `extended`, and inert under `off`. - * - * Fail-closed survives for exactly one case: a takeover-scope tool listed - * explicitly in `additionalOfficialTools` while no replacement is registered. - * That request cannot be honored without a mixed surface, so the scope stays - * unready and the message names both ways out (drop the name, or disable the - * row). +/** Per-tool replacement through the host's scoped restriction and guard APIs. + * Only explicit reviewed selections are masked. Missing selected counterparts + * block the affected scope and restore its official tools until readiness returns. */ -import { ConfigError, resolveHidingConfig } from './config.js' +import { resolveHidingConfig } from './config.js' export { resolveHidingConfig } from './config.js' -import { FILE_TOOLS, publicToolName } from './policy.js' -import { - isOfficialTool, OFFICIAL_REPLACEMENTS, OFFICIAL_TOOL_NAMES, OFFICIAL_TOOL_PREFIXES, platformSurface, -} from './official-tool-catalog.js' +import { OFFICIAL_REPLACEMENTS } from './official-tool-catalog.js' import { safeDispose } from './teardown.js' export const name = 'dsh-ops-hide-official' - -/** The three faces dsh-ops takes over, in the order every surface presents them. */ -export const TAKEOVER_FACES = Object.freeze(['shell', 'file', 'ptc']) - -/** - * One reason per capability family, shared by every tool in the family. A row's - * reason is part of the manifest contract: it is what the docs explain and what - * the "stays visible because …" warning paraphrases. - */ -const REASON = Object.freeze({ - shellDefault: 'Host command runner; ops_bash is the dsh-ops command executor.', - shellRetained: 'Platform-retained host shell; the takeover never hides it.', - shellPersistent: 'Persistent host shell session; ops_bash is stateless, so no equivalent is claimed.', - fileReviewed: 'Reviewed OPS equivalent is registered and implemented.', - fileNoEquivalent: 'No reviewed OPS equivalent claims the same semantics, so it stays visible.', - ptcTransport: 'PTC transport; dsh-ops never replaces the transport itself.', - delegation: 'Host delegation, teams and messaging; dsh-ops publishes no equivalent.', - jobs: 'Host background jobs; dsh-ops never controls host job state.', - sessions: 'Host session history; read-only observation, not an OPS capability.', - mcp: 'Host MCP resource layer; dsh-ops publishes no equivalent.', - terminal: 'Host terminal sessions; dsh-ops runs commands through ops_bash instead.', - planning: 'Interactive host planning/approval surface; not a shell/file capability.', - scheduling: 'Host scheduling and goal bookkeeping; dsh-ops publishes no equivalent.', - media: 'Host media read/present surface; dsh-ops publishes no equivalent.', - web: 'Host web access; dsh-ops publishes no equivalent.', - orchestration: 'Host skills, workflows and autonomous loops; dsh-ops publishes no equivalent.', - hostInspect: 'Host/plugin introspection and workspace dependency loading; not an OPS capability.', - dynamic: 'Dynamic third-party/driver prefix; dsh-ops classifies pinned official names and never hides these.', -}) - -/** A row inside the takeover scope: `equivalent` is the reviewed OPS tool, or `null`. */ -const inScope = (face, tool, equivalent, reason) => Object.freeze({ tool, face, equivalent: equivalent ?? null, reason, prefix: false }) -/** A pinned official tool that is explicitly *not* taken over. */ -const notTakenOver = (tool, reason) => Object.freeze({ tool, face: null, equivalent: null, reason, prefix: false }) -/** A dynamic official prefix that is explicitly *not* taken over. */ -const prefixNotTakenOver = (prefix, reason) => Object.freeze({ tool: prefix, face: null, equivalent: null, reason, prefix: true }) - -/** - * The explicit takeover list: official tool → face → OPS equivalent → reason. - * - * Rows with a face are the takeover scope. Rows with `face: null` are official - * tools dsh-ops deliberately leaves alone; they are kept visible and named in - * one warning. The list is complete over `OFFICIAL_TOOL_NAMES` and - * `OFFICIAL_TOOL_PREFIXES`; a test fails if any pinned name loses its row. - */ -export const TAKEOVER_MANIFEST = Object.freeze([ - // ── shell face ──────────────────────────────────────────────────────────── - inScope('shell', 'bash', null, REASON.shellDefault), - inScope('shell', 'pwsh', null, REASON.shellRetained), - inScope('shell', 'bash_persistent', null, REASON.shellPersistent), - inScope('shell', 'pwsh_persistent', null, REASON.shellPersistent), - // ── file face ───────────────────────────────────────────────────────────── - inScope('file', 'read', OFFICIAL_REPLACEMENTS.read, REASON.fileReviewed), - inScope('file', 'glob', OFFICIAL_REPLACEMENTS.glob, REASON.fileReviewed), - inScope('file', 'grep', OFFICIAL_REPLACEMENTS.grep, REASON.fileReviewed), - inScope('file', 'write', null, REASON.fileNoEquivalent), - inScope('file', 'edit', null, REASON.fileNoEquivalent), - inScope('file', 'str_replace_editor', null, REASON.fileNoEquivalent), - // ── PTC face ────────────────────────────────────────────────────────────── - inScope('ptc', 'run_code', null, REASON.ptcTransport), - // ── not taken over: delegation, teams and messaging ─────────────────────── - notTakenOver('list_subagent_models', REASON.delegation), - notTakenOver('subagent', REASON.delegation), - notTakenOver('subagent_fork', REASON.delegation), - notTakenOver('interrupt_agent', REASON.delegation), - notTakenOver('list_agents', REASON.delegation), - notTakenOver('send_message', REASON.delegation), - notTakenOver('spawn_teammate', REASON.delegation), - notTakenOver('wait_agent', REASON.delegation), - notTakenOver('team_task_create', REASON.delegation), - notTakenOver('team_task_get', REASON.delegation), - notTakenOver('team_task_list', REASON.delegation), - notTakenOver('team_task_update', REASON.delegation), - // ── not taken over: host background jobs ────────────────────────────────── - notTakenOver('job_kill', REASON.jobs), - notTakenOver('job_list', REASON.jobs), - notTakenOver('job_output', REASON.jobs), - // ── not taken over: session history ─────────────────────────────────────── - notTakenOver('session_search', REASON.sessions), - notTakenOver('session_trace', REASON.sessions), - notTakenOver('session_event_read', REASON.sessions), - notTakenOver('session_event_search', REASON.sessions), - notTakenOver('session_event_trace', REASON.sessions), - // ── not taken over: MCP resources ───────────────────────────────────────── - notTakenOver('list_mcp_resources', REASON.mcp), - notTakenOver('list_mcp_resource_templates', REASON.mcp), - notTakenOver('read_mcp_resource', REASON.mcp), - // ── not taken over: host terminal sessions ──────────────────────────────── - notTakenOver('terminal_open', REASON.terminal), - notTakenOver('terminal_close', REASON.terminal), - notTakenOver('terminal_list', REASON.terminal), - notTakenOver('terminal_read', REASON.terminal), - notTakenOver('terminal_send', REASON.terminal), - notTakenOver('terminal_signal', REASON.terminal), - // ── not taken over: interactive planning ────────────────────────────────── - notTakenOver('plan', REASON.planning), - notTakenOver('exit_plan_mode', REASON.planning), - notTakenOver('ask_user_question', REASON.planning), - notTakenOver('todo_write', REASON.planning), - // ── not taken over: scheduling and goals ────────────────────────────────── - notTakenOver('schedule_create', REASON.scheduling), - notTakenOver('schedule_delete', REASON.scheduling), - notTakenOver('schedule_list', REASON.scheduling), - notTakenOver('schedule_update', REASON.scheduling), - notTakenOver('create_goal', REASON.scheduling), - notTakenOver('get_goal', REASON.scheduling), - notTakenOver('update_goal', REASON.scheduling), - // ── not taken over: media ───────────────────────────────────────────────── - notTakenOver('present', REASON.media), - notTakenOver('read_image', REASON.media), - // ── not taken over: web ─────────────────────────────────────────────────── - notTakenOver('web_search', REASON.web), - notTakenOver('web_fetch', REASON.web), - // ── not taken over: skills and orchestration ────────────────────────────── - notTakenOver('skill', REASON.orchestration), - notTakenOver('workflow', REASON.orchestration), - notTakenOver('ralph', REASON.orchestration), - // ── not taken over: host/plugin introspection ───────────────────────────── - notTakenOver('plugin_manager', REASON.hostInspect), - notTakenOver('cordis_inspect_list', REASON.hostInspect), - notTakenOver('cordis_inspect_query', REASON.hostInspect), - notTakenOver('lsp', REASON.hostInspect), - notTakenOver('load_workspace_dependencies', REASON.hostInspect), - // ── not taken over: dynamic prefixes ────────────────────────────────────── - prefixNotTakenOver(OFFICIAL_TOOL_PREFIXES[0], REASON.dynamic), - prefixNotTakenOver(OFFICIAL_TOOL_PREFIXES[1], REASON.dynamic), -]) - -/** Manifest rows for pinned tool names, in manifest order. */ -export const TAKEOVER_SCOPE_ROWS = Object.freeze(TAKEOVER_MANIFEST.filter(row => !row.prefix)) -/** Manifest rows for dynamic tool prefixes, in manifest order. */ -export const TAKEOVER_PREFIX_ROWS = Object.freeze(TAKEOVER_MANIFEST.filter(row => row.prefix)) - -/** - * The official names dsh-ops takes over, grouped by face (derived from the manifest). - * - * Names are pinned official tool names, so a third-party tool is never - * classified by guesswork; a configurable alias is classified only when the - * operator lists it in `additionalOfficialTools`, and even then it stays - * outside this scope under the default `takeover`. - */ -export const TAKEOVER_SCOPE = Object.freeze(Object.fromEntries(TAKEOVER_FACES.map(face => [ - face, Object.freeze(TAKEOVER_SCOPE_ROWS.filter(row => row.face === face).map(row => row.tool)), -]))) - -const FACE_BY_TOOL = new Map(TAKEOVER_SCOPE_ROWS.filter(row => row.face !== null).map(row => [row.tool, row.face])) -const EQUIVALENT_BY_TOOL = new Map(TAKEOVER_SCOPE_ROWS.filter(row => row.equivalent !== null).map(row => [row.tool, row.equivalent])) - -/** The takeover face of an official tool, or `undefined` when outside scope. */ -export function takeoverFace(tool) { return FACE_BY_TOOL.get(tool) } - -/** The reviewed OPS equivalent of an official tool, or `undefined` when none is claimed. */ -export function takeoverEquivalent(tool) { return EQUIVALENT_BY_TOOL.get(tool) } - -/** The manifest row for a tool name or dynamic prefix, or `undefined`. */ -export function takeoverManifestRow(tool) { return TAKEOVER_MANIFEST.find(row => row.tool === tool) } - -// ── scope switch ──────────────────────────────────────────────────────────── - -/** The accepted `scope` values, widest last; `takeover` is the F4-reviewed default. */ -export const TAKEOVER_MODES = Object.freeze(['takeover', 'extended', 'off']) -/** The scope every row gets when the key is absent: exactly F4's safe semantics. */ -export const DEFAULT_TAKEOVER_MODE = 'takeover' - -const modeError = value => new ConfigError( - `scope must be one of ${TAKEOVER_MODES.map(mode => JSON.stringify(mode)).join(', ')}, got ${JSON.stringify(value)}`, -) - -/** - * Resolve one hiding row's config, including the `scope` switch. - * - * `scope` is read here rather than in `lib/config.js` so the switch stays part - * of this component's own surface; the remaining keys keep their F4 validation - * (`strict` must be `true`, the alias list stays an alias list) through - * {@link resolveHidingConfig}. - * @param {unknown} [raw] - the `config` value from the loader row. - * @returns {{scope: string, strict: true, additionalOfficialTools: string[]}} resolved config. - */ -export function resolveTakeoverConfig(raw = {}) { - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new ConfigError('hiding config must be an object') - const { scope, ...hiding } = /** @type {Record} */ (raw) - const resolved = resolveHidingConfig(hiding) - if (scope === undefined || scope === null) return { ...resolved, scope: DEFAULT_TAKEOVER_MODE } - if (!TAKEOVER_MODES.includes(/** @type {string} */ (scope))) throw modeError(scope) - return { ...resolved, scope: /** @type {string} */ (scope) } -} - -/** The declared scope of a resolved or partially resolved config, defaulting safely. */ -export function takeoverMode(config = {}) { - const mode = config?.scope ?? DEFAULT_TAKEOVER_MODE - if (!TAKEOVER_MODES.includes(mode)) throw modeError(mode) - return mode -} - -/** - * Whether dsh-ops is prepared to hide this official tool. - * - * Hiding requires all three: a takeover face, no reliance on the platform's - * retained host shell, and a reviewed equivalent in the manifest. A similarly - * named registration is still not evidence of equivalence, and an - * `additionalOfficialTools` entry cannot extend hiding beyond the scope. - * @param {string} tool - the official tool name. - * @param {string} [platform] - the platform whose retained shell is exempt. - * @returns {boolean} whether the tool is a hide candidate. - */ -export function isHideable(tool, platform = process.platform) { - if (tool === platformSurface(platform).retainedCommand) return false - return takeoverFace(tool) !== undefined && takeoverEquivalent(tool) !== undefined -} - -/** - * Split the presented surface into hide candidates and kept-visible tools. - * - * Never throws on the surface and never decides readiness: out-of-scope tools - * are the host's business, and in-scope tools without an equivalent stay - * visible. Under `scope: off` nothing is classified at all. The only - * readiness-shaped output is `explicitUnsupported` (see {@link surfaceWarning} - * and the explicit-blocked message in `apply`). - * @param {string[]} names - tool names the scope presents. - * @param {{additionalOfficialTools?: string[], scope?: string}} [config] - resolved hiding config. - * @param {string} [platform] - platform deciding the retained host shell. - * @returns {object} classified surface, with every list sorted by tool name. - */ -export function classifyOfficialSurface(names, config = resolveHidingConfig(), platform = process.platform) { - const mode = takeoverMode(config) - const { retainedCommand } = platformSurface(platform) - const explicit = new Set(config.additionalOfficialTools ?? []) - const official = [...new Set(names)].filter(tool => isOfficialTool(tool, [...explicit])).sort() - const surface = { - mode, retainedCommand, official, - hidden: [], inScopeUnsupported: [], explicitUnsupported: [], outOfScope: [], explicitOutOfScope: [], - } - if (mode === 'off') return surface - for (const tool of official) { - if (tool === retainedCommand) continue - const face = takeoverFace(tool) - // `extended` promotes an explicitly named out-of-scope tool into the scope; - // otherwise the name only earns a warning, never a mask. - if (face === undefined && !(mode === 'extended' && explicit.has(tool))) { - surface[explicit.has(tool) ? 'explicitOutOfScope' : 'outOfScope'].push(tool) - } else if (takeoverEquivalent(tool) !== undefined) { - surface.hidden.push(tool) - } else { - surface[explicit.has(tool) ? 'explicitUnsupported' : 'inScopeUnsupported'].push(tool) - } - } - return surface -} - -const count = (total, one, many) => `${total} ${total === 1 ? one : many}` - -/** - * One readable warning for everything that stays visible, or `undefined`. - * - * The text names every tool: an operator must be able to tell which official - * tools were not hidden and why, without reading the source. It never contains - * `READINESS_BLOCKED`: these tools do not stop the session. - * @param {ReturnType} surface - classified surface. - * @returns {string|undefined} the warning sentence group. - */ -export function surfaceWarning(surface) { - if (surface.mode === 'off') return undefined - const keptOut = [...surface.outOfScope, ...surface.explicitOutOfScope].sort() - const parts = [] - if (keptOut.length) { - parts.push(`${count(keptOut.length, 'official tool stays', 'official tools stay')} visible because ${keptOut.length === 1 ? 'it is' : 'they are'} outside the dsh-ops takeover scope (shell/file/ptc): ${keptOut.join(', ')}`) - if (surface.explicitOutOfScope.length) { - parts.push(`the explicit additionalOfficialTools request for ${surface.explicitOutOfScope.join(', ')} is ignored: strict never fails on out-of-scope tools`) - } - } - if (surface.inScopeUnsupported.length) { - parts.push(`${count(surface.inScopeUnsupported.length, 'in-scope official tool stays', 'in-scope official tools stay')} visible because no registered OPS equivalent exists: ${surface.inScopeUnsupported.join(', ')}; list one in additionalOfficialTools to fail closed deliberately`) - } - return parts.length ? `dsh-ops hide-official: ${parts.join('. ')}.` : undefined -} - -const blocked = detail => `READINESS_BLOCKED (EXECUTOR_UNAVAILABLE): dsh-ops hide-official cannot verify its takeover scope: ${detail}. No official tool inside shell/file/ptc stays hidden while this scope is unready; official tools outside those faces are unaffected by strict.` -const explicitBlocked = names => `READINESS_BLOCKED (HIDING_REQUEST_UNSATISFIABLE): dsh-ops hide-official cannot honor an explicit hide request inside its shell/file/ptc takeover scope: no registered OPS equivalent exists for ${names.join(', ')}. Fix: remove ${names.map(tool => `"${tool}"`).join(', ')} from additionalOfficialTools (each tool then stays visible and only warns), or disable the dsh-ops-hide-official row.` +const blocked = detail => `READINESS_BLOCKED (EXECUTOR_UNAVAILABLE): dsh-ops hide-official cannot verify selected replacements: ${detail}. Restore the selected OPS tools, remove the selection from replaceTools, or disable the dsh-ops-hide-official row.` export function apply(root, raw) { - const config = resolveTakeoverConfig(raw) - // `scope: off` is an inert row: no mask, no warning, no readiness gate, not - // even a host-API check. It exists to neutralize the row without editing - // `disabled` (shared profiles, staged rollouts). - if (config.scope === 'off') return - platformSurface() // Refuse an unknown platform before any surface work. - const hideable = tool => isHideable(tool) + const { replaceTools } = resolveHidingConfig(raw) + // Empty and legacy-only rows install no hooks and cannot block a session. + if (!replaceTools.length) return + const selected = new Set(replaceTools) + const hideable = tool => selected.has(tool) root.inject(['tools', 'systemPrompt'], ctx => { const entries = new Map() let stopped = false, mutating = false, lastSurface const tools = ctx.get('tools') const apiError = ['schemas', 'get', 'restrict', 'guard'].filter(api => typeof tools[api] !== 'function') const fail = message => ctx.logger?.error?.(message) - const warn = message => ctx.logger?.warn?.(message) const clear = entry => { const pending = entry.lifts.splice(0) const failed = [] @@ -359,22 +35,21 @@ export function apply(root, raw) { // Readiness examines the unmasked scope. It never accepts ops_ // as an equivalence claim, nor uses run_code as a native replacement. const prepare = (agent, entry) => { - const names = tools.schemas(agent).map(schema => schema.name) - const surface = classifyOfficialSurface(names, config) - const missing = FILE_TOOLS.map(publicToolName).filter(tool => !names.includes(tool)) - for (const tool of surface.hidden) { - const replacement = takeoverEquivalent(tool) - if (!names.includes(replacement)) missing.push(`${tool} -> ${replacement}`) - } + const names = typeof tools.schemas === 'function' ? tools.schemas(agent).map(schema => schema.name) : [] + const hidden = names.filter(hideable) + const missing = replaceTools.flatMap(tool => { + const replacement = OFFICIAL_REPLACEMENTS[tool] + return names.includes(replacement) && typeof tools.get === 'function' && tools.get(replacement, agent) + ? [] : [`${tool} -> ${replacement}`] + }) const diagnostics = [ ...apiError.length ? [`missing host API: ${apiError.join(', ')}`] : [], ...entry.cleanupError ? [entry.cleanupError] : [], ...missing.length ? [`missing OPS surface: ${[...new Set(missing)].join(', ')}`] : [], ] - const failure = diagnostics.length ? blocked(diagnostics.join('; ')) - : surface.explicitUnsupported.length ? explicitBlocked(surface.explicitUnsupported) : undefined + const failure = diagnostics.length ? blocked(diagnostics.join('; ')) : undefined // A failed plan hides nothing: no partial mask over an unready scope. - return { deny: failure ? [] : surface.hidden, warning: surfaceWarning(surface), failure } + return { deny: failure ? [] : hidden, failure } } // Compare public surfaces before touching masks. Other components also // reconcile tools/change; emitting a fresh restriction for an unchanged @@ -390,8 +65,9 @@ export function apply(root, raw) { for (const entry of entries.values()) clear(entry) const plans = [] for (const [agent, entry] of entries) { - const plan = prepare(agent, entry) - entry.warning = plan.warning + let plan + try { plan = prepare(agent, entry) } + catch (error) { plan = { deny: [], failure: blocked(error.message) } } plans.push([agent, entry, plan]) } // Each scope owns its readiness and mask. A failed child is blocked at @@ -408,10 +84,6 @@ export function apply(root, raw) { } catch (error) { clear(entry); entry.error = blocked(error.message) } } for (const entry of entries.values()) { - if (entry.warning !== entry.reportedWarning) { - if (entry.warning) warn(entry.warning) - entry.reportedWarning = entry.warning - } if (entry.error !== entry.reported) { if (entry.error) fail(entry.error) entry.reported = entry.error @@ -441,13 +113,13 @@ export function apply(root, raw) { attach(exec.agent) reconcile() // Authority is live, not a startup snapshot. return entries.get(exec.agent)?.error - ?? (hideable(exec.name) ? blocked(`official tool ${exec.name} is hidden by the shell/file/ptc takeover`) : undefined) + ?? (hideable(exec.name) ? blocked(`official tool ${exec.name} is selected for replacement`) : undefined) } ctx.on('tools/pre-execute', (exec, next) => { const reason = refusal(exec) return reason ? { kind: 'deny', reason, info: { code: 'READINESS_BLOCKED' } } : next() }) - if (typeof tools.guard === 'function') ctx.effect(() => ctx.tools.guard(refusal), 'dsh-ops: strict surface guard') + if (typeof tools.guard === 'function') ctx.effect(() => ctx.tools.guard(refusal), 'dsh-ops: replacement surface guard') ctx.on('system-prompt/assemble', async (assembly, context, next) => { // A global diagnostic assembly is not an agent AI surface. if (!context.scope) return next() @@ -479,7 +151,7 @@ export function apply(root, raw) { stopped = true; mutating = true for (const entry of entries.values()) clear(entry) entries.clear() - }, 'dsh-ops: strict official-tool restrictions') + }, 'dsh-ops: selected official-tool restrictions') for (const agent of ctx.get('agents')?.list?.() ?? []) attach(agent) if (apiError.length) fail(blocked(`missing host API: ${apiError.join(', ')}`)) }) diff --git a/test/lib/official-tool-hiding.mjs b/test/lib/official-tool-hiding.mjs index 4bad0af2..3c137ce6 100644 --- a/test/lib/official-tool-hiding.mjs +++ b/test/lib/official-tool-hiding.mjs @@ -36,8 +36,8 @@ async function mint(ctx, id, parent) { await ctx.serial('agent/created', { agent }) return agent } -async function mount(ctx, config = {}) { - const fiber = await ctx.plugin({ name: 'fixture-strict-hide', apply }, config) +async function mount(ctx, config = { replaceTools: ['read', 'glob', 'grep'] }) { + const fiber = await ctx.plugin({ name: 'fixture-selected-replacements', apply }, config) // Injected fibers activate after the owning component's mount resolves. await new Promise(resolve => setImmediate(resolve)) return fiber @@ -56,7 +56,7 @@ const assemble = (ctx, agent) => ctx.get('systemPrompt').assemble({ scope: agent const execute = (ctx, agent, name) => ctx.get('tools').execute({ agent, name, arguments: {}, callId: 'hide-test', signal: new AbortController().signal }) export async function officialToolHidingMountChecks() { - await test('strict hiding removes official schemas and dispatch, preserves unrelated scopes, and lifts on unload', async () => { + await test('selected replacements remove official schemas and dispatch, preserves unrelated scopes, and lifts on unload', async () => { const { ctx } = await bootHost() try { replacements(ctx) @@ -90,76 +90,50 @@ export async function officialToolHidingMountChecks() { assert.ok((await assemble(ctx, current)).tools.some(t => t.name === 'read')) } finally { await shutdown(ctx) } }) - await test('the reported 48-tool stock surface boots: out-of-scope tools stay visible with one readable warning', async () => { + await test('unselected stock tools stay visible and dispatchable without warning lists', async () => { const { ctx } = await bootHost() try { replacements(ctx) for (const name of ['read', 'glob', 'grep', ...STOCK_UNSUPPORTED_48]) ctx.tools.register(tool(name)) const warnings = warningsFrom(ctx) - await mount(ctx) + await mount(ctx, { replaceTools: ['read'] }) const agent = await mint(ctx, 'stock-desktop-48') - // The #207 failure was readiness: this assembly must resolve, not reject. - const assembly = await assemble(ctx, agent) - const names = assembly.tools.map(schema => schema.name) - for (const name of STOCK_UNSUPPORTED_48) assert.ok(names.includes(name), `${name} must stay visible`) - for (const name of ['read', 'glob', 'grep']) assert.equal(names.includes(name), false, `${name} is replaced and hidden`) - assert.deepEqual([...scopedToolNames(ctx, agent)].sort(), [...names].sort()) - assert.equal((await execute(ctx, agent, 'subagent')).isError, false) - assert.equal((await execute(ctx, agent, 'team_task_create')).isError, false) - assert.equal((await execute(ctx, agent, 'write')).isError, false) - assert.equal((await execute(ctx, agent, 'read')).isError, true) - - const hidingWarnings = warnings.filter(message => message.includes('dsh-ops hide-official')) - assert.equal(hidingWarnings.length, 1, `one aggregated warning: ${JSON.stringify(hidingWarnings)}`) - const [warning] = hidingWarnings - assert.doesNotMatch(warning, /READINESS_BLOCKED/) - assert.match(warning, /46 official tools stay visible because they are outside the dsh-ops takeover scope \(shell\/file\/ptc\)/) - for (const name of ['subagent', 'team_task_create', 'terminal_send', 'session_search', 'mcp', 'schedule_create', 'web_search', 'workflow']) { - assert.ok(warning.includes(name), `the warning names ${name}`) + const names = (await assemble(ctx, agent)).tools.map(schema => schema.name) + for (const name of ['glob', 'grep', ...STOCK_UNSUPPORTED_48]) { + assert.ok(names.includes(name), name) + assert.equal((await execute(ctx, agent, name)).isError, false, name) } - assert.match(warning, /2 in-scope official tools stay visible because no registered OPS equivalent exists: edit, write/) - } finally { await shutdown(ctx) } - }) - await test('in-scope tools without an equivalence claim stay visible and warn instead of blocking assembly', async () => { - const { ctx } = await bootHost() - try { - replacements(ctx) - for (const name of ['read', 'write', 'str_replace_editor', 'web_search', 'subagent', 'ask_user_question', 'plan', 'foreign']) ctx.tools.register(tool(name)) - const warnings = warningsFrom(ctx) - await mount(ctx) - const agent = await mint(ctx, 'mixed-surface') - const assembly = await assemble(ctx, agent) - const names = assembly.tools.map(schema => schema.name) assert.equal(names.includes('read'), false) - for (const name of ['write', 'str_replace_editor', 'web_search', 'subagent', 'ask_user_question', 'plan']) { - assert.ok(names.includes(name), `${name} stays visible`) - assert.equal((await execute(ctx, agent, name)).isError, false, `${name} stays dispatchable`) - } - const warning = warnings.find(message => message.includes('dsh-ops hide-official')) - assert.match(warning, /2 in-scope official tools stay visible.*str_replace_editor, write/) - assert.match(warning, /outside the dsh-ops takeover scope \(shell\/file\/ptc\).*ask_user_question, plan, subagent, web_search/) + assert.equal((await execute(ctx, agent, 'read')).isError, true) + assert.deepEqual(warnings.filter(message => message.includes('dsh-ops hide-official')), []) } finally { await shutdown(ctx) } }) - await test('an explicit hide request for an in-scope tool without equivalence fails closed with an executable way out', async () => { + await test('each selected tool needs only its own reviewed counterpart', async () => { + for (const [official, replacement] of [['read', 'inspect_local_file'], ['glob', 'glob'], ['grep', 'grep']]) { + const { ctx } = await bootHost() + try { + ctx.tools.register(tool(publicToolName(replacement))) + for (const name of ['read', 'glob', 'grep', 'write', 'edit', 'foreign']) ctx.tools.register(tool(name)) + await mount(ctx, { replaceTools: [official] }) + const agent = await mint(ctx, 'one-selection-' + official) + const names = (await assemble(ctx, agent)).tools.map(schema => schema.name) + for (const name of ['read', 'glob', 'grep', 'write', 'edit', 'foreign']) { + assert.equal(names.includes(name), name !== official, name) + assert.equal((await execute(ctx, agent, name)).isError, name === official, name) + } + } finally { await shutdown(ctx) } + } + }) + await test('a missing selected counterpart restores every mask and fails closed', async () => { const { ctx } = await bootHost() try { - replacements(ctx) - for (const name of ['read', 'write', 'web_search', 'foreign']) ctx.tools.register(tool(name)) - const warnings = warningsFrom(ctx) - await mount(ctx, { additionalOfficialTools: ['write', 'web_search'] }) - const agent = await mint(ctx, 'explicit-request') - let message - await assert.rejects(() => assemble(ctx, agent), error => { message = error.message; return true }) - assert.match(message, /HIDING_REQUEST_UNSATISFIABLE/) - assert.match(message, /no registered OPS equivalent exists for write/) - assert.match(message, /remove "write" from additionalOfficialTools/) - assert.match(message, /disable the dsh-ops-hide-official row/) - // Fail-closed means no partial mask: the replaced tool is visible again. - assert.ok(scopedToolNames(ctx, agent).includes('read')) + ctx.tools.register(tool(publicToolName('glob'))) + for (const name of ['read', 'glob', 'foreign', 'ops_read']) ctx.tools.register(tool(name)) + await mount(ctx, { replaceTools: ['read', 'glob'] }) + const agent = await mint(ctx, 'missing-selection') + await assert.rejects(() => assemble(ctx, agent), /read -> ops_inspect_local_file.*remove the selection from replaceTools/) + for (const name of ['read', 'glob', 'foreign']) assert.ok(scopedToolNames(ctx, agent).includes(name)) assert.equal((await execute(ctx, agent, 'foreign')).isError, true) - const warning = warnings.find(text => text.includes('dsh-ops hide-official')) - assert.match(warning, /explicit additionalOfficialTools request for web_search is ignored/) - assert.doesNotMatch(warning, /write/) } finally { await shutdown(ctx) } }) await test('replacement withdrawal restores official tools but blocks the turn until readiness returns', async () => { @@ -176,7 +150,7 @@ export async function officialToolHidingMountChecks() { ctx.tools.register(tool(publicToolName(FILE_TOOLS[0]))) await waitFor(() => !scopedToolNames(ctx, agent).includes('read')) await assemble(ctx, agent) - // In-scope write has no reviewed replacement: it stays visible, not fatal. + // Unselected write stays visible without affecting selected read readiness. const disposeWrite = ctx.tools.register(tool('write')) const assembly = await assemble(ctx, agent) assert.ok(assembly.tools.some(schema => schema.name === 'write')) @@ -197,7 +171,7 @@ export async function officialToolHidingMountChecks() { assert.ok(scopedToolNames(ctx, agent).includes('read')) } finally { await shutdown(ctx) } }) - await test('the PTC transport run_code is in scope without a replacement: kept visible and warned, session starts', async () => { + await test('the unselected PTC transport stays visible without warnings', async () => { const { ctx } = await bootHost() try { replacements(ctx) @@ -208,27 +182,28 @@ export async function officialToolHidingMountChecks() { const lift = agent.ctx.tools.presentAs('both') const assembly = await assemble(ctx, agent) assert.ok(assembly.tools.some(schema => schema.name === 'run_code')) - assert.match(warnings.find(message => message.includes('dsh-ops hide-official')), /in-scope official tool stays visible.*run_code/) + assert.deepEqual(warnings.filter(message => message.includes('dsh-ops hide-official')), []) assert.equal((await execute(ctx, agent, publicToolName('glob'))).isError, false) lift() await assemble(ctx, agent) } finally { await shutdown(ctx) } }) - await test('configured aliases and dynamic prefixes stay visible outside the takeover scope', async () => { - const { ctx } = await bootHost() - try { - replacements(ctx) - for (const name of ['custom_delegate', 'stagehand_tabs', 'cua_driver_native__click', 'foreign']) ctx.tools.register(tool(name)) - const warnings = warningsFrom(ctx) - await mount(ctx, { additionalOfficialTools: ['custom_delegate'] }) - const agent = await mint(ctx, 'official-alias') - const assembly = await assemble(ctx, agent) - const names = assembly.tools.map(schema => schema.name) - for (const name of ['custom_delegate', 'stagehand_tabs', 'cua_driver_native__click', 'foreign']) assert.ok(names.includes(name)) - const warning = warnings.find(message => message.includes('dsh-ops hide-official')) - assert.match(warning, /outside the dsh-ops takeover scope/) - assert.match(warning, /explicit additionalOfficialTools request for custom_delegate is ignored/) - } finally { await shutdown(ctx) } + await test('legacy blanket selections deactivate without guessing replacements or warning', async () => { + for (const scope of ['takeover', 'extended', 'off']) { + const { ctx } = await bootHost() + try { + for (const name of ['read', 'glob', 'grep', 'custom_delegate', 'stagehand_tabs', 'cua_driver_native__click']) ctx.tools.register(tool(name)) + const warnings = warningsFrom(ctx) + await mount(ctx, { strict: true, scope, additionalOfficialTools: ['custom_delegate', 'read'] }) + const agent = await mint(ctx, 'legacy-' + scope) + const names = (await assemble(ctx, agent)).tools.map(schema => schema.name) + for (const name of ['read', 'glob', 'grep', 'custom_delegate', 'stagehand_tabs', 'cua_driver_native__click']) { + assert.ok(names.includes(name), name) + assert.equal((await execute(ctx, agent, name)).isError, false, name) + } + assert.deepEqual(warnings.filter(message => message.includes('dsh-ops hide-official')), []) + } finally { await shutdown(ctx) } + } }) await test('a root that loses its OPS surface does not stop a healthy root', async () => { const { ctx } = await bootHost() @@ -302,6 +277,21 @@ export async function officialToolHidingMountChecks() { assert.equal(decision.info.code, 'READINESS_BLOCKED') } finally { await shutdown(ctx) } }) + await test('schema-only counterpart claims do not satisfy selected readiness', async () => { + const { ctx } = await bootHost({ withTools: false }) + try { + // Infrastructure fixture exposes a schema without a registered definition. + ctx.provide('tools', { + schemas: () => [{ name: publicToolName('inspect_local_file') }], + get: () => undefined, + restrict: () => () => {}, + guard: () => () => {}, + }) + await mount(ctx, { replaceTools: ['read'] }) + const agent = await mint(ctx, 'schema-without-registration') + await assert.rejects(() => assemble(ctx, agent), /missing OPS surface: read -> ops_inspect_local_file/) + } finally { await shutdown(ctx) } + }) await test('assembled injection of a hidden tool is rejected even when registry masking succeeded', async () => { const { ctx } = await bootHost() try { @@ -357,13 +347,13 @@ export async function officialToolHidingMountChecks() { assert.ok(scopedToolNames(ctx, agent).includes('read')) } finally { await shutdown(ctx) } }) - await test('scope off is inert: nothing hidden, no warning, and not even a host-API readiness gate', async () => { + await test('empty selections are inert without warnings or a host API readiness gate', async () => { { const { ctx } = await bootHost() try { for (const name of ['read', 'glob', 'grep', ...STOCK_UNSUPPORTED_48]) ctx.tools.register(tool(name)) const warnings = warningsFrom(ctx) - await mount(ctx, { scope: 'off' }) + await mount(ctx, { replaceTools: [] }) const agent = await mint(ctx, 'scope-off') const assembly = await assemble(ctx, agent) const names = assembly.tools.map(schema => schema.name) @@ -382,51 +372,43 @@ export async function officialToolHidingMountChecks() { try { ctx.provide('tools', { schemas: () => FILE_TOOLS.map(name => ({ name: publicToolName(name) })), get() {} }) const warnings = warningsFrom(ctx) - await mount(ctx, { scope: 'off' }) + await mount(ctx, { replaceTools: [] }) const agent = await mint(ctx, 'scope-off-no-host-api') await assemble(ctx, agent) assert.equal(warnings.filter(message => message.includes('dsh-ops hide-official')).length, 0) } finally { await shutdown(ctx) } } }) - await test('scope extended promotes an explicit out-of-scope request into the scope and fails closed with the way out', async () => { + await test('an unconfigured enabled row preserves official tools with no OPS registrations', async () => { const { ctx } = await bootHost() try { - replacements(ctx) - for (const name of ['read', 'subagent', 'foreign']) ctx.tools.register(tool(name)) - await mount(ctx, { scope: 'extended', additionalOfficialTools: ['subagent'] }) - const agent = await mint(ctx, 'scope-extended') - let message - await assert.rejects(() => assemble(ctx, agent), error => { message = error.message; return true }) - assert.match(message, /HIDING_REQUEST_UNSATISFIABLE/) - assert.match(message, /no registered OPS equivalent exists for subagent/) - assert.match(message, /remove "subagent" from additionalOfficialTools/) - assert.match(message, /disable the dsh-ops-hide-official row/) - // Fail-closed means no partial mask, and dispatch is refused while unready. - assert.ok(scopedToolNames(ctx, agent).includes('read')) - assert.equal((await execute(ctx, agent, 'foreign')).isError, true) + for (const name of ['read', 'glob', 'grep', 'foreign']) ctx.tools.register(tool(name)) + await mount(ctx, {}) + const agent = await mint(ctx, 'default-off') + const names = (await assemble(ctx, agent)).tools.map(schema => schema.name) + for (const name of ['read', 'glob', 'grep', 'foreign']) { + assert.ok(names.includes(name), name) + assert.equal((await execute(ctx, agent, name)).isError, false) + } } finally { await shutdown(ctx) } }) - await test('scope extended with no explicit extension is the takeover scope: the 48-tool stock surface still resolves', async () => { + await test('changing selection through remount restores deselected tools', async () => { const { ctx } = await bootHost() try { replacements(ctx) - for (const name of ['read', 'glob', 'grep', ...STOCK_UNSUPPORTED_48]) ctx.tools.register(tool(name)) - const warnings = warningsFrom(ctx) - await mount(ctx, { scope: 'extended' }) - const agent = await mint(ctx, 'extended-stock-48') - const assembly = await assemble(ctx, agent) - const names = assembly.tools.map(schema => schema.name) - for (const name of STOCK_UNSUPPORTED_48) assert.ok(names.includes(name), `${name} must stay visible`) - for (const name of ['read', 'glob', 'grep']) assert.equal(names.includes(name), false, `${name} is replaced and hidden`) - const hidingWarnings = warnings.filter(message => message.includes('dsh-ops hide-official')) - assert.equal(hidingWarnings.length, 1) - assert.doesNotMatch(hidingWarnings[0], /READINESS_BLOCKED/) - assert.match(hidingWarnings[0], /46 official tools stay visible because they are outside the dsh-ops takeover scope \(shell\/file\/ptc\)/) - assert.match(hidingWarnings[0], /2 in-scope official tools stay visible because no registered OPS equivalent exists: edit, write/) - assert.equal((await execute(ctx, agent, 'subagent')).isError, false) - assert.equal((await execute(ctx, agent, 'write')).isError, false) - assert.equal((await execute(ctx, agent, 'read')).isError, true) + for (const name of ['read', 'glob', 'grep']) ctx.tools.register(tool(name)) + const first = await mount(ctx, { replaceTools: ['read'] }) + const agent = await mint(ctx, 'changed-selection') + assert.equal(scopedToolNames(ctx, agent).includes('read'), false) + await first.dispose() + await mount(ctx, { replaceTools: ['glob'] }) + await ctx.serial('agent/created', { agent }) + const names = (await assemble(ctx, agent)).tools.map(schema => schema.name) + assert.ok(names.includes('read')) + assert.ok(names.includes('grep')) + assert.equal(names.includes('glob'), false) + assert.equal((await execute(ctx, agent, 'read')).isError, false) } finally { await shutdown(ctx) } }) + } diff --git a/test/mount.test.mjs b/test/mount.test.mjs index 80242f9a..d02629b8 100644 --- a/test/mount.test.mjs +++ b/test/mount.test.mjs @@ -336,7 +336,7 @@ await test('real file replacement surface hides duplicates, keeps the PTC transp await fileMount(ctx) for (const name of ['read', 'glob', 'grep', process.platform === 'win32' ? 'pwsh' : 'bash']) ctx.tools.register(foreignTool(name)) const { apply: hideOfficial } = await import('../lib/official-tool-hiding.js') - const hiding = await ctx.plugin({ name: 'real-file-takeover', apply: hideOfficial }, {}) + const hiding = await ctx.plugin({ name: 'real-file-takeover', apply: hideOfficial }, { replaceTools: ['read', 'glob', 'grep'] }) await new Promise(resolve => setImmediate(resolve)) const current = await agent(ctx, 'real-file-takeover') const assembly = await ctx.get('systemPrompt').assemble({ scope: current }) @@ -347,8 +347,7 @@ await test('real file replacement surface hides duplicates, keeps the PTC transp assert.equal(names.some(name => name.startsWith('ops_process_')), false) ctx.provide('ptcRuntime', { language: 'typescript' }) const restore = current.ctx.tools.presentAs('both') - // run_code is inside the PTC face with no reviewed replacement, so strict - // keeps it visible and warns instead of failing the session (#238). + // The unselected host transport remains visible alongside selected replacements. const withTransport = await ctx.get('systemPrompt').assemble({ scope: current }) const transported = withTransport.tools.map(tool => tool.name) assert.ok(transported.includes('run_code')) diff --git a/test/official-tool-hiding.test.mjs b/test/official-tool-hiding.test.mjs index e646088a..dd5f8bc6 100644 --- a/test/official-tool-hiding.test.mjs +++ b/test/official-tool-hiding.test.mjs @@ -1,210 +1,80 @@ import fs from 'node:fs' import path from 'node:path' import { parse } from 'yaml' +import { Context } from '@deepseek-ai/cordis' import { resolveHidingConfig } from '../lib/config.js' -import { isOfficialTool, OFFICIAL_TOOL_NAMES, OFFICIAL_TOOL_PREFIXES, OFFICIAL_REPLACEMENTS, platformSurface } from '../lib/official-tool-catalog.js' -import { - classifyOfficialSurface, DEFAULT_TAKEOVER_MODE, isHideable, resolveTakeoverConfig, surfaceWarning, - TAKEOVER_MANIFEST, TAKEOVER_MODES, TAKEOVER_PREFIX_ROWS, TAKEOVER_SCOPE, TAKEOVER_SCOPE_ROWS, - takeoverEquivalent, takeoverFace, takeoverManifestRow, -} from '../lib/official-tool-hiding.js' +import { OFFICIAL_REPLACEMENTS, platformSurface } from '../lib/official-tool-catalog.js' +import { Config } from '../lib/components/hide-official/index.js' import { assert, PACKAGE_ROOT, report, test } from './lib/harness.mjs' -import { officialToolHidingMountChecks, STOCK_UNSUPPORTED_48 } from './lib/official-tool-hiding.mjs' +import { officialToolHidingMountChecks } from './lib/official-tool-hiding.mjs' -await test('strict hiding config is independent, rejects downgrade/unknown keys and copies aliases', () => { - assert.deepEqual(resolveHidingConfig(), { strict: true, additionalOfficialTools: [] }) - for (const config of [null, [], false, { strict: false }, { strict: 'true' }, { required: true }, - { additionalOfficialTools: 'subagent' }, { additionalOfficialTools: [''] }, { additionalOfficialTools: ['ops_read'] }]) { - assert.throws(() => resolveHidingConfig(config)) - } - const raw = { additionalOfficialTools: ['custom_delegation', 'custom_delegation'] } +await test('replacement config defaults empty, copies and deduplicates explicit selections', () => { + assert.deepEqual(resolveHidingConfig(), { replaceTools: [] }) + const raw = { replaceTools: ['read', 'grep', 'read'] } const result = resolveHidingConfig(raw) - raw.additionalOfficialTools.push('another') - assert.deepEqual(result.additionalOfficialTools, ['custom_delegation']) + raw.replaceTools.push('glob') + assert.deepEqual(result, { replaceTools: ['read', 'grep'] }) +}) +await test('invalid configuration names its key or unsupported selection', () => { + for (const config of [null, [], false]) assert.throws(() => resolveHidingConfig(config), /hiding config/) + for (const replaceTools of [null, true, 'read', {}, 1]) { + assert.throws(() => resolveHidingConfig({ replaceTools }), /replaceTools must be an array/) + } + for (const tool of ['write', 'edit', 'str_replace_editor', 'bash', 'ops_read', 'run_code', 'toString', '', null, {}]) { + assert.throws(() => resolveHidingConfig({ replaceTools: [tool] }), /replaceTools contains unsupported tool/) + } + assert.throws(() => resolveHidingConfig({ replacementTools: ['read'] }), /unknown hiding key: replacementTools/) +}) +await test('legacy blanket settings are validated but never authorize replacements', () => { + for (const scope of ['takeover', 'extended', 'off', undefined, null]) { + assert.deepEqual(resolveHidingConfig({ strict: true, scope, additionalOfficialTools: ['read', 'write', 'subagent'] }), { replaceTools: [] }) + assert.deepEqual(resolveHidingConfig({ scope, replaceTools: ['grep'] }), { replaceTools: ['grep'] }) + } + assert.throws(() => resolveHidingConfig({ strict: false }), /strict/) + assert.throws(() => resolveHidingConfig({ scope: 'all' }), /scope/) + for (const value of ['read', [''], ['ops_read']]) { + assert.throws(() => resolveHidingConfig({ additionalOfficialTools: value }), /additionalOfficialTools/) + } +}) +await test('the real Cordis loader consumes the component Standard Schema validator', async () => { + const ctx = new Context() + ctx.logger.level = 0 + let received + try { + await ctx.plugin({ Config, apply(_ctx, value) { received = value } }, { strict: true, replaceTools: ['read', 'read'] }) + assert.deepEqual(received, { replaceTools: ['read'] }) + let invalidMounted = false + await assert.rejects(async () => await ctx.plugin({ Config, apply() { invalidMounted = true } }, { unknownSelection: true }), /unknown hiding key: unknownSelection/) + assert.equal(invalidMounted, false) + const error = Config['~standard'].validate({ replaceTools: ['write'] }) + assert.match(error.issues[0].message, /unsupported tool: "write"/) + } finally { await ctx.fiber.dispose() } }) -await test('strict hiding is disabled opt-in and the pinned platform surface is exact', () => { +await test('the disabled component patch also defaults to an empty selection', () => { const document = parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'), 'utf8'), { customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: value => value }], }) - const rows = document.flatMap(item => item.insert) - const hiding = rows.find(row => row.name === 'dsh-ops/hide-official') - // Registered but never enabled by default: enabling the row is an operator - // decision, and a component that hid host capabilities by default would - // change every install's surface without one. + const hiding = document.flatMap(item => item.insert).find(row => row.name === 'dsh-ops/hide-official') assert.equal(hiding.disabled, true) - assert.deepEqual(resolveHidingConfig(hiding.config), { strict: true, additionalOfficialTools: [] }) + assert.deepEqual(hiding.config, { replaceTools: [] }) + assert.deepEqual(resolveHidingConfig(hiding.config), { replaceTools: [] }) for (const platform of ['win32', 'linux', 'darwin']) { assert.deepEqual(platformSurface(platform), { retainedCommand: platform === 'win32' ? 'pwsh' : 'bash', shellDefaultEnabled: true }) } - assert.throws(() => platformSurface('freebsd'), /Unsupported/) -}) -await test('catalog covers the pinned official categories without guessing third-party or OPS equivalence', () => { - assert.equal(new Set(OFFICIAL_TOOL_NAMES).size, OFFICIAL_TOOL_NAMES.length) - for (const name of ['run_code', 'bash', 'pwsh', 'read', 'read_image', 'write', 'edit', 'glob', 'grep', 'str_replace_editor', - 'exit_plan_mode', 'subagent', 'subagent_fork', 'ask_user_question', 'web_search', 'web_fetch', 'terminal_send', 'plugin_manager', - 'schedule_create', 'list_mcp_resources', 'session_trace', 'lsp', 'workflow', 'team_task_list', 'stagehand_tabs', 'cua_driver_native__dynamic']) { - assert.equal(isOfficialTool(name), true, name) - } - for (const name of ['foreign', 'mcp__server__tool', 'ops_read', 'ops_glob']) assert.equal(isOfficialTool(name), false) - assert.equal(isOfficialTool('custom_alias', ['custom_alias']), true) - assert.deepEqual(Object.keys(OFFICIAL_REPLACEMENTS).sort(), ['glob', 'grep', 'read']) -}) -await test('the takeover scope is exactly shell/file/ptc, and hiding needs a reviewed replacement', () => { - assert.deepEqual(Object.keys(TAKEOVER_SCOPE), ['shell', 'file', 'ptc']) - for (const [tool, face] of [['bash', 'shell'], ['pwsh', 'shell'], ['read', 'file'], ['write', 'file'], - ['str_replace_editor', 'file'], ['run_code', 'ptc']]) assert.equal(takeoverFace(tool), face, tool) - // Host capabilities the component was never meant to replace are out of scope. - assert.equal(takeoverFace('subagent'), undefined) - assert.equal(takeoverFace('terminal_send'), undefined) - assert.equal(takeoverFace('read_image'), undefined) - assert.equal(takeoverFace('web_search'), undefined) - for (const tool of ['read', 'glob', 'grep']) assert.equal(isHideable(tool, 'win32'), true, tool) - // In scope without a reviewed replacement: a hide candidate is not claimed. - for (const tool of ['write', 'edit', 'str_replace_editor', 'run_code']) assert.equal(isHideable(tool, 'linux'), false, tool) - // The platform's retained host shell is never hidden, on either side. - assert.equal(isHideable('pwsh', 'win32'), false) - assert.equal(isHideable('bash', 'linux'), false) - assert.equal(isHideable('subagent', 'win32'), false) - // An alias cannot extend hiding outside the scope, even when it is official. - assert.equal(isHideable('custom_delegate', 'linux'), false) -}) -await test('the takeover manifest is complete, reasoned and the single source of the scope', () => { - // Every row is an explicit triple: official tool -> face -> equivalent -> reason. - for (const row of TAKEOVER_MANIFEST) { - assert.equal(typeof row.tool, 'string', JSON.stringify(row)) - assert.equal(typeof row.prefix, 'boolean') - assert.equal(typeof row.reason, 'string') - assert.ok(row.reason.length > 20, `${row.tool} carries a real reason`) - if (row.prefix) { - assert.equal(row.face, null, `${row.tool} is a prefix and cannot own a face`) - assert.equal(row.equivalent, null) - } else if (row.face === null) { - assert.equal(row.equivalent, null, `${row.tool} is outside the scope and must claim no equivalent`) - } else { - assert.ok(['shell', 'file', 'ptc'].includes(row.face), `${row.tool} has a takeover face`) - } - if (row.equivalent !== null) assert.equal(OFFICIAL_REPLACEMENTS[row.tool], row.equivalent) - } - // Complete over the pinned catalog: delete a row and this goes red rather than - // quietly narrowing the takeover or forgetting a "not taken over" annotation. - assert.deepEqual(TAKEOVER_SCOPE_ROWS.map(row => row.tool).sort(), [...OFFICIAL_TOOL_NAMES].sort()) - assert.deepEqual(TAKEOVER_PREFIX_ROWS.map(row => row.tool), [...OFFICIAL_TOOL_PREFIXES]) - assert.equal(new Set(TAKEOVER_MANIFEST.map(row => row.tool)).size, TAKEOVER_MANIFEST.length) - // The reviewed equivalence registry cannot drift away from the manifest either. - assert.deepEqual(TAKEOVER_SCOPE_ROWS.filter(row => row.equivalent !== null).map(row => row.tool).sort(), - Object.keys(OFFICIAL_REPLACEMENTS).sort()) - // Scale, pinned: 11 in scope over shell/file/ptc, 52 explicitly not taken over, 2 prefixes. - assert.equal(TAKEOVER_SCOPE_ROWS.filter(row => row.face !== null).length, 11) - assert.equal(TAKEOVER_SCOPE_ROWS.filter(row => row.face === null).length, 52) - assert.equal(TAKEOVER_PREFIX_ROWS.length, 2) - assert.equal(takeoverEquivalent('read'), OFFICIAL_REPLACEMENTS.read) - assert.equal(takeoverEquivalent('write'), undefined) - assert.equal(takeoverManifestRow('subagent').face, null) - assert.match(takeoverManifestRow('subagent').reason, /delegation/iu) - assert.match(takeoverManifestRow('stagehand_').reason, /prefix/iu) -}) -await test('the scope switch defaults to the F4 takeover scope and validates every value', () => { - assert.deepEqual([...TAKEOVER_MODES], ['takeover', 'extended', 'off']) - assert.equal(DEFAULT_TAKEOVER_MODE, 'takeover') - // A row without `scope` is the F4 config plus the safe default: unchanged behaviour. - assert.deepEqual(resolveTakeoverConfig(), { strict: true, additionalOfficialTools: [], scope: 'takeover' }) - assert.deepEqual(resolveTakeoverConfig({ strict: true }), { strict: true, additionalOfficialTools: [], scope: 'takeover' }) - for (const scope of TAKEOVER_MODES) { - assert.deepEqual(resolveTakeoverConfig({ scope, additionalOfficialTools: ['subagent'] }), - { strict: true, additionalOfficialTools: ['subagent'], scope }) - } - for (const bad of ['strict', '', 'TAKEOVER', 'extend', 'on', true, 3, [], {}]) { - assert.throws(() => resolveTakeoverConfig({ scope: bad }), /scope must be one of/) - } - // The F4 keys keep their exact validation while `scope` is added. - assert.throws(() => resolveTakeoverConfig({ scope: 'takeover', strict: false }), /strict must be true/) - assert.throws(() => resolveTakeoverConfig({ scope: 'takeover', additionalOfficialTools: ['ops_read'] }), /additionalOfficialTools/) - assert.throws(() => resolveTakeoverConfig({ scope: 'takeover', required: true }), /unknown hiding key/) }) -await test('scope selects how far the takeover reaches: takeover ignores, extended promotes, off hides nothing', () => { - const names = ['read', 'glob', 'grep', 'write', 'subagent', 'pwsh', 'foreign'] - const classify = config => classifyOfficialSurface(names, resolveTakeoverConfig(config), 'linux') - - const takeover = classify({ additionalOfficialTools: ['subagent', 'write'] }) - assert.equal(takeover.mode, 'takeover') - assert.deepEqual(takeover.hidden, ['glob', 'grep', 'read']) - assert.deepEqual(takeover.explicitOutOfScope, ['subagent']) - assert.deepEqual(takeover.explicitUnsupported, ['write']) - assert.match(surfaceWarning(classify({ additionalOfficialTools: ['subagent'] })), - /explicit additionalOfficialTools request for subagent is ignored/) - - const extended = classify({ scope: 'extended', additionalOfficialTools: ['subagent', 'write'] }) - assert.equal(extended.mode, 'extended') - assert.deepEqual(extended.hidden, ['glob', 'grep', 'read']) - assert.deepEqual(extended.explicitOutOfScope, []) - assert.deepEqual(extended.explicitUnsupported, ['subagent', 'write']) - assert.doesNotMatch(surfaceWarning(classify({ scope: 'extended', additionalOfficialTools: ['subagent'] })), - /is ignored/) - - const off = classify({ scope: 'off', additionalOfficialTools: ['subagent', 'write'] }) - assert.equal(off.mode, 'off') - assert.deepEqual(off.official, ['glob', 'grep', 'pwsh', 'read', 'subagent', 'write']) - for (const key of ['hidden', 'inScopeUnsupported', 'explicitUnsupported', 'outOfScope', 'explicitOutOfScope']) { - assert.deepEqual(off[key], [], `${key} stays empty under scope off`) - } - assert.equal(surfaceWarning(off), undefined) +await test('only reviewed read workflows have mappings; mutation and transport tools do not', () => { + assert.deepEqual(OFFICIAL_REPLACEMENTS, { read: 'ops_inspect_local_file', glob: 'ops_glob', grep: 'ops_grep' }) + assert.ok(Object.isFrozen(OFFICIAL_REPLACEMENTS)) }) -await test('the component-reference scope table is the manifest, not a copy that can drift', () => { - const expected = TAKEOVER_MANIFEST.map(row => [ - row.prefix ? `${row.tool}*` : row.tool, - row.face === null ? (row.prefix ? 'prefix' : 'outside') : row.face, - row.equivalent === null ? '—' : row.equivalent, - ]) +await test('both component references list the reviewed mappings and explain deliberate workflow differences', () => { for (const file of ['docs/component-reference.md', 'docs/组件参考.md']) { const text = fs.readFileSync(path.join(PACKAGE_ROOT, file), 'utf8') const block = /([\s\S]*?)/u.exec(text) - assert.ok(block, `${file} carries the marked scope table`) - const rows = [] - for (const line of block[1].split('\n')) { - if (!line.trim().startsWith('|')) continue - const cells = line.split('|').map(cell => cell.trim()) - const tool = cells[1]?.replaceAll('`', '') - if (!tool || /^-+$/u.test(tool) || ['Official tool', '官方工具'].includes(tool)) continue - rows.push([tool, cells[2]?.replaceAll('`', ''), cells[3]?.replaceAll('`', '')]) - } - assert.deepEqual(rows, expected, `${file} lists exactly the manifest rows, in manifest order`) + assert.ok(block, file) + const rows = block[1].split('\n').filter(line => /^\| \x60/.test(line)).map(line => line.split('|').slice(1, 3).map(cell => cell.trim().replaceAll('\x60', ''))) + assert.deepEqual(rows, Object.entries(OFFICIAL_REPLACEMENTS), file) + for (const term of ['replaceTools', 'expectedRevision', 'CAS', 'Rust', 'ripgrep', 'StandardSchemaV1']) assert.ok(text.includes(term), file + ': ' + term) } }) -await test('the reported 48-tool surface classifies without a fatal condition and keeps every out-of-scope tool', () => { - // The #207 stock surface: 48 unsupported tools plus read/glob/grep (replaced) - // and pwsh (the Windows retained host shell) — 52 official tools in total. - const surface = [...STOCK_UNSUPPORTED_48, 'read', 'glob', 'grep', 'pwsh'] - assert.equal(surface.length, 52) - assert.equal(new Set(surface).size, 52) - const outOfScope = STOCK_UNSUPPORTED_48.filter(tool => !['edit', 'write'].includes(tool)).sort() - assert.equal(outOfScope.length, 46) - - const win = classifyOfficialSurface(surface, resolveHidingConfig(), 'win32') - assert.equal(win.official.length, 52) - assert.equal(win.retainedCommand, 'pwsh') - assert.deepEqual(win.hidden, ['glob', 'grep', 'read']) - assert.deepEqual(win.outOfScope, outOfScope) - assert.deepEqual(win.inScopeUnsupported, ['edit', 'write']) - assert.deepEqual(win.explicitUnsupported, []) - const warning = surfaceWarning(win) - assert.doesNotMatch(warning, /READINESS_BLOCKED/) - assert.match(warning, /46 official tools stay visible because they are outside the dsh-ops takeover scope \(shell\/file\/ptc\)/) - assert.match(warning, /2 in-scope official tools stay visible because no registered OPS equivalent exists: edit, write/) - - // `pwsh` is the retained host shell on Windows, and an in-scope unsupported - // tool on Linux — never a hide candidate and never fatal on either platform. - const linux = classifyOfficialSurface(surface, resolveHidingConfig(), 'linux') - assert.deepEqual(linux.inScopeUnsupported, ['edit', 'pwsh', 'write']) - assert.equal(linux.outOfScope.length, 46) - assert.deepEqual(linux.hidden, ['glob', 'grep', 'read']) - - // `additionalOfficialTools` escalates an in-scope tool into fail-closed and - // is only warned about outside the scope. Classification itself never throws. - const explicit = classifyOfficialSurface(surface, resolveHidingConfig({ additionalOfficialTools: ['write', 'subagent', 'pwsh'] }), 'win32') - assert.deepEqual(explicit.explicitUnsupported, ['write']) - assert.deepEqual(explicit.explicitOutOfScope, ['subagent']) - assert.equal(explicit.inScopeUnsupported.includes('pwsh'), false) - assert.match(surfaceWarning(explicit), /explicit additionalOfficialTools request for subagent is ignored/) -}) await officialToolHidingMountChecks() report('official-tool-hiding') From e8ce4e98e161fca8773a00f20be40b1c115c9493 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:37:19 +0800 Subject: [PATCH 007/118] test(native): compare canonical private staging permissions --- native/windows-metadata/src/lib.rs | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/native/windows-metadata/src/lib.rs b/native/windows-metadata/src/lib.rs index 1e75aeb2..2f82fee7 100644 --- a/native/windows-metadata/src/lib.rs +++ b/native/windows-metadata/src/lib.rs @@ -363,6 +363,25 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; + fn canonical_dacl(sd: &SecurityDescriptor) -> String { + // Auto-inherited describes how an ACL was computed; P still prevents + // inheritance and every ACE must match. create_private verifies the same. + wrappers::ConvertSecurityDescriptorToStringSecurityDescriptor(sd, Info::Dacl) + .unwrap().to_string_lossy().replace("D:PAI", "D:P") + } + + #[test] + fn dacl_comparison_normalizes_sid_aliases_without_accepting_other_grants() { + let canonical = |text: &str| { + let sd: LocalBox = text.parse().unwrap(); + canonical_dacl(&sd) + }; + assert_eq!(canonical("D:P(A;;FA;;;S-1-5-18)"), canonical("D:P(A;;FA;;;SY)")); + assert_eq!(canonical("D:PAI(A;;FA;;;SY)"), canonical("D:P(A;;FA;;;SY)")); + assert_ne!(canonical("D:P(A;;FA;;;SY)"), canonical("D:P(A;;FA;;;SY)(A;;FR;;;WD)")); + assert_ne!(canonical("D:P(A;;FA;;;SY)"), canonical("D:(A;;FA;;;SY)")); + } + #[test] fn staging_is_private_before_bytes_and_exclusive() { let root = std::env::temp_dir().join(format!( @@ -382,12 +401,10 @@ mod tests { let mut file = create_private(&parent, "private").unwrap(); let sd = descriptor(&file).unwrap(); let sid = windows_permissions::utilities::current_process_sid().unwrap(); - assert!( - sddl(&sd) - .unwrap() - .to_string_lossy() - .ends_with(&format!("D:P(A;;FA;;;{sid})")) - ); + // Windows may render well-known/account SIDs as SDDL aliases. Compare + // the exact canonical DACL, not a numeric SID's spelling in full SDDL. + let expected: LocalBox = format!("D:P(A;;FA;;;{sid})").parse().unwrap(); + assert_eq!(canonical_dacl(&sd), canonical_dacl(&expected)); assert_eq!(file.metadata().unwrap().len(), 0); assert_eq!( create_private(&parent, "private").unwrap_err().kind(), From 929d4ba2eec7dfeeaefdfedca1e7e97e08e4c642 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:45:13 +0800 Subject: [PATCH 008/118] test(file): assert encoding guidance through host permissions --- test/permissions.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/permissions.test.mjs b/test/permissions.test.mjs index ef17c043..d96d0b32 100644 --- a/test/permissions.test.mjs +++ b/test/permissions.test.mjs @@ -324,7 +324,9 @@ await test('owned Modify-only workspace starts real DSH sandbox; three modes and assert.equal(placeholders.status,'completed',JSON.stringify(placeholders)) const badEncoding=await fileCall(ctx,worker,'inspect_local_file',{path:path.join(outside,'keep.txt'),encoding:'gbk'},exec,exec.signal) assert.notEqual(badEncoding.status,'completed',JSON.stringify(badEncoding)) - assert.equal(badEncoding.error.code,'INVALID_ARGUMENT',JSON.stringify(badEncoding)) + assert.equal(badEncoding.error.code,'ERR_INVALID_ENCODING',JSON.stringify(badEncoding)) + assert.equal(badEncoding.error.effectState,'none') + assert.match(badEncoding.error.nextAction,/document\/binary.*view=hex/) const replaced=await fileCall(ctx,worker,'replace',{path:'data.txt',pattern:'before',replacement:'after'},exec,exec.signal) assert.equal(replaced.changed,1,JSON.stringify(replaced)) const batch=await ptcCall(ctx,worker,'batch',{root,action:'run',operations:[{op:'copy',path:'data.txt',to:'copy.txt'}]},exec,exec.signal) From 6a42c7340d89af7c8c449007252e0d8f88c0f107 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:43:27 +0800 Subject: [PATCH 009/118] feat(search): [5a/16] report coverage and validate continuations --- docs/CHANGELOG.md | 8 + docs/search-coverage.md | 59 +++++ ...06\347\233\226\350\214\203\345\233\264.md" | 51 ++++ native/crates/ops-protocol/src/lib.rs | 1 + native/crates/ops-protocol/src/search.rs | 41 +++ native/crates/ops-search/src/lib.rs | 238 ++---------------- native/crates/ops-search/src/paging.rs | 96 +++++++ native/crates/ops-search/src/scan.rs | 178 +++++++++++++ native/crates/ops-search/src/search.rs | 119 +++++++++ test/grep-pagination.test.mjs | 11 +- test/search-coverage.test.mjs | 146 +++++++++++ 11 files changed, 731 insertions(+), 217 deletions(-) create mode 100644 docs/search-coverage.md create mode 100644 "docs/\346\220\234\347\264\242\350\246\206\347\233\226\350\214\203\345\233\264.md" create mode 100644 native/crates/ops-protocol/src/search.rs create mode 100644 native/crates/ops-search/src/paging.rs create mode 100644 native/crates/ops-search/src/scan.rs create mode 100644 native/crates/ops-search/src/search.rs create mode 100644 test/search-coverage.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 233b9230..3d99f8df 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -26,6 +26,14 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add typed Outcome, search coverage and result-reference contracts plus explicit error/retry policies for subsequent adapter slices. Incomplete searches cannot claim a known total. - Pin Node 22 declarations and check valid/invalid contracts in the regular test runner. + +### Report search coverage and validate continuation state (#261) + +- Search reports distinguish scan coverage from page delivery, count skip reasons, and omit full + totals after unreadable, encoding, size or execution-budget gaps. Bounded rescan continuations + reject changed queries, authority and observed filesystem state; incomplete traversals require + a narrower fresh query. Search retains physical LF accounting and bounded output prefixes. + ### Version the one-shot worker request protocol (#261) - Send strict v1 requests with per-call IDs, component provenance, and enforced deadline/output diff --git a/docs/search-coverage.md b/docs/search-coverage.md new file mode 100644 index 00000000..d4f17317 --- /dev/null +++ b/docs/search-coverage.md @@ -0,0 +1,59 @@ +# Search coverage and continuation + +中文版:[搜索覆盖范围.md](搜索覆盖范围.md). + +`glob` and `grep` return an additive `SearchExecutionReport` alongside their existing items. +A settled legacy `status: completed` means the worker returned a result; callers must still +inspect `scanComplete`, `pageComplete` and `complete` before treating it as exhaustive. + +| Field | Meaning | +| --- | --- | +| `scanComplete` | Every eligible candidate was observed without a read/encoding/size gap or execution stop. | +| `pageComplete` | The requested page of observed items was fully rendered; independent of whether later pages exist. | +| `scannedFiles` | Allowed, non-hidden regular file candidates considered, including declared glob-filter exclusions. | +| `skippedFiles` | Known regular files excluded or unreadable; never an estimate of files below a skipped directory. | +| `skipReasons` | Counts of excluded files/directory entries, or observed gaps. A skipped directory counts once. | +| `matchedObserved` | Matches observed so far: matching files for glob, matching lines/spans for grep. | +| `matchedTotal` | Present only when `scanComplete` is true; the exact full-scan match count. | +| `truncatedBy` | `scan-budget`, `time`, `output-budget`, `row-limit`, or `none`. A non-budget read/encoding gap can use `none`. | +| `continuation` | Opaque cursor for another page of a fully observed, unchanged query. | + +`grep` count/files modes page matching files while `matchedTotal` counts matches; the legacy +`total` counts page items. `total` and `omitted` are absent when the scan is incomplete. Existing +`matched`, `matches` and `files` are observed counts, never a replacement for the coverage flags. +An explicit `limit` can produce `scanComplete: true`, `pageComplete: true`, +`truncatedBy: row-limit` and a continuation. Legacy `complete` becomes true only on the final +fully displayed page of a complete scan. + +Declared pattern exclusions, hidden paths, excluded dependency/build directories, credential +locations and no-follow links stay outside the query scope. Their skip counts do not make an +otherwise complete query incomplete. Unreadable entries/directories/files, invalid path encoding, +files over 32 MiB, and non-UTF-8 content are coverage gaps. These do make `scanComplete` false, +remove full totals/cursors, and return `nextAction` advising a narrower query or repair of the gap. +Credential paths and content are never exposed through the report. + +Search considers at most 10,000 regular files, 100,000 directory entries, depth 128, and 256 MiB +of read bytes per call. Its cooperative deadline is 30 seconds, checked between directory entries, +64 KiB read chunks and matches; existing host cancellation and the worker process deadline remain +in force. Directory sorting and glob candidates are bounded; grep streams one bounded file and +materializes only its output page. Later shared-budget work will centralize these limits. + +Pass a returned `continuation` with the same query options and omit `offset` (or pass the cursor's +`nextOffset`). The token binds the tool, normalized host-resolved path arguments, session, current +authority fields, workspace identity and observed search state. Candidate order is deterministic; +modified-time sorting retains that order for ties. Grep fingerprints include file contents, so +restoring a file's size and timestamp does not conceal changed bytes. Glob fingerprints bind its +observed paths and metadata. A changed query, authority, token checksum or observation returns +`SEARCH_STALE` with no effects. Discard that token and start a fresh query. Every call rechecks +host authority and no-follow boundaries; a cursor or its checksum grants no permission. + +This implementation rescans the bounded query and validates its fingerprint before returning a +later page. It does not freeze a filesystem snapshot or resume beyond a scan/time/gap stop. +Incomplete traversals return explicit narrow-query guidance without a cursor. Stored snapshots +and reference-backed resumption belong to the later result-reference slice. + +An oversized matching line may return a bounded prefix marked `truncatedInLine`, `bytesTotal` +and `bytesOmitted`. That makes `pageComplete` false. No continuation skips the missing suffix; +`nextOffset` stays at the incomplete item, with guidance to inspect that file using text/hex +ranges. Whole omitted rows can continue normally after a complete scan. Output is bounded by +serialized JSON bytes, not source-character counts, and search creates no audit/reference store. diff --git "a/docs/\346\220\234\347\264\242\350\246\206\347\233\226\350\214\203\345\233\264.md" "b/docs/\346\220\234\347\264\242\350\246\206\347\233\226\350\214\203\345\233\264.md" new file mode 100644 index 00000000..750075e5 --- /dev/null +++ "b/docs/\346\220\234\347\264\242\350\246\206\347\233\226\350\214\203\345\233\264.md" @@ -0,0 +1,51 @@ +# 搜索覆盖范围与续页 + +English: [search-coverage.md](search-coverage.md). + +`glob` 和 `grep` 在原有条目之外返回 `SearchExecutionReport`。旧版信封中的 +`status: completed` 只表示工作进程已返回结果;判断结果是否完整时,仍须检查 +`scanComplete`、`pageComplete` 和 `complete`。 + +| 字段 | 含义 | +| --- | --- | +| `scanComplete` | 所有查询范围内的候选项均已观察,且没有读取、编码、大小缺口或执行早停。 | +| `pageComplete` | 本次请求页中的已观察条目均已完整输出,与是否还有后续页相互独立。 | +| `scannedFiles` | 已检查且权限允许、非隐藏的普通文件候选数,包括被显式 glob 条件排除的文件。 | +| `skippedFiles` | 已知被排除或无法读取的普通文件数,不估算被跳过目录内部的文件数。 | +| `skipReasons` | 排除的文件/目录项或观察缺口的分类计数;跳过一个目录只计一次。 | +| `matchedObserved` | 当前已观察的匹配数:glob 为文件数,grep 为匹配行/跨度数。 | +| `matchedTotal` | 仅在 `scanComplete` 为 true 时出现,表示完整扫描的精确匹配总数。 | +| `truncatedBy` | `scan-budget`、`time`、`output-budget`、`row-limit` 或 `none`;非预算类读取/编码缺口可以为 `none`。 | +| `continuation` | 仅用于完整观察且状态未变查询的下一页不透明游标。 | + +`grep` 的 count/files 模式按匹配文件分页,但 `matchedTotal` 仍表示匹配数;旧字段 +`total` 表示页条目总数。扫描不完整时,不返回 `total` 和 `omitted`。原有 `matched`、 +`matches` 和 `files` 均为已观察计数,不能替代覆盖标记。显式 `limit` 可以同时产生 +`scanComplete: true`、`pageComplete: true`、`truncatedBy: row-limit` 和续页游标。 +旧字段 `complete` 仅在完整扫描的最后一页也完整展示时为 true。 + +显式模式排除、隐藏路径、依赖/构建目录、凭据位置和禁止跟随的链接均不属于查询范围; +这些跳过计数不会单独使完整查询变为不完整。无法读取的目录项/目录/文件、非法路径编码、 +超过 32 MiB 的文件及非 UTF-8 内容属于覆盖缺口:`scanComplete` 为 false,不返回总数或 +游标,并通过 `nextAction` 建议缩小查询或修复缺口。报告不会泄露凭据路径或内容。 + +每次搜索最多检查 10,000 个普通文件、100,000 个目录项、128 层目录,累计读取 256 MiB。 +协作式截止时间为 30 秒,在目录项、64 KiB 读取块及匹配之间检查;宿主取消机制和进程级 +截止时间仍然有效。目录排序和 glob 候选表有界;grep 每次处理一个有界文件,只构造请求页。 +后续共享预算工作将集中管理这些限制。 + +续页时保留相同查询选项并传入 `continuation`,省略 `offset`,或传入游标给出的 +`nextOffset`。游标绑定工具、宿主解析后的路径参数、会话、当前权限字段、工作区身份和观察 +到的搜索状态。候选顺序确定;按修改时间排序时,同时间项保持该顺序。grep 指纹包含文件 +内容,因此还原大小和时间戳无法隐藏字节变更;glob 指纹绑定观察到的路径和元数据。 +查询、权限、游标校验值或观察状态变化会返回无副作用的 `SEARCH_STALE`,须丢弃游标并重新 +查询。每次调用仍执行宿主权限及 no-follow 检查;游标和校验值都不授予权限。 + +当前实现通过重新扫描有界查询并校验指纹来续页,不冻结文件系统快照,也不支持越过扫描、 +时间或覆盖缺口继续扫描。不完整遍历明确建议缩小查询且不提供游标。保存快照及通过结果 +引用续取属于后续结果引用切片。 + +过大的匹配行可以返回带有 `truncatedInLine`、`bytesTotal` 和 `bytesOmitted` 的有界前缀, +此时 `pageComplete` 为 false。不会提供跳过缺失后缀的游标;`nextOffset` 停留在未完整条目, +并建议使用文本/十六进制范围读取该文件。完整扫描后被整行省略的条目可以正常续页。 +输出按序列化 JSON 字节限制,而非字符数;搜索不创建审计或引用存储。 diff --git a/native/crates/ops-protocol/src/lib.rs b/native/crates/ops-protocol/src/lib.rs index ca38fca1..a3e370c4 100644 --- a/native/crates/ops-protocol/src/lib.rs +++ b/native/crates/ops-protocol/src/lib.rs @@ -1,5 +1,6 @@ #![forbid(unsafe_code)] pub mod envelope; +pub mod search; use serde::{Deserialize, Serialize}; use serde_json::Value; diff --git a/native/crates/ops-protocol/src/search.rs b/native/crates/ops-protocol/src/search.rs new file mode 100644 index 00000000..060e9957 --- /dev/null +++ b/native/crates/ops-protocol/src/search.rs @@ -0,0 +1,41 @@ +//! Search reports never attach a full total to an incomplete traversal. +use serde::{Serialize, Serializer, ser::SerializeMap}; +use std::collections::BTreeMap; + +pub enum SearchCoverage { Complete { matched_total: usize }, Incomplete } +impl Serialize for SearchCoverage { + fn serialize(&self, serializer: S) -> std::result::Result { + let mut map = serializer.serialize_map(None)?; + map.serialize_entry("scanComplete", &matches!(self, Self::Complete { .. }))?; + if let Self::Complete { matched_total } = self { map.serialize_entry("matchedTotal", matched_total)?; } + map.end() + } +} +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum TruncatedBy { ScanBudget, OutputBudget, Time, RowLimit, None } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchExecutionReport { + #[serde(flatten)] + pub coverage: SearchCoverage, + pub page_complete: bool, + pub scanned_files: usize, + pub skipped_files: usize, + pub skip_reasons: BTreeMap, + pub matched_observed: usize, + pub truncated_by: TruncatedBy, + #[serde(skip_serializing_if = "Option::is_none")] + pub continuation: Option, +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn incomplete_coverage_cannot_serialize_a_full_total() { + let incomplete = serde_json::to_value(SearchCoverage::Incomplete).unwrap(); + assert_eq!(incomplete["scanComplete"], false); assert!(incomplete.get("matchedTotal").is_none()); + let complete = serde_json::to_value(SearchCoverage::Complete { matched_total: 7 }).unwrap(); + assert_eq!(complete["scanComplete"], true); assert_eq!(complete["matchedTotal"], 7); + } +} diff --git a/native/crates/ops-search/src/lib.rs b/native/crates/ops-search/src/lib.rs index d43f3055..5b77a0b4 100644 --- a/native/crates/ops-search/src/lib.rs +++ b/native/crates/ops-search/src/lib.rs @@ -1,5 +1,8 @@ //! File search orchestration over the shared no-follow file primitives. #![forbid(unsafe_code)] +mod scan; +mod paging; +mod search; use ops_protocol::{Failure, Request, Result}; use ops_files::{file_path, target, relative_display, read_bytes, result, lock_and_read_for_update, atomic_write, MAX_OUTPUT_BYTES}; use ops_files as publication; @@ -19,51 +22,13 @@ fn yes() -> bool { true } pub fn dispatch(request: &Request) -> Result { if let Some(raw) = request.args.get("path").and_then(Value::as_str) { request.authorize_path(Path::new(raw))?; } match request.tool.as_str() { - "glob" => glob(request), - "grep" => grep(request), + "glob" => search::glob(request), + "grep" => search::grep(request), "replace" => replace(request), _ => Err(Failure::new("INVALID_ARGUMENT", "Unknown search capability")), }.map_err(|mut error| { error.message = format!("{}: {}", request.tool, error.message); error }) } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct GlobArgs { - path: String, - pattern: Vec, - #[serde(default)] - output_mode: String, - #[serde(default)] - sort: String, - #[serde(default)] - offset: usize, - #[serde(default = "one_hundred")] - limit: usize, -} -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct GrepArgs { - path: String, - pattern: String, - #[serde(default)] - glob: Vec, - #[serde(default)] - output_mode: String, - #[serde(default)] - case_insensitive: bool, - #[serde(default = "yes")] - line_numbers: bool, - #[serde(default)] - before_context: usize, - #[serde(default)] - after_context: usize, - #[serde(default)] - multiline: bool, - #[serde(default)] - offset: usize, - #[serde(default = "one_hundred")] - limit: usize, -} #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct ReplaceArgs { @@ -191,149 +156,6 @@ fn segment_match(pattern: &str, value: &str) -> bool { while pi < p.len() && p[pi] == b'*' { pi += 1; } pi == p.len() } -/// Modification time of `metadata` in whole milliseconds since the Unix epoch, -/// or `None` when the platform will not report one. -/// -/// This is the file tools' one definition of a timestamp quantity: `ops_stat`'s -/// `modifiedMs` and `ops_glob`'s `modified` describe the same instant the same -/// way, so the two cannot drift into different units. Milliseconds since the -/// epoch carry no timezone, and they are what `stat -c %Y` reports as whole -/// seconds — the same instant, at the resolution a single-second field loses. -fn modified_ms(metadata: &fs::Metadata) -> Option { - metadata.modified().ok() - .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|time| time.as_millis()) -} -/// Last modification time of one path, in milliseconds since the Unix epoch. -/// -/// `symlink_metadata`, not `metadata`: this module never follows links, and a -/// link, junction or reparse entry is refused before it can become a candidate -/// (`host_link_or_reparse`, `file_path::link`), so no `ops_glob` item is a link -/// and "the link or its target" cannot arise in a result. Probing the entry -/// itself keeps the timestamp probe consistent with every other path probe -/// here; a caller that needs the target of a link asks `ops_stat`, which opens -/// the file and reports the opened inode's time. -/// -/// This used to return `symlink_metadata(..).len()` — the byte count — so -/// `details.modified` was a second copy of `details.bytes` and -/// `sort: "modified"` ordered by file size and never by time (#236, reproduced -/// in #204 P2). `None`, not `0`, is the honest answer for a time the -/// filesystem cannot report or one that predates the epoch; `ops_stat` reports -/// the same `null` there, so both tools answer "unknown" the same way. -fn modified(path: &Path) -> Option { fs::symlink_metadata(path).ok().as_ref().and_then(modified_ms) } -/// Order candidates for `sort: "modified"`: newest first, an unreadable time -/// last, equal times in the walk's path order. -/// -/// `Reverse` keeps the direction the size-based key already produced -/// (descending), so this fix changes the key, not the ordering contract. The -/// sort is stable (`sort_by_key`), so equal timestamps — a coarse clock, a copy -/// that preserves a timestamp, two writes inside one second — keep the path -/// order the walk produced instead of reshuffling between identical calls. -/// `Reverse(None)` sorts last because `None` is below every `Some`. -fn sort_by_modified(files: &mut [PathBuf]) { files.sort_by_key(|file| std::cmp::Reverse(modified(file))); } -/// One `details` item: the path, its on-disk size, and its modification time as -/// an epoch-millisecond number (`null` when unreadable). The size is a size and -/// the time is a time; neither stands in for the other. -fn details(file: &Path) -> Value { - json!({"path":file,"bytes":fs::symlink_metadata(file).map(|metadata| metadata.len()).unwrap_or(0),"modified":modified(file)}) -} -fn glob(request: &Request) -> Result { - let args: GlobArgs = serde_json::from_value(request.args.clone())?; - if args.limit == 0 || args.limit > 10_000 { return Err(Failure::new("INVALID_ARGUMENT", "glob limit is 1-10000")); } - if args.pattern.is_empty() || args.pattern.iter().all(|p| p.starts_with('!')) { return Err(Failure::new("INVALID_ARGUMENT", "pattern requires a positive glob")); } - let root = target(&args.path, false)?; if !root.is_dir() { return Err(Failure::new("INVALID_ARGUMENT", "glob path must be a directory")); } - let walked = walk(&root, request)?; - let mut files: Vec<_> = walked.files.into_iter().filter(|f| matches_any(&root, f, &args.pattern)).collect(); - // `sort` is optional: empty keeps the walk's path order, which is what `path` - // asks for explicitly. The whole candidate list is ordered before paging, so a - // page boundary always falls in the same sequence a full listing would produce. - if args.sort == "modified" { sort_by_modified(&mut files); } else if !args.sort.is_empty() && args.sort != "path" { return Err(Failure::new("INVALID_ARGUMENT", "sort must be path or modified")); } - let output_mode = if args.output_mode.is_empty() { "paths" } else { args.output_mode.as_str() }; - if output_mode != "paths" && output_mode != "details" { return Err(Failure::new("INVALID_ARGUMENT", "outputMode must be paths or details")); } - let total = files.len(); let selected: Vec<_> = files.into_iter().skip(args.offset).take(args.limit).map(|f| if output_mode == "paths" { json!(f) } else { details(&f) }).collect(); - Ok(result(selected, total, args.offset, args.offset, total, walked.truncated)) -} -fn grep(request: &Request) -> Result { - let args: GrepArgs = serde_json::from_value(request.args.clone())?; - if args.pattern.is_empty() || args.limit == 0 || args.limit > 10_000 { return Err(Failure::new("INVALID_ARGUMENT", "pattern and bounded limit are required")); } - if args.before_context > 32 || args.after_context > 32 { return Err(Failure::new("INVALID_ARGUMENT", "context is limited to 32 lines")); } - let regex = RegexBuilder::new(&args.pattern).case_insensitive(args.case_insensitive).multi_line(args.multiline).build().map_err(|e| Failure::new("INVALID_ARGUMENT", e))?; - if !matches!(args.output_mode.as_str(), "" | "content" | "files_with_matches" | "count" | "summary") { return Err(Failure::new("INVALID_ARGUMENT", "invalid grep outputMode")); } - let walked = files_for(&args.path, &args.glob, request)?; let mut matches = Vec::new(); let mut file_count = 0; let mut match_count = 0; - let mut failed = 0usize; let mut skipped_files = 0usize; - // Which modes answer with an item per match (`content`) and which with an item per file. Declared - // once here because both the per-file loop and the envelope's `total` have to agree on it. - let content_mode = !matches!(args.output_mode.as_str(), "files_with_matches" | "count" | "summary"); - let page = args.offset.saturating_add(args.limit); - for file in walked.files { - let bytes = match read_bytes(&file) { - Ok(bytes) => bytes, - Err(error) if error.code == "OUTPUT_LIMIT" => { skipped_files += 1; continue; } - Err(_) => { failed += 1; continue; } - }; - let text = match String::from_utf8(bytes) { Ok(v) => v, Err(_) => { skipped_files += 1; continue; } }; let lines: Vec<_> = text.lines().collect(); - // Line by line by default. With `multiline` the pattern is matched against - // the whole file, so a pattern containing `\n` can match across lines - // instead of never matching at all — which is what the flag's name promises - // and what it did not do. `^` and `$` still resolve per line, because the - // builder keeps `multi_line` on in both modes. - // - // Each hit is reduced to the span of lines it covers, so one reporting path - // serves both modes: a cross-line match is reported at the line where it - // starts, with its context lines around it. - // Matches are counted in one pass and materialized in a second, page-bounded one. Counting a - // match costs no allocation, while joining its lines into a `text` item costs the lines - // themselves — and the previous shape built one item per match in the file *before* `limit` - // was consulted, so a 32 KiB line matched by `pattern: "a"` copied that line 32 768 times and - // the worker died under a memory cap instead of returning its first item. Reported by - // @Haoming9527 in review of #81. - let mut hits = 0usize; - let mut spans: Vec<(usize, usize)> = Vec::new(); - if args.multiline { - let starts: Vec = text.match_indices('\n').map(|(at, _)| at + 1).collect(); - // Clamped, so a match can never be attributed to a line the file does - // not have: the offset-to-line map is only meaningful for offsets inside - // the text. - let line_of = |offset: usize| starts.partition_point(|start| *start <= offset).min(lines.len().saturating_sub(1)); - for found in regex.find_iter(&text) { - // Matching the whole file at once adds one zero-width match at the - // position after a final newline — the one place `$` and `^` already - // matched at the end of the last real line. Reporting it invented - // line N+1 with empty text, so `$` answered six times for a - // five-line file. A file with no trailing newline keeps its last - // match, because there the end of the file is the end of a line. - if found.start() >= text.len() && text.ends_with('\n') { continue; } - hits += 1; - // Only what the page can still hold is materialized, so the peak is the page and not - // the file's match count. `hits` above stays exact: `matched` reports every match. - let ordinal = match_count + hits - 1; - if content_mode && ordinal >= args.offset && ordinal < page { spans.push((line_of(found.start()), line_of(found.end().saturating_sub(1).max(found.start())))); } - } - } else { - for (index, line) in lines.iter().enumerate() { - if !regex.is_match(line) { continue; } - hits += 1; - let ordinal = match_count + hits - 1; - if content_mode && ordinal >= args.offset && ordinal < page { spans.push((index, index)); } - } - } - if hits == 0 { continue; } file_count += 1; match_count += hits; - let in_page = file_count > args.offset && file_count <= page; - match args.output_mode.as_str() { "files_with_matches" if in_page => matches.push(json!({"path":file})), "count" if in_page => matches.push(json!({"path":file,"count":hits})), "files_with_matches" | "count" | "summary" => {} , _ => { for (first, last) in spans { let start=first.saturating_sub(args.before_context); let end=(last+args.after_context+1).min(lines.len()); matches.push(json!({"path":file,"line":if args.line_numbers {first+1} else {0},"text":lines[start..end].join("\n")})); } } } - // Keep counting through every candidate; only materialize the requested - // window. A page-full prefix must never masquerade as the global total. - } - let incomplete = walked.truncated || failed > 0; - if args.output_mode == "summary" { return Ok(json!({"status":"completed","phase":"verify","changed":0,"matched":match_count,"skipped":0,"failed":failed,"skippedFiles":skipped_files,"complete":!incomplete,"truncated":incomplete,"omitted":0,"files":file_count,"matches":match_count})); } - // `total` is the number of items a full materialization would have produced: one per match in the - // content modes, one per file otherwise. It is the match count in the first case because the page - // bound above only limits what was *built*, never what was counted. - let total = if content_mode { match_count } else { file_count }; - let mut output = result(matches, total, args.offset, args.offset, match_count, incomplete); - output["failed"] = json!(failed); - output["skippedFiles"] = json!(skipped_files); - Ok(output) -} fn replace(request: &Request) -> Result { if request.read_only { return Err(Failure::policy("read-only", "replace is unavailable in read-only mode")); } let args: ReplaceArgs = serde_json::from_value(request.args.clone())?; @@ -422,9 +244,7 @@ mod tests { fn sidecar(&self, name: &str) -> PathBuf { self.root.join(format!(".dsh-ops-update-{:x}.lock", Sha256::digest(name.to_lowercase().as_bytes()))) } - fn names(files: &[PathBuf]) -> Vec { - files.iter().map(|file| file.file_name().unwrap().to_string_lossy().into_owned()).collect() - } + } impl Drop for Fixture { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.root); } @@ -434,41 +254,33 @@ mod tests { /// `modified == bytes` and `sort: "modified"` ordered by size. The fixtures /// are inverted on purpose — the larger file is the older one — so a /// size-based key reverses them. + fn listing(fixture: &Fixture, offset: usize, limit: usize) -> Value { + let request = serde_json::from_value(json!({"root":fixture.root,"scope":"mtime-test","tool":"glob", + "args":{"path":fixture.root,"pattern":["*.txt"],"sort":"modified","outputMode":"details","offset":offset,"limit":limit}})).unwrap(); + dispatch(&request).unwrap() + } #[test] fn modified_is_epoch_milliseconds_and_ordering_ignores_size() { let fixture = Fixture::new("glob-modified"); - let old_big = fixture.file("a-old-big.txt", &[b'x'; 8192], 1_600_000_000_000); - let new_small = fixture.file("z-new-small.txt", b"tiny", 1_600_000_120_000); - let (old, new) = (modified(&old_big).expect("mtime"), modified(&new_small).expect("mtime")); - assert!(old > 1_000_000_000_000, "modified must be epoch milliseconds, got {old}"); - assert_ne!(old, 8192, "modified must not be the byte count"); - // A filesystem may keep coarser than a millisecond; one second of slack - // covers that without accepting a different quantity or unit. - for (path, expected) in [(&old_big, 1_600_000_000_000_i128), (&new_small, 1_600_000_120_000_i128)] { - let actual = modified(path).unwrap() as i128; - assert!((actual - expected).abs() <= 1_000, "{}: expected {expected} ms ±1s, got {actual}", path.display()); + fixture.file("a-old-big.txt", &[b'x'; 8192], 1_600_000_000_000); + fixture.file("z-new-small.txt", b"tiny", 1_600_000_120_000); + let output = listing(&fixture, 0, 10); + let items = output["items"].as_array().unwrap(); + assert!(items[0]["path"].as_str().unwrap().ends_with("z-new-small.txt")); + assert_eq!(items[0]["bytes"], 4); assert_eq!(items[1]["bytes"], 8192); + for (item, expected) in items.iter().zip([1_600_000_120_000_i128, 1_600_000_000_000_i128]) { + let actual = item["modified"].as_u64().unwrap() as i128; + assert!((actual - expected).abs() <= 1_000, "expected epoch milliseconds, got {actual}"); } - assert!(new > old, "the newer file must carry the larger timestamp"); - assert_eq!(modified(&fixture.root.join("absent.txt")), None, "an unreadable time is null, not zero"); - assert_eq!(details(&old_big)["bytes"], json!(8192)); - - let mut files = vec![new_small, old_big]; - sort_by_modified(&mut files); - assert_eq!(Fixture::names(&files), ["z-new-small.txt", "a-old-big.txt"], "newest first, independent of size"); } - - /// Equal timestamps must not reshuffle, so two identical calls page the same - /// way: the sort is stable and the walk's path order decides the tie. #[test] fn equal_timestamps_keep_the_candidate_order() { let fixture = Fixture::new("glob-modified-tie"); - let first = fixture.file("a.txt", b"a", 1_600_000_000_000); - let second = fixture.file("b.txt", b"bb", 1_600_000_000_000); - let third = fixture.file("c.txt", b"ccc", 1_600_000_000_000); - assert_eq!(modified(&first), modified(&second), "the fixture must produce equal timestamps"); - let mut files = vec![second, first, third]; - sort_by_modified(&mut files); - assert_eq!(Fixture::names(&files), ["b.txt", "a.txt", "c.txt"], "ties keep their relative order"); + for name in ["c.txt", "a.txt", "b.txt"] { fixture.file(name, name.as_bytes(), 1_600_000_000_000); } + for (offset, name) in ["a.txt", "b.txt", "c.txt"].iter().enumerate() { + let page = listing(&fixture, offset, 1); + assert!(page["items"][0]["path"].as_str().unwrap().ends_with(name)); + } } /// #240 P5, declared behavior: the update lock creates the persistent hidden diff --git a/native/crates/ops-search/src/paging.rs b/native/crates/ops-search/src/paging.rs new file mode 100644 index 00000000..acb0dfe5 --- /dev/null +++ b/native/crates/ops-search/src/paging.rs @@ -0,0 +1,96 @@ +//! Stateless cursors revalidate one bounded snapshot; they never grant authority. +use super::*; +use super::scan::Scan; +use cap_fs_ext::MetadataExt; +use ops_protocol::search::{SearchCoverage, SearchExecutionReport, TruncatedBy}; + +fn digest(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } +pub struct Cursor { pub offset: usize, binding: String, snapshot: Option } +impl Cursor { + pub fn new(request: &Request, offset: usize, token: Option<&str>) -> Result { + let mut query = request.args.clone(); + if let Some(fields) = query.as_object_mut() { fields.remove("offset"); fields.remove("continuation"); } + let root = file_path::checked(&request.root)?; + let identity = root.parent.symlink_metadata(&root.name)?; + let binding = digest(&serde_json::to_vec(&json!([request.tool, query, request.scope, request.root, + identity.dev(), identity.ino(), request.read_only, request.write_roots, request.deny_roots, request.deny_credential_names]))?); + let mut cursor = Self { offset, binding, snapshot: None }; + if let Some(token) = token { + let parts: Vec<_> = token.split(':').collect(); + let valid = token.len() <= 256 && parts.len() == 5 && parts[0] == "s1" + && parts[1] == cursor.binding && parts[2].len() == 64 + && parts[2].bytes().all(|byte| byte.is_ascii_hexdigit()) + && digest(parts[..4].join(":").as_bytes()) == parts[4]; + if !valid { return Err(stale()); } + let next = parts[3].parse::().map_err(|_| stale())?; + if offset != 0 && offset != next { return Err(stale()); } + cursor.offset = next; cursor.snapshot = Some(parts[2].into()); + } + Ok(cursor) + } + fn token(&self, snapshot: &str, next: usize) -> String { + let body = format!("s1:{}:{snapshot}:{next}", self.binding); + format!("{body}:{}", digest(body.as_bytes())) + } +} +fn stale() -> Failure { + let mut error = Failure::new("SEARCH_STALE", "Search continuation does not match the current query, authority or filesystem observation"); + error.next_action = "Discard the continuation and start a fresh bounded query; the cursor grants no authority.".into(); + error +} +pub struct Page { + pub items: Vec, pub offset: usize, limit: usize, bytes: usize, + pub output_cut: bool, partial_item: bool, +} +impl Page { + pub fn new(offset: usize, limit: usize) -> Self { + Self { items: Vec::new(), offset, limit, bytes: 0, output_cut: false, partial_item: false } + } + pub fn wants(&self, ordinal: usize) -> bool { + !self.output_cut && ordinal >= self.offset && ordinal < self.offset.saturating_add(self.limit) + } + pub fn push(&mut self, mut item: Value, ordinal: usize) -> Result<()> { + if !self.wants(ordinal) { return Ok(()); } + let remaining = MAX_OUTPUT_BYTES.saturating_sub(8192 + self.bytes); + let size = serde_json::to_vec(&item)?.len() + 1; + if size <= remaining { self.items.push(item); self.bytes += size; return Ok(()); } + self.output_cut = true; + // Preserve a useful first prefix without pretending the remaining line was delivered. + if let Some(text) = item.get("text").and_then(Value::as_str).map(str::to_owned) { + let length = text.len(); let mut end = length.min(remaining); + while end > 0 { + while !text.is_char_boundary(end) { end -= 1; } + item["text"] = json!(&text[..end]); item["truncatedInLine"] = json!(true); + item["bytesTotal"] = json!(length); item["bytesOmitted"] = json!(length - end); + if serde_json::to_vec(&item)?.len() + 1 <= remaining { + self.items.push(item); self.partial_item = true; break; + } + end /= 2; + } + } + Ok(()) + } +} +pub fn finish(mut scan: Scan, cursor: Cursor, page: Page, observed: usize, item_total: usize, extra: Value) -> Result { + scan.running(); // Account for time spent in the final read/match/output step too. + let snapshot = format!("{:x}", scan.hash.clone().finalize()); + let complete = scan.complete(); + if cursor.snapshot.as_ref().is_some_and(|expected| !complete || *expected != snapshot) { return Err(stale()); } + let next = page.offset.saturating_add(page.items.len()).saturating_sub(usize::from(page.partial_item)); + let more = next < item_total; + let continuation = (complete && more && !page.partial_item && next > page.offset).then(|| cursor.token(&snapshot, next)); + let truncated_by = scan.stopped.unwrap_or(if page.output_cut { TruncatedBy::OutputBudget } + else if more { TruncatedBy::RowLimit } else { TruncatedBy::None }); + let report = SearchExecutionReport { coverage: if complete { SearchCoverage::Complete { matched_total: observed } } else { SearchCoverage::Incomplete }, + page_complete: !page.output_cut, scanned_files: scan.scanned, skipped_files: scan.skipped, + skip_reasons: scan.reasons, matched_observed: observed, truncated_by, continuation }; + let mut output = json!({"status":"completed","phase":"verify","changed":0,"matched":observed, + "skipped":page.offset,"failed":scan.failed,"complete":complete && !more && !page.output_cut, + "truncated":!complete || more || page.output_cut,"items":page.items,"nextOffset":next}); + if complete { output["total"] = json!(item_total); output["omitted"] = json!(item_total.saturating_sub(next)); } + if !complete { output["nextAction"] = json!("Narrow path/glob or resolve the reported scan gaps, then start a fresh query. No stable continuation is available for an incomplete traversal."); } + else if page.partial_item { output["nextAction"] = json!("A matching line is only partially displayed. Inspect that file with bounded text/hex ranges; nextOffset does not skip the undisplayed suffix."); } + output.as_object_mut().unwrap().extend(serde_json::to_value(report)?.as_object().unwrap().clone()); + if let Some(extra) = extra.as_object() { output.as_object_mut().unwrap().extend(extra.clone()); } + Ok(output) +} diff --git a/native/crates/ops-search/src/scan.rs b/native/crates/ops-search/src/scan.rs new file mode 100644 index 00000000..bde0e9fb --- /dev/null +++ b/native/crates/ops-search/src/scan.rs @@ -0,0 +1,178 @@ +//! Bounded, no-follow traversal. Declared exclusions differ from unobserved gaps. +use super::*; +use cap_fs_ext::MetadataExt; +use ops_protocol::search::TruncatedBy; +use std::{collections::BTreeMap, io::Read, time::{Duration, Instant, UNIX_EPOCH}}; + +pub const MAX_SCAN_FILES: usize = 10_000; +pub const SCAN_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_SCAN_BYTES: usize = 256 * 1024 * 1024; +const MAX_FILE_BYTES: usize = 32 * 1024 * 1024; +const MAX_WALK_ENTRIES: usize = 100_000; + +pub struct Scan { + pub scanned: usize, + pub skipped: usize, + pub reasons: BTreeMap, + pub gap: bool, + pub failed: usize, + pub stopped: Option, + pub hash: Sha256, + started: Instant, + timeout: Duration, + max_files: usize, + entries: usize, + bytes: usize, +} +impl Scan { + pub fn new() -> Self { + Self { scanned: 0, skipped: 0, reasons: BTreeMap::new(), gap: false, failed: 0, stopped: None, + hash: Sha256::new(), started: Instant::now(), timeout: SCAN_TIMEOUT, + max_files: MAX_SCAN_FILES, entries: 0, bytes: 0 } + } + pub fn running(&mut self) -> bool { + if self.stopped.is_none() && self.started.elapsed() >= self.timeout { self.stopped = Some(TruncatedBy::Time); } + self.stopped.is_none() + } + pub fn skip(&mut self, reason: &str, file: bool, gap: bool) { + *self.reasons.entry(reason.into()).or_default() += 1; + self.skipped += usize::from(file); + self.gap |= gap; + if gap && !matches!(reason, "oversized" | "invalid-encoding") { self.failed += 1; } + } + pub fn complete(&self) -> bool { !self.gap && self.stopped.is_none() } + pub fn stamp(&mut self, path: &Path, metadata: &cap_std::fs::Metadata) { + let modified = metadata.modified().ok().and_then(|time| time.into_std().duration_since(UNIX_EPOCH).ok()).map(|time| time.as_nanos()); + self.hash.update(serde_json::to_vec(&json!([path, metadata.dev(), metadata.ino(), metadata.len(), modified, metadata.is_dir()])).unwrap()); + } + pub fn read(&mut self, path: &Path) -> Option> { + let read = (|| -> Result>> { + let target = file_path::checked(path.to_str().ok_or_else(|| Failure::new("INVALID_ARGUMENT", "Non-UTF8 path"))?)?; + let mut file = file_path::read_file(&target)?; + if file.metadata()?.len() > MAX_FILE_BYTES as u64 { self.skip("oversized", true, true); return Ok(None); } + let mut bytes = Vec::new(); let mut chunk = [0; 64 * 1024]; + loop { + if !self.running() { return Ok(None); } + let count = file.read(&mut chunk)?; + if count == 0 { break; } + self.bytes += count; + if self.bytes > MAX_SCAN_BYTES { self.stopped = Some(TruncatedBy::ScanBudget); return Ok(None); } + if bytes.len() + count > MAX_FILE_BYTES { self.skip("oversized", true, true); return Ok(None); } + bytes.extend_from_slice(&chunk[..count]); + } + self.hash.update(Sha256::digest(&bytes)); + Ok(Some(bytes)) + })(); + match read { Ok(value) => value, Err(_) => { self.skip("read-error", true, true); None } } + } + pub fn walk(&mut self, root: &Path, patterns: &[String], request: &Request, mut each: F) -> Result<()> + where F: FnMut(&Path, &cap_std::fs::Metadata, &mut Self) -> Result<()> { + request.authorize_path(root)?; + if !self.running() { return Ok(()); } + let target = file_path::checked(root.to_str().ok_or_else(|| Failure::new("INVALID_ARGUMENT", "Non-UTF8 path"))?)?; + let metadata = target.parent.symlink_metadata(&target.name)?; + self.stamp(root, &metadata); + if metadata.is_file() { self.scanned += 1; return each(root, &metadata, self); } + let directory = target.parent.open_dir_nofollow(&target.name)?; + self.visit(root, root, directory, 0, patterns, request, &mut each) + } + fn visit(&mut self, root: &Path, display: &Path, directory: cap_std::fs::Dir, depth: usize, patterns: &[String], request: &Request, each: &mut F) -> Result<()> + where F: FnMut(&Path, &cap_std::fs::Metadata, &mut Self) -> Result<()> { + if !self.running() { return Ok(()); } + if depth > MAX_DEPTH { self.stopped = Some(TruncatedBy::ScanBudget); return Ok(()); } + let entries = match directory.entries() { + Ok(entries) => entries, + Err(error) if depth == 0 => return Err(error.into()), + Err(_) => { self.skip("unreadable-directory", false, true); return Ok(()); } + }; + let mut names = Vec::new(); + for entry in entries { + if !self.running() { break; } + self.entries += 1; + if self.entries > MAX_WALK_ENTRIES { self.stopped = Some(TruncatedBy::ScanBudget); break; } + let entry = match entry { Ok(entry) => entry, Err(_) => { self.skip("unreadable-entry", false, true); continue; } }; + match entry.file_name().into_string() { Ok(name) => names.push(name), Err(_) => self.skip("non-utf8-path", false, true) } + } + names.sort(); + for name in names { + if !self.running() { break; } + let child = display.join(&name); + if file_path::validate_name(&name).is_err() { self.skip("invalid-path", false, true); continue; } + let metadata = match directory.symlink_metadata(&name) { + Ok(metadata) => metadata, Err(_) => { self.skip("unreadable-entry", false, true); continue; } + }; + let file = metadata.is_file(); + if request.denies_path(&child) { self.skip("credential", file, false); continue; } + let host = match fs::symlink_metadata(&child) { + Ok(metadata) => metadata, Err(_) => { self.skip("unreadable-entry", file, true); continue; } + }; + #[cfg(windows)] + let reparse = host.file_attributes() & 0x400 != 0; + #[cfg(not(windows))] + let reparse = false; + if file_path::link(&metadata) || host.file_type().is_symlink() || reparse { self.skip("symlink", file, false); continue; } + if hidden(&child, root) || matches!(name.as_str(), "node_modules" | "target" | ".venv") { self.skip("excluded", file, false); continue; } + if file { + if self.scanned >= self.max_files { self.stopped = Some(TruncatedBy::ScanBudget); break; } + self.scanned += 1; + if !matches_any(root, &child, patterns) { self.skip("pattern", true, false); continue; } + self.stamp(&child, &metadata); + each(&child, &metadata, self)?; + } else if metadata.is_dir() { + self.stamp(&child, &metadata); + match directory.open_dir_nofollow(&name) { + Ok(next) => self.visit(root, &child, next, depth + 1, patterns, request, each)?, + Err(_) => self.skip("unreadable-directory", false, true), + } + } else { self.skip("unsupported-entry", false, false); } + } + Ok(()) + } +} +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> (PathBuf, Request) { + let root = fs::canonicalize(std::env::temp_dir()).unwrap().join(format!("dsh-search-budget-{}", uuid::Uuid::new_v4())); + fs::create_dir(&root).unwrap(); + for name in ["a", "b", "c"] { fs::write(root.join(name), "hit\n").unwrap(); } + let request = serde_json::from_value(json!({"root":root,"scope":"scan-test","tool":"grep","args":{}})).unwrap(); + (root, request) + } + #[test] + fn file_entry_time_and_io_limits_never_claim_a_complete_scan() { + let (root, request) = fixture(); + let mut scan = Scan::new(); scan.max_files = 2; + let mut paths = Vec::new(); + scan.walk(&root, &[], &request, |path, _, _| { paths.push(path.file_name().unwrap().to_owned()); Ok(()) }).unwrap(); + assert_eq!(paths.len(), 2); assert_eq!(scan.scanned, 2); + assert!(!scan.complete()); assert_eq!(scan.stopped, Some(TruncatedBy::ScanBudget)); + let report = super::super::paging::finish(scan, super::super::paging::Cursor::new(&request, 0, None).unwrap(), + super::super::paging::Page::new(0, 1), paths.len(), paths.len(), json!({})).unwrap(); + assert_eq!(report["scanComplete"], false); assert!(report.get("matchedTotal").is_none()); + assert_eq!(report["truncatedBy"], "scan-budget"); assert!(report.get("continuation").is_none()); + assert!(report["nextAction"].as_str().unwrap().contains("Narrow")); + let mut scan = Scan::new(); scan.entries = MAX_WALK_ENTRIES; + scan.walk(&root, &[], &request, |_, _, _| panic!("entry limit must stop before file processing")).unwrap(); + assert!(!scan.complete()); assert_eq!(scan.stopped, Some(TruncatedBy::ScanBudget)); + let mut scan = Scan::new(); scan.timeout = Duration::ZERO; + scan.walk(&root, &[], &request, |_, _, _| panic!("expired scan must not run")).unwrap(); + assert!(!scan.complete()); assert_eq!(scan.stopped, Some(TruncatedBy::Time)); + let mut scan = Scan::new(); scan.bytes = MAX_SCAN_BYTES; + assert!(scan.read(&root.join("a")).is_none()); + assert!(!scan.complete()); assert_eq!(scan.stopped, Some(TruncatedBy::ScanBudget)); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn exclusions_are_counted_without_turning_them_into_unreadable_gaps() { + let (root, request) = fixture(); + fs::write(root.join(".hidden"), "hit").unwrap(); + let mut scan = Scan::new(); + scan.walk(&root, &["a".into()], &request, |_, _, _| Ok(())).unwrap(); + assert!(scan.complete()); assert_eq!(scan.scanned, 3); assert_eq!(scan.skipped, 3); + assert_eq!(scan.reasons["pattern"], 2); assert_eq!(scan.reasons["excluded"], 1); + assert!(scan.read(&root.join("missing")).is_none()); + assert!(!scan.complete()); assert_eq!(scan.reasons["read-error"], 1); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/native/crates/ops-search/src/search.rs b/native/crates/ops-search/src/search.rs new file mode 100644 index 00000000..5e6a0610 --- /dev/null +++ b/native/crates/ops-search/src/search.rs @@ -0,0 +1,119 @@ +//! Search execution streams bounded files and keeps only the requested output page. +use super::*; +use super::{scan::Scan, paging::{Cursor, Page, finish}}; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct GlobArgs { + path: String, pattern: Vec, + #[serde(default)] output_mode: String, + #[serde(default)] sort: String, + #[serde(default)] offset: usize, + #[serde(default = "one_hundred")] limit: usize, + continuation: Option, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct GrepArgs { + path: String, pattern: String, + #[serde(default)] glob: Vec, + #[serde(default)] output_mode: String, + #[serde(default)] case_insensitive: bool, + #[serde(default = "yes")] line_numbers: bool, + #[serde(default)] before_context: usize, + #[serde(default)] after_context: usize, + #[serde(default)] multiline: bool, + #[serde(default)] offset: usize, + #[serde(default = "one_hundred")] limit: usize, + continuation: Option, +} + +pub fn glob(request: &Request) -> Result { + let args: GlobArgs = serde_json::from_value(request.args.clone())?; + if args.limit == 0 || args.limit > 10_000 { return Err(Failure::new("INVALID_ARGUMENT", "glob limit is 1-10000")); } + if args.pattern.is_empty() || args.pattern.iter().all(|pattern| pattern.starts_with('!')) { return Err(Failure::new("INVALID_ARGUMENT", "pattern requires a positive glob")); } + if !matches!(args.sort.as_str(), "" | "path" | "modified") { return Err(Failure::new("INVALID_ARGUMENT", "sort must be path or modified")); } + if !matches!(args.output_mode.as_str(), "" | "paths" | "details") { return Err(Failure::new("INVALID_ARGUMENT", "outputMode must be paths or details")); } + let root = target(&args.path, false)?; + if !root.is_dir() { return Err(Failure::new("INVALID_ARGUMENT", "glob path must be a directory")); } + let cursor = Cursor::new(request, args.offset, args.continuation.as_deref())?; + let mut scan = Scan::new(); let mut files = Vec::new(); + scan.walk(&root, &args.pattern, request, |file, metadata, _| { + let modified = metadata.modified().ok().and_then(|time| time.into_std().duration_since(std::time::UNIX_EPOCH).ok()).map(|time| time.as_millis()); + files.push((file.to_path_buf(), modified, metadata.len())); Ok(()) + })?; + if args.sort == "modified" { files.sort_by_key(|(_, modified, _)| std::cmp::Reverse(*modified)); } + let total = files.len(); let mut page = Page::new(cursor.offset, args.limit); + for (ordinal, (file, modified, bytes)) in files.into_iter().enumerate() { + if page.wants(ordinal) { page.push(if args.output_mode == "details" { json!({"path":file,"bytes":bytes,"modified":modified}) } else { json!(file) }, ordinal)?; } + } + finish(scan, cursor, page, total, total, json!({})) +} + +/// Extract physical LF-delimited context without allocating a line table for the whole file. +fn excerpt(text: &str, first: usize, last: usize, before: usize, after: usize) -> String { + let bytes = text.as_bytes(); + let mut start = bytes[..first].iter().rposition(|byte| *byte == b'\n').map_or(0, |index| index + 1); + for _ in 0..before { if start == 0 { break; } start = bytes[..start - 1].iter().rposition(|byte| *byte == b'\n').map_or(0, |index| index + 1); } + let mut end = bytes[last..].iter().position(|byte| *byte == b'\n').map_or(bytes.len(), |index| last + index + 1); + for _ in 0..after { if end == bytes.len() { break; } end = bytes[end..].iter().position(|byte| *byte == b'\n').map_or(bytes.len(), |index| end + index + 1); } + let mut output = String::new(); + for (index, raw) in text[start..end].split_inclusive('\n').enumerate() { + if index > 0 { output.push('\n'); } + output.push_str(raw.strip_suffix('\n').map(|line| line.strip_suffix('\r').unwrap_or(line)).unwrap_or(raw)); + } + output +} +pub fn grep(request: &Request) -> Result { + let args: GrepArgs = serde_json::from_value(request.args.clone())?; + if args.pattern.is_empty() || args.limit == 0 || args.limit > 10_000 { return Err(Failure::new("INVALID_ARGUMENT", "pattern and bounded limit are required")); } + if args.before_context > 32 || args.after_context > 32 { return Err(Failure::new("INVALID_ARGUMENT", "context is limited to 32 lines")); } + if !matches!(args.output_mode.as_str(), "" | "content" | "files_with_matches" | "count" | "summary") { return Err(Failure::new("INVALID_ARGUMENT", "invalid grep outputMode")); } + let regex = RegexBuilder::new(&args.pattern).case_insensitive(args.case_insensitive).multi_line(args.multiline).build().map_err(|error| Failure::new("INVALID_ARGUMENT", error))?; + let root = target(&args.path, false)?; + let cursor = Cursor::new(request, args.offset, args.continuation.as_deref())?; + let mut scan = Scan::new(); let mut page = Page::new(cursor.offset, args.limit); + let mut file_count = 0; let mut match_count = 0; + let content = matches!(args.output_mode.as_str(), "" | "content"); + scan.walk(&root, &args.glob, request, |file, _, scan| { + let Some(bytes) = scan.read(file) else { return Ok(()); }; + let text = match String::from_utf8(bytes) { Ok(text) => text, Err(_) => { scan.skip("invalid-encoding", true, true); return Ok(()); } }; + if text.is_empty() { return Ok(()); } + let mut hits = 0; + let mut hit = |first: usize, last: usize, line: usize| -> Result<()> { + let ordinal = match_count + hits; hits += 1; + if content && page.wants(ordinal) { + page.push(json!({"path":file,"line":if args.line_numbers {line} else {0}, + "text":excerpt(&text, first, last, args.before_context, args.after_context)}), ordinal)?; + } + Ok(()) + }; + if args.multiline { + let mut previous = 0; let mut line = 1; + for found in regex.find_iter(&text) { + if !scan.running() { break; } + if found.start() >= text.len() && text.ends_with('\n') { continue; } + line += text[previous..found.start()].bytes().filter(|byte| *byte == b'\n').count(); previous = found.start(); + hit(found.start(), found.end().saturating_sub(1).max(found.start()), line)?; + } + } else { + let mut start = 0; + for (index, raw) in text.split_inclusive('\n').enumerate() { + if !scan.running() { break; } + let line = raw.strip_suffix('\n').map(|line| line.strip_suffix('\r').unwrap_or(line)).unwrap_or(raw); + if regex.is_match(line) { hit(start, start + line.len().saturating_sub(1), index + 1)?; } + start += raw.len(); + } + } + if hits > 0 { + let ordinal = file_count; file_count += 1; match_count += hits; + if !content && args.output_mode != "summary" { + page.push(if args.output_mode == "count" { json!({"path":file,"count":hits}) } else { json!({"path":file}) }, ordinal)?; + } + } + Ok(()) + })?; + let total = if args.output_mode == "summary" { 0 } else if content { match_count } else { file_count }; + let extra = if args.output_mode == "summary" { json!({"files":file_count,"matches":match_count}) } else { json!({}) }; + finish(scan, cursor, page, match_count, total, extra) +} diff --git a/test/grep-pagination.test.mjs b/test/grep-pagination.test.mjs index 935c988a..faccdf4c 100644 --- a/test/grep-pagination.test.mjs +++ b/test/grep-pagination.test.mjs @@ -72,7 +72,7 @@ await test('content pages inside and across files do not build or return the ski }) }) -await test('oversized grep files are size exclusions, not read failures', async () => { +await test('oversized grep files leave explicit coverage gaps without read failures', async () => { await withTempDir('grep-oversized', async root => { fs.writeFileSync(path.join(root, 'small.txt'), 'hit\nhit\n') const fd = fs.openSync(path.join(root, 'oversized.txt'), 'w') @@ -82,11 +82,14 @@ await test('oversized grep files are size exclusions, not read failures', async assert.equal(page.skippedFiles, 1) assert.equal(page.failed, 0) assert.equal(page.matched, 2) - assert.equal(page.complete, true) - assert.equal(page.truncated, false) + assert.equal(page.complete, false) + assert.equal(page.scanComplete, false) + assert.equal(page.truncated, true) + assert.equal(page.matchedTotal, undefined) + assert.equal(page.skipReasons.oversized, 1) if (outputMode === 'summary') assert.equal(page.files, 1) else { - assert.equal(page.total, 1) + assert.equal(page.total, undefined) assert.equal(page.items.length, 1) assert.equal(page.items[0].count, 2) } diff --git a/test/search-coverage.test.mjs b/test/search-coverage.test.mjs new file mode 100644 index 00000000..7478a847 --- /dev/null +++ b/test/search-coverage.test.mjs @@ -0,0 +1,146 @@ +/** Real worker search coverage, deterministic pages and state-bound cursor regressions. */ +import fs from 'node:fs' +import path from 'node:path' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { callWorker, request, envelopeOf, requireWorker } from './lib/boundary.mjs' +requireWorker('search-coverage') +const run = async (root, tool, args, authority = {}) => envelopeOf(await callWorker(JSON.stringify({ + ...request(tool, { path: root, ...args }, { root, ...authority }), + ...(authority.denyRoots ? { deny_roots: authority.denyRoots } : {}), +})), assert) +const grep = (root, args = {}, authority = {}) => run(root, 'grep', { pattern: 'hit', limit: 2, ...args }, authority) +const stale = value => { assert.equal(value.status, 'error', JSON.stringify(value)); assert.equal(value.error.code, 'SEARCH_STALE'); assert.equal(value.error.effectState, 'none') } + +await test('complete scans separate full totals from bounded pages and report declared exclusions', async () => { + await withTempDir('search-complete', async root => { + for (const file of ['a.txt', 'b.txt', 'c.txt']) fs.writeFileSync(path.join(root, file), 'hit one\nhit two\n') + fs.writeFileSync(path.join(root, 'other.log'), 'hit ignored\n') + fs.writeFileSync(path.join(root, '.hidden'), 'hit hidden\n') + fs.mkdirSync(path.join(root, 'node_modules')); fs.writeFileSync(path.join(root, 'node_modules', 'module.txt'), 'hit module\n') + const query = { glob: ['*.txt'] } + const first = await grep(root, query) + assert.equal(first.scanComplete, true); assert.equal(first.pageComplete, true) + assert.equal(first.matchedObserved, 6); assert.equal(first.matchedTotal, 6) + assert.equal(first.scannedFiles, 4); assert.equal(first.skippedFiles, 2) + assert.equal(first.skipReasons.pattern, 1); assert.equal(first.skipReasons.excluded, 2) + assert.equal(first.truncatedBy, 'row-limit'); assert.equal(first.complete, false) + assert.equal(typeof first.continuation, 'string') + assert.deepEqual(await grep(root, query), first, 'repeated observation yields the same first page and cursor') + const all = [...first.items]; let page = first + while (page.continuation) { + page = await grep(root, { ...query, continuation: page.continuation }) + all.push(...page.items) + } + assert.equal(all.length, 6); assert.equal(new Set(all.map(item => `${item.path}:${item.line}`)).size, 6) + assert.equal(page.complete, true); assert.equal(page.truncatedBy, 'none') + assert.equal(fs.existsSync(path.join(root, '.dsh-ops')), false, 'search paging never creates audit or reference storage') + }) +}) + +await test('continuations reject altered query, paging token and current authority', async () => { + await withTempDir('search-cursor-binding', async root => { + fs.writeFileSync(path.join(root, 'a.txt'), 'hit\nhit\nhit\n') + const first = await grep(root) + const token = first.continuation + for (const change of [{ pattern: 'other' }, { limit: 1 }, { outputMode: 'count' }, { offset: 1 }]) { + stale(await grep(root, { continuation: token, ...change })) + } + for (const authority of [{ scope: 'different' }, { readOnly: true }, { writeRoots: [root] }, { denyRoots: [path.join(root, 'other')] }]) { + stale(await grep(root, { continuation: token }, authority)) + } + stale(await grep(root, { continuation: `${token.slice(0, -1)}${token.endsWith('0') ? '1' : '0'}` })) + stale(await grep(root, { continuation: 's1:broken' })) + assert.equal((await grep(root, { continuation: token })).items.length, 1) + }) +}) + +await test('changed file bytes invalidate cursors even when size and timestamp are restored', async () => { + await withTempDir('search-cursor-content', async root => { + const file = path.join(root, 'a.txt'); fs.writeFileSync(file, 'hit\nhit\nhit\n') + const first = await grep(root); const before = fs.statSync(file) + fs.writeFileSync(file, 'hot\nhit\nhit\n'); fs.utimesSync(file, before.atime, before.mtime) + assert.equal(fs.statSync(file).size, before.size) + stale(await grep(root, { continuation: first.continuation })) + const current = await grep(root) + assert.equal(current.matchedTotal, 2); assert.equal(current.complete, true) + }) +}) + +await test('glob pages bind path membership and metadata while retaining deterministic ordering', async () => { + await withTempDir('search-cursor-tree', async root => { + for (const file of ['a.txt', 'b.txt', 'c.txt']) fs.writeFileSync(path.join(root, file), file) + const query = { pattern: ['*.txt'], outputMode: 'details', limit: 1, sort: 'path' } + const first = await run(root, 'glob', query) + assert.equal(first.scanComplete, true); assert.equal(first.matchedTotal, 3) + assert.equal(path.basename(first.items[0].path), 'a.txt') + const second = await run(root, 'glob', { ...query, continuation: first.continuation }) + assert.equal(path.basename(second.items[0].path), 'b.txt') + fs.renameSync(path.join(root, 'c.txt'), path.join(root, 'd.txt')) + stale(await run(root, 'glob', { ...query, continuation: first.continuation })) + assert.equal((await run(root, 'glob', query)).matchedTotal, 3) + }) +}) + +await test('oversized and non-UTF8 files are coverage gaps with no invented total or continuation', async () => { + await withTempDir('search-gaps', async root => { + fs.writeFileSync(path.join(root, 'a.txt'), 'hit\nhit\nhit\n') + fs.writeFileSync(path.join(root, 'binary.bin'), Buffer.from([0xff, 0xfe])) + const fd = fs.openSync(path.join(root, 'large.txt'), 'w') + try { fs.ftruncateSync(fd, 32 * 1024 * 1024 + 1) } finally { fs.closeSync(fd) } + for (const outputMode of ['content', 'count', 'summary']) { + const value = await grep(root, { outputMode }) + assert.equal(value.status, 'completed'); assert.equal(value.scanComplete, false) + assert.equal(value.pageComplete, true); assert.equal(value.complete, false) + assert.equal(value.matchedObserved, 3); assert.equal(value.matchedTotal, undefined) + assert.equal(value.total, undefined); assert.equal(value.omitted, undefined) + assert.equal(value.skippedFiles, 2); assert.equal(value.failed, 0) + assert.equal(value.skipReasons.oversized, 1); assert.equal(value.skipReasons['invalid-encoding'], 1) + assert.equal(value.continuation, undefined); assert.match(value.nextAction, /Narrow path\/glob/) + } + const filtered = await grep(root, { glob: ['a.txt'], outputMode: 'summary' }) + assert.equal(filtered.scanComplete, true); assert.equal(filtered.matchedTotal, 3) + assert.equal(filtered.skipReasons.pattern, 2) + }) +}) + +await test('a gap introduced after observation invalidates a continuation instead of silently omitting it', async () => { + await withTempDir('search-cursor-gap', async root => { + const file = path.join(root, 'a.txt'); fs.writeFileSync(file, 'hit\nhit\nhit\n') + const first = await grep(root) + fs.writeFileSync(file, Buffer.from([0xff])) + stale(await grep(root, { continuation: first.continuation })) + }) +}) + +await test('output truncation preserves scan totals but never advances beyond an undisplayed match suffix', async () => { + await withTempDir('search-output-gap', async root => { + fs.writeFileSync(path.join(root, 'a.txt'), `hit${'\0"\\😀'.repeat(50000)}`) + const value = await grep(root) + assert.equal(value.scanComplete, true); assert.equal(value.matchedTotal, 1) + assert.equal(value.pageComplete, false); assert.equal(value.truncatedBy, 'output-budget') + assert.equal(value.complete, false); assert.equal(value.nextOffset, 0) + assert.equal(value.continuation, undefined); assert.equal(value.items[0].truncatedInLine, true) + assert.ok(value.items[0].bytesOmitted > 0) + assert.ok(Buffer.byteLength(JSON.stringify(value)) < 128 * 1024) + assert.match(value.nextAction, /nextOffset does not skip/) + }) +}) + +await test('streamed physical lines retain multiline anchors and before/after context', async () => { + await withTempDir('search-context', async root => { + fs.writeFileSync(path.join(root, 'a.txt'), 'before\r\nhit one\r\nhit two\r\nafter\r\n') + const value = await grep(root, { pattern: 'hit one\nhit two', multiline: true, beforeContext: 1, afterContext: 1 }) + assert.equal(value.matchedTotal, 0, 'literal LF never matches CRLF bytes') + const whole = await grep(root, { pattern: 'hit one\r?\nhit two', multiline: true, beforeContext: 1, afterContext: 1 }) + assert.equal(whole.matchedTotal, 1); assert.equal(whole.items[0].line, 2) + assert.equal(whole.items[0].text, 'before\nhit one\nhit two\nafter') + const lines = await grep(root, { beforeContext: 1, afterContext: 1 }) + assert.equal(lines.matchedTotal, 2); assert.equal(lines.items[0].text, 'before\nhit one\nhit two') + fs.writeFileSync(path.join(root, 'empty.txt'), '') + const empty = await grep(root, { path: path.join(root, 'empty.txt'), pattern: '^', multiline: true }) + assert.equal(empty.matchedTotal, 0, 'an empty file has no physical line to match') + const anchored = await grep(root, { pattern: '$', multiline: true, limit: 10 }) + assert.equal(anchored.matchedTotal, 4, 'a final LF never creates a phantom line') + }) +}) +report('search-coverage') From 3e0c82befd0622b41ad9c799593f02d647d06943 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:53:26 +0800 Subject: [PATCH 010/118] feat(file): [5b/16] batch bounded reads and atomic edits --- native/crates/ops-files/src/file.rs | 75 ++------ native/crates/ops-files/src/file_many.rs | 63 +++++++ native/crates/ops-files/src/file_output.rs | 64 +++++++ native/crates/ops-files/src/file_single.rs | 2 +- native/src/lib.rs | 2 +- native/src/main.rs | 2 +- test/file-batching-worker.test.mjs | 208 +++++++++++++++++++++ 7 files changed, 357 insertions(+), 59 deletions(-) create mode 100644 native/crates/ops-files/src/file_many.rs create mode 100644 native/crates/ops-files/src/file_output.rs create mode 100644 test/file-batching-worker.test.mjs diff --git a/native/crates/ops-files/src/file.rs b/native/crates/ops-files/src/file.rs index f4353856..f7be283e 100644 --- a/native/crates/ops-files/src/file.rs +++ b/native/crates/ops-files/src/file.rs @@ -46,7 +46,8 @@ pub fn dispatch(request: &Request) -> Result { if let Some(raw) = request.args.get("path").and_then(Value::as_str) { request.authorize_path(Path::new(raw))?; } match request.tool.as_str() { "inspect_local_file" => inspect(request), - "stat" | "diff" | "write_file" | "edit_file" | "copy" | "move" | "remove" => single::run(request), + "read_many" => many::read_many(request), + "stat" | "diff" | "write_file" | "edit_file" | "edit_many" | "copy" | "move" | "remove" => single::run(request), _ => Err(Failure::new("INVALID_ARGUMENT", "Unknown file capability")), }.map_err(|mut error| { error.message = format!("{}: {}", request.tool, error.message); error }) } @@ -66,68 +67,28 @@ pub fn read_bytes(path: &Path) -> Result> { if bytes.len() as u64 > MAX_FILE_BYTES { return Err(Failure::new("OUTPUT_LIMIT", "File exceeds the bounded read budget")); } Ok(bytes) } -/// What one character costs in serde_json's encoding. -fn encoded_cost(character: char) -> usize { - match character { - '"' | '\\' | '\n' | '\r' | '\t' | '\u{8}' | '\u{c}' => 2, - c if (c as u32) < 0x20 => 6, - c => c.len_utf8(), - } -} -/// The longest prefix of `text` whose encoding fits `budget` bytes, cut on a -/// character boundary so the result is still valid UTF-8. -fn truncate_to_encoded(text: &str, budget: usize) -> (String, bool) { - let mut kept = String::new(); let mut used = 0usize; - for character in text.chars() { let cost = encoded_cost(character); if used + cost > budget { return (kept, true); } used += cost; kept.push(character); } - (kept, false) -} -/// `offset` is how many units were skipped, counted from zero the way `glob` and -/// `grep` count their own `offset`. `next_base` is the value a caller must pass -/// back to resume, which is its own paging base rather than the skipped count: -/// `inspect` declares a 1-based `offset` (like its hex rows), so deriving the -/// next page from the skipped count re-read the last returned line on every page. +#[path = "file_output.rs"] +mod output; +#[path = "file_many.rs"] +mod many; + +/// Reserve space for metadata added by search callers after this shared builder. pub fn result(items: Vec, total: usize, offset: usize, next_base: usize, matched: usize, truncated: bool) -> Value { - // Spend the response budget on the serialized items. Returning more than the - // host will collect is not a bigger answer, it is a failed read: the host - // truncates stdout, reports OUTPUT_LIMIT and declares the effect unknown, so - // the caller loses the content and is told something may have changed. - let budget = MAX_OUTPUT_BYTES.saturating_sub(ENVELOPE_OVERHEAD); - let mut kept: Vec = Vec::new(); let mut used = 0usize; let mut stopped = false; - for item in items { - let size = serde_json::to_string(&item).map_or(0, |encoded| encoded.len()) + 1; - if used + size <= budget { used += size; kept.push(item); continue; } - // This item does not fit whole. Keep a bounded prefix of its text rather - // than returning nothing at all, so a file holding one long line still - // answers with its beginning; every remaining item is omitted and counted - // as such. Reading past the cut is the hex view's job, which pages by - // 16-byte rows. - let whole = item.get("text").and_then(Value::as_str); - let room = budget.saturating_sub(used).saturating_sub(96); - let prefix = whole.map(|text| truncate_to_encoded(text, room)); - if let Some((text, cut)) = prefix { - if !text.is_empty() { - let mut trimmed = item.clone(); - trimmed["text"] = Value::String(text); - trimmed["bytesTotal"] = json!(whole.map_or(0, |text| text.len())); - trimmed["truncatedInLine"] = json!(cut); - kept.push(trimmed); - } - } - stopped = true; - break; - } - let returned = kept.len(); let cut = truncated || stopped; - json!({"status":"completed","phase":"verify","changed":0,"matched":matched,"skipped":offset,"failed":0,"complete":!cut && offset + returned >= total,"truncated":cut,"omitted":total.saturating_sub(offset + returned),"items":kept,"total":total,"nextOffset":next_base + returned}) + output::bounded(items, total, offset, next_base, matched, truncated, json!({}), MAX_OUTPUT_BYTES - ENVELOPE_OVERHEAD) } fn inspect(request: &Request) -> Result { let args: InspectArgs = serde_json::from_value(request.args.clone())?; + inspect_args(args, MAX_OUTPUT_BYTES, false) +} +fn inspect_args(args: InspectArgs, budget: usize, include_path: bool) -> Result { if args.limit == 0 || args.limit > 10_000 { return Err(Failure::new("INVALID_ARGUMENT", "inspect limit is 1-10000")); } if !args.encoding.eq_ignore_ascii_case("utf-8") { return Err(Failure::new("ERR_INVALID_ENCODING", "Text inspection supports UTF-8 only")); } let path = target(&args.path, false)?; if !path.is_file() { return Err(Failure::new("INVALID_ARGUMENT", "path is a directory; use ops_glob or ops_grep")); } let bytes = read_bytes(&path)?; - let facts = content_facts(&bytes); + let mut facts = content_facts(&bytes); + if include_path { facts["path"] = json!(path); } if bytes.len() as u64 > MAX_FILE_BYTES { return Err(Failure::new("OUTPUT_LIMIT", "file exceeds the inspect size budget")); } if args.view == "hex" { // `offset` means the same thing in both views: the 1-based unit to start @@ -146,9 +107,11 @@ fn inspect(request: &Request) -> Result { // str::lines splits physical LF/CRLF, never bare CR or Unicode separators. // Empty files have zero lines; a final LF does not create a phantom line. let lines: Vec<_> = text.lines().enumerate().skip(args.offset.saturating_sub(1)).take(args.limit).map(|(i, line)| json!({"path":path,"line":i+1,"text":line})).collect(); - let mut output = result(lines, text.lines().count(), args.offset.saturating_sub(1), args.offset, text.lines().count(), false); - for key in ["revision", "bytes", "lineCount"] { output[key] = facts[key].clone(); } - Ok(output) + let value = output::bounded(lines, text.lines().count(), args.offset.saturating_sub(1), args.offset, text.lines().count(), false, facts, budget); + if output::size(&value) + 1 > budget { + return Err(Failure::new("OUTPUT_LIMIT", "File metadata exceeds the remaining output budget")); + } + Ok(value) } /// A POSIX ACL is exposed as this attribute, so preserving ACLs needs no libacl. #[cfg(unix)] diff --git a/native/crates/ops-files/src/file_many.rs b/native/crates/ops-files/src/file_many.rs new file mode 100644 index 00000000..18c7c853 --- /dev/null +++ b/native/crates/ops-files/src/file_many.rs @@ -0,0 +1,63 @@ +//! Multiple read-only file observations in one process, with one aggregate byte cap. +use super::*; +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ReadManyArgs { files: Vec } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ReadRange { + path: String, + #[serde(default = "one")] + offset: usize, + #[serde(default = "two_hundred")] + limit: usize, +} +const NEXT_ACTION: &str = "Resume remaining files from nextIndex. Follow each file's nextOffset for more lines; for truncatedInLine use that file's hex inspection guidance."; + +pub(super) fn read_many(request: &Request) -> Result { + let args: ReadManyArgs = serde_json::from_value(request.args.clone())?; + if args.files.is_empty() || args.files.len() > 32 { + return Err(Failure::new("INVALID_ARGUMENT", "read_many accepts 1-32 files")); + } + if args.files.iter().any(|file| file.offset == 0 || file.limit == 0 || file.limit > 10_000) { + return Err(Failure::new("INVALID_ARGUMENT", "read_many offset starts at 1 and limit is 1-10000")); + } + let total = args.files.len(); + // Keep the longest possible aggregate fields reserved before reading any file. + let mut value = json!({"status":"completed","phase":"verify","changed":0,"matched":total,"failed":total,"complete":false,"truncated":false,"effectState":"none","items":[],"total":total,"nextIndex":total,"pending":total,"nextAction":NEXT_ACTION}); + let mut room = MAX_OUTPUT_BYTES.saturating_sub(output::size(&value) + 1); + let mut items = Vec::new(); + let mut failed = 0; + let mut complete = true; + let mut truncated = false; + for file in args.files { + // Stop before touching another file when no meaningful receipt can fit. + if room < 1024 { break; } + let observed = request.authorize_path(Path::new(&file.path)).and_then(|()| { + inspect_args(InspectArgs { path: file.path.clone(), offset: file.offset, limit: file.limit, encoding: utf8(), view: text() }, room.saturating_sub(1), true) + }); + let item = match observed { + Ok(item) => item, + Err(error) => json!({"path":file.path,"status":"error","complete":false,"items":[],"error":error}), + }; + let size = output::size(&item) + 1; + if size > room { break; } // this input remains pending; never discard a receipt silently + room -= size; + if item.get("error").is_some() { failed += 1; } + complete &= item["complete"] == true; + truncated |= item["truncated"] == true; + items.push(item); + } + let returned = items.len(); + complete &= returned == total; + value["status"] = json!(if complete { "completed" } else { "partial" }); + value["complete"] = json!(complete); + value["truncated"] = json!(truncated || returned < total); + value["matched"] = json!(returned - failed); + value["failed"] = json!(failed); + value["nextIndex"] = json!(returned); + value["pending"] = json!(total - returned); + value["items"] = json!(items); + if complete { value.as_object_mut().expect("result object").remove("nextAction"); } + Ok(value) +} diff --git a/native/crates/ops-files/src/file_output.rs b/native/crates/ops-files/src/file_output.rs new file mode 100644 index 00000000..d5820753 --- /dev/null +++ b/native/crates/ops-files/src/file_output.rs @@ -0,0 +1,64 @@ +//! Serialized-byte accounting shared by inspection, search, and batched reads. +use super::*; +const HEX_RECOVERY: &str = "A line was cut by the output budget. Inspect this file with view=hex and page its 16-byte rows; nextOffset retains the incomplete line, so text paging alone cannot recover its suffix."; + +fn encoded_cost(character: char) -> usize { + match character { + '"' | '\\' | '\n' | '\r' | '\t' | '\u{8}' | '\u{c}' => 2, + c if (c as u32) < 0x20 => 6, + c => c.len_utf8(), + } +} +fn prefix(text: &str, budget: usize) -> &str { + let mut used = 0; + for (index, character) in text.char_indices() { + used += encoded_cost(character); + if used > budget { return &text[..index]; } + } + text +} +pub(super) fn size(value: &Value) -> usize { + serde_json::to_vec(value).expect("JSON value is serializable").len() +} +/// `offset` counts skipped units; `next_base` is the caller's resumption base. +/// The envelope reserves its longest possible flags, counters, and recovery advice. +/// Each candidate includes its actual escaped path and truncation metadata before +/// text receives any room. No fixed estimate for a path/header can overflow this cap. +#[allow(clippy::too_many_arguments)] +pub(super) fn bounded(items: Vec, total: usize, offset: usize, next_base: usize, matched: usize, truncated: bool, facts: Value, budget: usize) -> Value { + let mut value = json!({"status":"completed","phase":"verify","changed":0,"matched":matched,"skipped":offset,"failed":0,"complete":false,"truncated":false,"omitted":total,"items":[],"total":total,"nextOffset":next_base.saturating_add(items.len()),"nextAction":HEX_RECOVERY}); + value.as_object_mut().expect("result object").extend(facts.as_object().expect("facts object").clone()); + let mut room = budget.saturating_sub(size(&value) + 1); // trailing worker newline + let mut kept = Vec::new(); + let mut stopped = false; + let mut incomplete_line = false; + for mut item in items { + let cost = size(&item) + 1; // array comma (also reserved for the first item) + if cost <= room { room -= cost; kept.push(item); continue; } + if let Some(whole) = item.get("text").and_then(Value::as_str).map(str::to_owned) { + item["text"] = json!(""); + item["bytesTotal"] = json!(whole.len()); + item["bytesOmitted"] = json!(whole.len()); + item["truncatedInLine"] = json!(true); + let header = size(&item) + 1; + let text = prefix(&whole, room.saturating_sub(header)); + if !text.is_empty() && header <= room { + item["bytesOmitted"] = json!(whole.len() - text.len()); + item["text"] = json!(text); + kept.push(item); + incomplete_line = true; + } + } + stopped = true; + break; + } + let consumed = kept.len().saturating_sub(usize::from(incomplete_line)); + let cut = truncated || stopped; + value["complete"] = json!(!cut && offset.saturating_add(consumed) >= total); + value["truncated"] = json!(cut); + value["omitted"] = json!(total.saturating_sub(offset.saturating_add(consumed))); + value["nextOffset"] = json!(next_base.saturating_add(consumed)); + value["items"] = json!(kept); + if !incomplete_line { value.as_object_mut().expect("result object").remove("nextAction"); } + value +} diff --git a/native/crates/ops-files/src/file_single.rs b/native/crates/ops-files/src/file_single.rs index 5a3b1af6..5b8fb197 100644 --- a/native/crates/ops-files/src/file_single.rs +++ b/native/crates/ops-files/src/file_single.rs @@ -71,7 +71,7 @@ pub(super) fn run(request: &Request) -> Result { |_| Ok(args.content.clone().into_bytes()), ) } - "edit_file" => { + "edit_file" | "edit_many" => { let args: EditArgs = serde_json::from_value(request.args.clone())?; valid_revision(&args.expected_revision, false)?; write_single( diff --git a/native/src/lib.rs b/native/src/lib.rs index f3938bb0..bf39e7aa 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -25,7 +25,7 @@ pub fn dispatch(request: Request) -> Result { // File reads/searches do not create or consult the durable audit store. // This is important for read-only sessions and keeps observation separate // from mutation audit records. - if matches!(request.tool.as_str(), "inspect_local_file" | "glob" | "grep" | "replace" | "write_file" | "edit_file" | "diff" | "stat" | "copy" | "move" | "remove") { + if matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "glob" | "grep" | "replace" | "write_file" | "edit_file" | "edit_many" | "diff" | "stat" | "copy" | "move" | "remove") { request.root_identity = String::new(); return if matches!(request.tool.as_str(), "glob" | "grep" | "replace") { ops_search::dispatch(&request) diff --git a/native/src/main.rs b/native/src/main.rs index 0dbf8803..ea400a86 100644 --- a/native/src/main.rs +++ b/native/src/main.rs @@ -4,7 +4,7 @@ use serde_json::json; use std::io::{Read, Write}; fn may_mutate(request: &Request) -> bool { - !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "glob" | "grep" | "diff" | "stat") + !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "glob" | "grep" | "diff" | "stat") && !(request.tool == "result" && request.args.get("action").and_then(serde_json::Value::as_str) == Some("page")) } fn main() { diff --git a/test/file-batching-worker.test.mjs b/test/file-batching-worker.test.mjs new file mode 100644 index 00000000..d7940cf7 --- /dev/null +++ b/test/file-batching-worker.test.mjs @@ -0,0 +1,208 @@ +/** Real-worker read_many observations and exact single-file edit_many semantics. */ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { callTool, callWorker, request, envelopeOf, requireWorker } from './lib/boundary.mjs' +requireWorker('file-batching-worker') +const hash = value => createHash('sha256').update(value).digest('hex') +const run = async (root, tool, args, options = {}) => envelopeOf(await callTool(tool, args, { root, ...options }), assert) +const send = async input => envelopeOf(await callWorker(JSON.stringify(input)), assert) +const snapshot = root => fs.readdirSync(root, { recursive: true }).sort() +const noEffects = body => { + assert.equal(body.status, 'error', JSON.stringify(body)) + assert.equal(body.error.effectState, 'none') + assert.equal(body.error.retryable, false) +} + +await test('four files retain exact ranges, LF facts, revisions, and empty-file completeness', async () => { + await withTempDir('many-ranges', async root => { + const texts = ['one\r\ntwo\rthree\nlast\n', '', '\n', 'first\nsecond\nthird'] + const files = texts.map((text, index) => { + const file = path.join(root, `${index}.txt`); fs.writeFileSync(file, text); return { path: file } + }) + const body = await run(root, 'read_many', { files: [{ ...files[0], offset: 2, limit: 1 }, files[1], files[2], { ...files[3], offset: 2 }] }, { readOnly: true }) + assert.equal(body.status, 'partial'); assert.equal(body.complete, false) + assert.equal(body.items.length, 4); assert.equal(body.nextIndex, 4); assert.equal(body.pending, 0) + assert.equal(body.failed, 0); assert.equal(body.effectState, 'none') + for (let index = 0; index < 4; index++) { + const file = body.items[index] + assert.equal(file.path, files[index].path) + assert.equal(file.revision, hash(texts[index])); assert.equal(file.bytes, Buffer.byteLength(texts[index])) + assert.equal(file.lineCount, [3, 0, 1, 3][index]) + } + assert.deepEqual(body.items[0].items.map(({ line, text }) => ({ line, text })), [{ line: 2, text: 'two\rthree' }]) + assert.equal(body.items[0].complete, false); assert.equal(body.items[0].nextOffset, 3) + assert.deepEqual(body.items[1].items, []); assert.equal(body.items[1].complete, true) + assert.deepEqual(body.items[2].items.map(item => item.text), ['']) + assert.deepEqual(body.items[3].items.map(item => item.line), [2, 3]); assert.equal(body.items[3].complete, true) + const complete = await run(root, 'read_many', { files }) + assert.equal(complete.status, 'completed'); assert.equal(complete.complete, true) + assert.equal(complete.pending, 0); assert.equal(complete.nextAction, undefined) + assert.deepEqual(snapshot(root), ['0.txt', '1.txt', '2.txt', '3.txt']) + }) +}) + +await test('descriptors are strict and bounded before observations, with a 200-line default', async () => { + await withTempDir('many-bounds', async root => { + const file = path.join(root, 'file.txt'); fs.writeFileSync(file, `${'line\n'.repeat(201)}`) + const first = await run(root, 'read_many', { files: [{ path: file }] }) + assert.equal(first.items[0].items.length, 200); assert.equal(first.items[0].nextOffset, 201) + assert.equal(first.complete, false) + const last = await run(root, 'read_many', { files: [{ path: file, offset: 201 }] }) + assert.equal(last.complete, true); assert.equal(last.items[0].items.length, 1) + const cases = [ + { files: [] }, { files: Array(33).fill({ path: file }) }, { files: [{ path: file }], unknown: true }, + ...[0, -1, 1.5].map(offset => ({ files: [{ path: file }, { path: file, offset }] })), + ...[0, -1, 10001, 1.5].map(limit => ({ files: [{ path: file }, { path: file, limit }] })), + ...['encoding', 'view', 'unknown'].map(key => ({ files: [{ path: file, [key]: 'text' }] })), + ] + for (const args of cases) { + const value = await run(root, 'read_many', args) + noEffects(value); assert.equal(value.error.code, 'INVALID_ARGUMENT'); assert.equal(value.items, undefined) + } + const maximum = await run(root, 'read_many', { files: Array(32).fill({ path: file, offset: 201, limit: 10000 }) }) + assert.equal(maximum.complete, true); assert.equal(maximum.items.length, 32) + assert.deepEqual(snapshot(root), ['file.txt']) + }) +}) + +await test('per-file missing, directory, UTF-8, and size failures preserve successful neighbors', async () => { + await withTempDir('many-errors', async root => { + const good = path.join(root, 'good.txt'); fs.writeFileSync(good, 'visible\n') + const binary = path.join(root, 'binary'); fs.writeFileSync(binary, Buffer.from([255, 254])) + const large = path.join(root, 'large'); const fd = fs.openSync(large, 'w') + try { fs.ftruncateSync(fd, 32 * 1024 * 1024 + 1) } finally { fs.closeSync(fd) } + const paths = [good, path.join(root, 'missing'), root, binary, large, good] + const before = snapshot(root) + const body = await run(root, 'read_many', { files: paths.map(path => ({ path })) }, { readOnly: true }) + assert.equal(body.status, 'partial'); assert.equal(body.complete, false) + assert.equal(body.items.length, 6); assert.equal(body.failed, 4); assert.equal(body.pending, 0) + assert.equal(body.items[0].items[0].text, 'visible'); assert.equal(body.items[5].revision, hash('visible\n')) + for (let index = 1; index <= 4; index++) { + const item = body.items[index]; noEffects(item) + assert.equal(item.path, paths[index]); assert.equal(item.complete, false); assert.deepEqual(item.items, []) + assert.equal(item.error.code, ['NOT_FOUND', 'INVALID_ARGUMENT', 'ERR_INVALID_ENCODING', 'OUTPUT_LIMIT'][index - 1]) + } + assert.match(body.items[3].error.nextAction, /hex/) + assert.deepEqual(snapshot(root), before) + }) +}) + +await test('each batched file rechecks credential roots, names, and no-follow without read artifacts', async () => { + await withTempDir('many-authority', async root => { + const good = path.join(root, 'good.txt'); fs.writeFileSync(good, 'safe') + const secret = path.join(root, 'vault'); fs.mkdirSync(secret) + fs.writeFileSync(path.join(secret, 'token'), 'DO_NOT_DISCLOSE') + const credential = path.join(root, '.env'); fs.writeFileSync(credential, 'SECRET') + const link = path.join(root, 'linked'); fs.symlinkSync(secret, link, process.platform === 'win32' ? 'junction' : 'dir') + const files = [good, path.join(secret, 'token'), credential, path.join(link, 'token')].map(path => ({ path })) + const before = snapshot(root) + for (const readOnly of [false, true]) { + const input = request('read_many', { files }, { root, readOnly, writeRoots: [] }) + input.deny_roots = [secret]; input.deny_credential_names = true + const body = await send(input) + assert.equal(body.items[0].items[0].text, 'safe') + assert.equal(body.items[1].error.code, 'CREDENTIAL_DENIED') + assert.equal(body.items[2].error.code, 'CREDENTIAL_DENIED') + assert.equal(body.items[3].error.code, 'PATH_RESTRICTED') + assert.equal(body.complete, false); assert.equal(body.failed, 3) + assert.doesNotMatch(JSON.stringify(body), /DO_NOT_DISCLOSE|SECRET/) + for (const item of body.items.slice(1)) noEffects(item) + assert.deepEqual(snapshot(root), before) + } + }) +}) + +await test('one escaped huge line retains its prefix and receipts within the aggregate 96KiB cap', async () => { + await withTempDir('many-budget', async root => { + // The long directory/path is part of the encoded budget, not an assumed 96-byte header. + const directory = path.join(root, 'p'.repeat(90)); fs.mkdirSync(directory) + const file = path.join(directory, `${'f'.repeat(90)}.txt`) + const text = '\0"\\中'.repeat(100000); fs.writeFileSync(file, text) + const files = [file, file, file, file].map(path => ({ path })) + for (const tool of ['read_many', 'inspect_local_file']) { + const raw = await callTool(tool, tool === 'read_many' ? { files } : { path: file }, { root }) + assert.ok(Buffer.byteLength(raw.stdout) <= 96 * 1024, `${tool}: ${Buffer.byteLength(raw.stdout)} bytes`) + const body = envelopeOf(raw, assert) + const observed = tool === 'read_many' ? body.items[0] : body + assert.equal(observed.complete, false); assert.equal(observed.truncated, true) + assert.equal(observed.nextOffset, 1, 'a partial line is not consumed') + assert.equal(observed.omitted, 1) + assert.equal(observed.revision, hash(text)); assert.equal(observed.bytes, Buffer.byteLength(text)); assert.equal(observed.lineCount, 1) + assert.equal(observed.items.length, 1) + const item = observed.items[0] + assert.equal(item.truncatedInLine, true); assert.ok(item.text.length > 0) + assert.ok(text.startsWith(item.text)); assert.equal(item.bytesTotal, Buffer.byteLength(text)) + assert.equal(item.bytesOmitted, Buffer.byteLength(text) - Buffer.byteLength(item.text)) + assert.match(observed.nextAction, /view=hex/) + if (tool === 'read_many') { + assert.equal(body.status, 'partial'); assert.equal(body.nextIndex, 1); assert.equal(body.pending, 3) + assert.equal(body.items.length, 1); assert.equal(body.complete, false) + } + } + const tail = path.join(root, 'tail.txt'); fs.writeFileSync(tail, 'remaining') + const mixedRaw = await callTool('read_many', { files: [{ path: tail }, { path: file }, { path: tail }] }, { root }) + assert.ok(Buffer.byteLength(mixedRaw.stdout) <= 96 * 1024) + const mixed = envelopeOf(mixedRaw, assert) + assert.equal(mixed.items[0].complete, true); assert.equal(mixed.items[0].items[0].text, 'remaining') + assert.equal(mixed.items[1].items[0].truncatedInLine, true) + assert.equal(mixed.nextIndex, 2); assert.equal(mixed.pending, 1); assert.equal(mixed.complete, false) + const resumed = await run(root, 'read_many', { files: [{ path: tail }] }) + assert.equal(resumed.complete, true); assert.equal(resumed.items[0].items[0].text, 'remaining') + }) +}) + +await test('transport clipping identifies read_many as read-only even in a writable session', async () => { + await withTempDir('many-transport', async root => { + const file = path.join(root, 'large.txt'); fs.writeFileSync(file, 'x'.repeat(2000)) + const raw = await callWorker(JSON.stringify({ version: 1, requestId: 'many-output', component: 'file', budget: { timeoutMs: 30000, maxBytes: 1024 }, request: request('read_many', { files: [{ path: file }] }, { root, readOnly: false }) })) + const body = envelopeOf(raw, assert) + assert.ok(Buffer.byteLength(raw.stdout) <= 1024) + noEffects(body); assert.equal(body.error.code, 'OUTPUT_LIMIT') + assert.deepEqual(snapshot(root), ['large.txt']) + }) +}) + +await test('edit_many applies unique nonoverlapping edits once and shares edit_file dry-run receipts', async () => { + await withTempDir('many-edits', async root => { + const file = path.join(root, 'file.txt'); const text = 'alpha\nbeta\ngamma\n'; fs.writeFileSync(file, text) + const args = { path: file, expectedRevision: hash(text), edits: [{ oldText: 'alpha', newText: 'A' }, { oldText: 'gamma', newText: 'G' }] } + const before = snapshot(root) + const dry = await run(root, 'edit_many', { ...args, dryRun: true }) + const legacy = await run(root, 'edit_file', { ...args, dryRun: true }) + assert.deepEqual(dry, legacy); assert.equal(dry.complete, true) + assert.equal(fs.readFileSync(file, 'utf8'), text); assert.deepEqual(snapshot(root), before) + const value = await run(root, 'edit_many', args) + assert.equal(value.status, 'completed'); assert.equal(value.changed, 1); assert.equal(value.complete, true) + assert.equal(value.items[0].revisionBefore, hash(text)); assert.equal(value.revision, hash('A\nbeta\nG\n')) + assert.equal(fs.readFileSync(file, 'utf8'), 'A\nbeta\nG\n') + assert.equal(value.items.length, 1) + assert.equal(fs.existsSync(path.join(root, '.dsh-ops')), false) + }) +}) + +await test('edit_many CAS, ambiguity, overlap, and authority refusals leave no effects or sidecars', async () => { + await withTempDir('many-edits-refused', async root => { + const file = path.join(root, 'file.txt'); const text = 'alpha beta gamma alpha'; fs.writeFileSync(file, text) + const args = { path: file, expectedRevision: hash(text), edits: [{ oldText: 'beta', newText: 'B' }, { oldText: 'gamma', newText: 'G' }] } + const before = snapshot(root) + const cases = [ + [{ ...args, expectedRevision: '0'.repeat(64) }, {}, 'REVISION_CONFLICT'], + [{ ...args, edits: [...args.edits, { oldText: 'missing', newText: 'M' }] }, {}, 'EDIT_CONFLICT'], + [{ ...args, edits: [...args.edits, { oldText: 'alpha', newText: 'A' }] }, {}, 'EDIT_CONFLICT'], + [{ ...args, edits: [args.edits[0], { oldText: 'beta gamma', newText: 'BG' }] }, {}, 'EDIT_CONFLICT'], + [args, { readOnly: true }, 'POLICY_DENIED'], [args, { writeRoots: [] }, 'POLICY_DENIED'], + [{ files: [args] }, {}, 'INVALID_ARGUMENT'], + ] + for (const [input, options, code] of cases) { + const value = await run(root, 'edit_many', input, options) + noEffects(value); assert.equal(value.error.code, code) + assert.equal(fs.readFileSync(file, 'utf8'), text); assert.deepEqual(snapshot(root), before) + } + const denied = request('edit_many', args, { root }); denied.deny_roots = [file] + const refusal = await send(denied); noEffects(refusal); assert.equal(refusal.error.code, 'CREDENTIAL_DENIED') + assert.deepEqual(snapshot(root), before) + }) +}) +report('file-batching-worker') From 8c6313872c32b9e99e5653433fd0f1fa24fb8e41 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:53:26 +0800 Subject: [PATCH 011/118] feat(file): [5b/16] batch bounded reads and atomic edits --- native/crates/ops-files/src/file.rs | 75 ++------ native/crates/ops-files/src/file_many.rs | 63 +++++++ native/crates/ops-files/src/file_output.rs | 64 +++++++ native/crates/ops-files/src/file_single.rs | 2 +- native/src/lib.rs | 2 +- native/src/main.rs | 2 +- test/file-batching-worker.test.mjs | 208 +++++++++++++++++++++ 7 files changed, 357 insertions(+), 59 deletions(-) create mode 100644 native/crates/ops-files/src/file_many.rs create mode 100644 native/crates/ops-files/src/file_output.rs create mode 100644 test/file-batching-worker.test.mjs diff --git a/native/crates/ops-files/src/file.rs b/native/crates/ops-files/src/file.rs index f4353856..f7be283e 100644 --- a/native/crates/ops-files/src/file.rs +++ b/native/crates/ops-files/src/file.rs @@ -46,7 +46,8 @@ pub fn dispatch(request: &Request) -> Result { if let Some(raw) = request.args.get("path").and_then(Value::as_str) { request.authorize_path(Path::new(raw))?; } match request.tool.as_str() { "inspect_local_file" => inspect(request), - "stat" | "diff" | "write_file" | "edit_file" | "copy" | "move" | "remove" => single::run(request), + "read_many" => many::read_many(request), + "stat" | "diff" | "write_file" | "edit_file" | "edit_many" | "copy" | "move" | "remove" => single::run(request), _ => Err(Failure::new("INVALID_ARGUMENT", "Unknown file capability")), }.map_err(|mut error| { error.message = format!("{}: {}", request.tool, error.message); error }) } @@ -66,68 +67,28 @@ pub fn read_bytes(path: &Path) -> Result> { if bytes.len() as u64 > MAX_FILE_BYTES { return Err(Failure::new("OUTPUT_LIMIT", "File exceeds the bounded read budget")); } Ok(bytes) } -/// What one character costs in serde_json's encoding. -fn encoded_cost(character: char) -> usize { - match character { - '"' | '\\' | '\n' | '\r' | '\t' | '\u{8}' | '\u{c}' => 2, - c if (c as u32) < 0x20 => 6, - c => c.len_utf8(), - } -} -/// The longest prefix of `text` whose encoding fits `budget` bytes, cut on a -/// character boundary so the result is still valid UTF-8. -fn truncate_to_encoded(text: &str, budget: usize) -> (String, bool) { - let mut kept = String::new(); let mut used = 0usize; - for character in text.chars() { let cost = encoded_cost(character); if used + cost > budget { return (kept, true); } used += cost; kept.push(character); } - (kept, false) -} -/// `offset` is how many units were skipped, counted from zero the way `glob` and -/// `grep` count their own `offset`. `next_base` is the value a caller must pass -/// back to resume, which is its own paging base rather than the skipped count: -/// `inspect` declares a 1-based `offset` (like its hex rows), so deriving the -/// next page from the skipped count re-read the last returned line on every page. +#[path = "file_output.rs"] +mod output; +#[path = "file_many.rs"] +mod many; + +/// Reserve space for metadata added by search callers after this shared builder. pub fn result(items: Vec, total: usize, offset: usize, next_base: usize, matched: usize, truncated: bool) -> Value { - // Spend the response budget on the serialized items. Returning more than the - // host will collect is not a bigger answer, it is a failed read: the host - // truncates stdout, reports OUTPUT_LIMIT and declares the effect unknown, so - // the caller loses the content and is told something may have changed. - let budget = MAX_OUTPUT_BYTES.saturating_sub(ENVELOPE_OVERHEAD); - let mut kept: Vec = Vec::new(); let mut used = 0usize; let mut stopped = false; - for item in items { - let size = serde_json::to_string(&item).map_or(0, |encoded| encoded.len()) + 1; - if used + size <= budget { used += size; kept.push(item); continue; } - // This item does not fit whole. Keep a bounded prefix of its text rather - // than returning nothing at all, so a file holding one long line still - // answers with its beginning; every remaining item is omitted and counted - // as such. Reading past the cut is the hex view's job, which pages by - // 16-byte rows. - let whole = item.get("text").and_then(Value::as_str); - let room = budget.saturating_sub(used).saturating_sub(96); - let prefix = whole.map(|text| truncate_to_encoded(text, room)); - if let Some((text, cut)) = prefix { - if !text.is_empty() { - let mut trimmed = item.clone(); - trimmed["text"] = Value::String(text); - trimmed["bytesTotal"] = json!(whole.map_or(0, |text| text.len())); - trimmed["truncatedInLine"] = json!(cut); - kept.push(trimmed); - } - } - stopped = true; - break; - } - let returned = kept.len(); let cut = truncated || stopped; - json!({"status":"completed","phase":"verify","changed":0,"matched":matched,"skipped":offset,"failed":0,"complete":!cut && offset + returned >= total,"truncated":cut,"omitted":total.saturating_sub(offset + returned),"items":kept,"total":total,"nextOffset":next_base + returned}) + output::bounded(items, total, offset, next_base, matched, truncated, json!({}), MAX_OUTPUT_BYTES - ENVELOPE_OVERHEAD) } fn inspect(request: &Request) -> Result { let args: InspectArgs = serde_json::from_value(request.args.clone())?; + inspect_args(args, MAX_OUTPUT_BYTES, false) +} +fn inspect_args(args: InspectArgs, budget: usize, include_path: bool) -> Result { if args.limit == 0 || args.limit > 10_000 { return Err(Failure::new("INVALID_ARGUMENT", "inspect limit is 1-10000")); } if !args.encoding.eq_ignore_ascii_case("utf-8") { return Err(Failure::new("ERR_INVALID_ENCODING", "Text inspection supports UTF-8 only")); } let path = target(&args.path, false)?; if !path.is_file() { return Err(Failure::new("INVALID_ARGUMENT", "path is a directory; use ops_glob or ops_grep")); } let bytes = read_bytes(&path)?; - let facts = content_facts(&bytes); + let mut facts = content_facts(&bytes); + if include_path { facts["path"] = json!(path); } if bytes.len() as u64 > MAX_FILE_BYTES { return Err(Failure::new("OUTPUT_LIMIT", "file exceeds the inspect size budget")); } if args.view == "hex" { // `offset` means the same thing in both views: the 1-based unit to start @@ -146,9 +107,11 @@ fn inspect(request: &Request) -> Result { // str::lines splits physical LF/CRLF, never bare CR or Unicode separators. // Empty files have zero lines; a final LF does not create a phantom line. let lines: Vec<_> = text.lines().enumerate().skip(args.offset.saturating_sub(1)).take(args.limit).map(|(i, line)| json!({"path":path,"line":i+1,"text":line})).collect(); - let mut output = result(lines, text.lines().count(), args.offset.saturating_sub(1), args.offset, text.lines().count(), false); - for key in ["revision", "bytes", "lineCount"] { output[key] = facts[key].clone(); } - Ok(output) + let value = output::bounded(lines, text.lines().count(), args.offset.saturating_sub(1), args.offset, text.lines().count(), false, facts, budget); + if output::size(&value) + 1 > budget { + return Err(Failure::new("OUTPUT_LIMIT", "File metadata exceeds the remaining output budget")); + } + Ok(value) } /// A POSIX ACL is exposed as this attribute, so preserving ACLs needs no libacl. #[cfg(unix)] diff --git a/native/crates/ops-files/src/file_many.rs b/native/crates/ops-files/src/file_many.rs new file mode 100644 index 00000000..18c7c853 --- /dev/null +++ b/native/crates/ops-files/src/file_many.rs @@ -0,0 +1,63 @@ +//! Multiple read-only file observations in one process, with one aggregate byte cap. +use super::*; +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ReadManyArgs { files: Vec } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ReadRange { + path: String, + #[serde(default = "one")] + offset: usize, + #[serde(default = "two_hundred")] + limit: usize, +} +const NEXT_ACTION: &str = "Resume remaining files from nextIndex. Follow each file's nextOffset for more lines; for truncatedInLine use that file's hex inspection guidance."; + +pub(super) fn read_many(request: &Request) -> Result { + let args: ReadManyArgs = serde_json::from_value(request.args.clone())?; + if args.files.is_empty() || args.files.len() > 32 { + return Err(Failure::new("INVALID_ARGUMENT", "read_many accepts 1-32 files")); + } + if args.files.iter().any(|file| file.offset == 0 || file.limit == 0 || file.limit > 10_000) { + return Err(Failure::new("INVALID_ARGUMENT", "read_many offset starts at 1 and limit is 1-10000")); + } + let total = args.files.len(); + // Keep the longest possible aggregate fields reserved before reading any file. + let mut value = json!({"status":"completed","phase":"verify","changed":0,"matched":total,"failed":total,"complete":false,"truncated":false,"effectState":"none","items":[],"total":total,"nextIndex":total,"pending":total,"nextAction":NEXT_ACTION}); + let mut room = MAX_OUTPUT_BYTES.saturating_sub(output::size(&value) + 1); + let mut items = Vec::new(); + let mut failed = 0; + let mut complete = true; + let mut truncated = false; + for file in args.files { + // Stop before touching another file when no meaningful receipt can fit. + if room < 1024 { break; } + let observed = request.authorize_path(Path::new(&file.path)).and_then(|()| { + inspect_args(InspectArgs { path: file.path.clone(), offset: file.offset, limit: file.limit, encoding: utf8(), view: text() }, room.saturating_sub(1), true) + }); + let item = match observed { + Ok(item) => item, + Err(error) => json!({"path":file.path,"status":"error","complete":false,"items":[],"error":error}), + }; + let size = output::size(&item) + 1; + if size > room { break; } // this input remains pending; never discard a receipt silently + room -= size; + if item.get("error").is_some() { failed += 1; } + complete &= item["complete"] == true; + truncated |= item["truncated"] == true; + items.push(item); + } + let returned = items.len(); + complete &= returned == total; + value["status"] = json!(if complete { "completed" } else { "partial" }); + value["complete"] = json!(complete); + value["truncated"] = json!(truncated || returned < total); + value["matched"] = json!(returned - failed); + value["failed"] = json!(failed); + value["nextIndex"] = json!(returned); + value["pending"] = json!(total - returned); + value["items"] = json!(items); + if complete { value.as_object_mut().expect("result object").remove("nextAction"); } + Ok(value) +} diff --git a/native/crates/ops-files/src/file_output.rs b/native/crates/ops-files/src/file_output.rs new file mode 100644 index 00000000..d5820753 --- /dev/null +++ b/native/crates/ops-files/src/file_output.rs @@ -0,0 +1,64 @@ +//! Serialized-byte accounting shared by inspection, search, and batched reads. +use super::*; +const HEX_RECOVERY: &str = "A line was cut by the output budget. Inspect this file with view=hex and page its 16-byte rows; nextOffset retains the incomplete line, so text paging alone cannot recover its suffix."; + +fn encoded_cost(character: char) -> usize { + match character { + '"' | '\\' | '\n' | '\r' | '\t' | '\u{8}' | '\u{c}' => 2, + c if (c as u32) < 0x20 => 6, + c => c.len_utf8(), + } +} +fn prefix(text: &str, budget: usize) -> &str { + let mut used = 0; + for (index, character) in text.char_indices() { + used += encoded_cost(character); + if used > budget { return &text[..index]; } + } + text +} +pub(super) fn size(value: &Value) -> usize { + serde_json::to_vec(value).expect("JSON value is serializable").len() +} +/// `offset` counts skipped units; `next_base` is the caller's resumption base. +/// The envelope reserves its longest possible flags, counters, and recovery advice. +/// Each candidate includes its actual escaped path and truncation metadata before +/// text receives any room. No fixed estimate for a path/header can overflow this cap. +#[allow(clippy::too_many_arguments)] +pub(super) fn bounded(items: Vec, total: usize, offset: usize, next_base: usize, matched: usize, truncated: bool, facts: Value, budget: usize) -> Value { + let mut value = json!({"status":"completed","phase":"verify","changed":0,"matched":matched,"skipped":offset,"failed":0,"complete":false,"truncated":false,"omitted":total,"items":[],"total":total,"nextOffset":next_base.saturating_add(items.len()),"nextAction":HEX_RECOVERY}); + value.as_object_mut().expect("result object").extend(facts.as_object().expect("facts object").clone()); + let mut room = budget.saturating_sub(size(&value) + 1); // trailing worker newline + let mut kept = Vec::new(); + let mut stopped = false; + let mut incomplete_line = false; + for mut item in items { + let cost = size(&item) + 1; // array comma (also reserved for the first item) + if cost <= room { room -= cost; kept.push(item); continue; } + if let Some(whole) = item.get("text").and_then(Value::as_str).map(str::to_owned) { + item["text"] = json!(""); + item["bytesTotal"] = json!(whole.len()); + item["bytesOmitted"] = json!(whole.len()); + item["truncatedInLine"] = json!(true); + let header = size(&item) + 1; + let text = prefix(&whole, room.saturating_sub(header)); + if !text.is_empty() && header <= room { + item["bytesOmitted"] = json!(whole.len() - text.len()); + item["text"] = json!(text); + kept.push(item); + incomplete_line = true; + } + } + stopped = true; + break; + } + let consumed = kept.len().saturating_sub(usize::from(incomplete_line)); + let cut = truncated || stopped; + value["complete"] = json!(!cut && offset.saturating_add(consumed) >= total); + value["truncated"] = json!(cut); + value["omitted"] = json!(total.saturating_sub(offset.saturating_add(consumed))); + value["nextOffset"] = json!(next_base.saturating_add(consumed)); + value["items"] = json!(kept); + if !incomplete_line { value.as_object_mut().expect("result object").remove("nextAction"); } + value +} diff --git a/native/crates/ops-files/src/file_single.rs b/native/crates/ops-files/src/file_single.rs index 5a3b1af6..5b8fb197 100644 --- a/native/crates/ops-files/src/file_single.rs +++ b/native/crates/ops-files/src/file_single.rs @@ -71,7 +71,7 @@ pub(super) fn run(request: &Request) -> Result { |_| Ok(args.content.clone().into_bytes()), ) } - "edit_file" => { + "edit_file" | "edit_many" => { let args: EditArgs = serde_json::from_value(request.args.clone())?; valid_revision(&args.expected_revision, false)?; write_single( diff --git a/native/src/lib.rs b/native/src/lib.rs index f3938bb0..bf39e7aa 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -25,7 +25,7 @@ pub fn dispatch(request: Request) -> Result { // File reads/searches do not create or consult the durable audit store. // This is important for read-only sessions and keeps observation separate // from mutation audit records. - if matches!(request.tool.as_str(), "inspect_local_file" | "glob" | "grep" | "replace" | "write_file" | "edit_file" | "diff" | "stat" | "copy" | "move" | "remove") { + if matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "glob" | "grep" | "replace" | "write_file" | "edit_file" | "edit_many" | "diff" | "stat" | "copy" | "move" | "remove") { request.root_identity = String::new(); return if matches!(request.tool.as_str(), "glob" | "grep" | "replace") { ops_search::dispatch(&request) diff --git a/native/src/main.rs b/native/src/main.rs index 0dbf8803..ea400a86 100644 --- a/native/src/main.rs +++ b/native/src/main.rs @@ -4,7 +4,7 @@ use serde_json::json; use std::io::{Read, Write}; fn may_mutate(request: &Request) -> bool { - !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "glob" | "grep" | "diff" | "stat") + !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "glob" | "grep" | "diff" | "stat") && !(request.tool == "result" && request.args.get("action").and_then(serde_json::Value::as_str) == Some("page")) } fn main() { diff --git a/test/file-batching-worker.test.mjs b/test/file-batching-worker.test.mjs new file mode 100644 index 00000000..d7940cf7 --- /dev/null +++ b/test/file-batching-worker.test.mjs @@ -0,0 +1,208 @@ +/** Real-worker read_many observations and exact single-file edit_many semantics. */ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { callTool, callWorker, request, envelopeOf, requireWorker } from './lib/boundary.mjs' +requireWorker('file-batching-worker') +const hash = value => createHash('sha256').update(value).digest('hex') +const run = async (root, tool, args, options = {}) => envelopeOf(await callTool(tool, args, { root, ...options }), assert) +const send = async input => envelopeOf(await callWorker(JSON.stringify(input)), assert) +const snapshot = root => fs.readdirSync(root, { recursive: true }).sort() +const noEffects = body => { + assert.equal(body.status, 'error', JSON.stringify(body)) + assert.equal(body.error.effectState, 'none') + assert.equal(body.error.retryable, false) +} + +await test('four files retain exact ranges, LF facts, revisions, and empty-file completeness', async () => { + await withTempDir('many-ranges', async root => { + const texts = ['one\r\ntwo\rthree\nlast\n', '', '\n', 'first\nsecond\nthird'] + const files = texts.map((text, index) => { + const file = path.join(root, `${index}.txt`); fs.writeFileSync(file, text); return { path: file } + }) + const body = await run(root, 'read_many', { files: [{ ...files[0], offset: 2, limit: 1 }, files[1], files[2], { ...files[3], offset: 2 }] }, { readOnly: true }) + assert.equal(body.status, 'partial'); assert.equal(body.complete, false) + assert.equal(body.items.length, 4); assert.equal(body.nextIndex, 4); assert.equal(body.pending, 0) + assert.equal(body.failed, 0); assert.equal(body.effectState, 'none') + for (let index = 0; index < 4; index++) { + const file = body.items[index] + assert.equal(file.path, files[index].path) + assert.equal(file.revision, hash(texts[index])); assert.equal(file.bytes, Buffer.byteLength(texts[index])) + assert.equal(file.lineCount, [3, 0, 1, 3][index]) + } + assert.deepEqual(body.items[0].items.map(({ line, text }) => ({ line, text })), [{ line: 2, text: 'two\rthree' }]) + assert.equal(body.items[0].complete, false); assert.equal(body.items[0].nextOffset, 3) + assert.deepEqual(body.items[1].items, []); assert.equal(body.items[1].complete, true) + assert.deepEqual(body.items[2].items.map(item => item.text), ['']) + assert.deepEqual(body.items[3].items.map(item => item.line), [2, 3]); assert.equal(body.items[3].complete, true) + const complete = await run(root, 'read_many', { files }) + assert.equal(complete.status, 'completed'); assert.equal(complete.complete, true) + assert.equal(complete.pending, 0); assert.equal(complete.nextAction, undefined) + assert.deepEqual(snapshot(root), ['0.txt', '1.txt', '2.txt', '3.txt']) + }) +}) + +await test('descriptors are strict and bounded before observations, with a 200-line default', async () => { + await withTempDir('many-bounds', async root => { + const file = path.join(root, 'file.txt'); fs.writeFileSync(file, `${'line\n'.repeat(201)}`) + const first = await run(root, 'read_many', { files: [{ path: file }] }) + assert.equal(first.items[0].items.length, 200); assert.equal(first.items[0].nextOffset, 201) + assert.equal(first.complete, false) + const last = await run(root, 'read_many', { files: [{ path: file, offset: 201 }] }) + assert.equal(last.complete, true); assert.equal(last.items[0].items.length, 1) + const cases = [ + { files: [] }, { files: Array(33).fill({ path: file }) }, { files: [{ path: file }], unknown: true }, + ...[0, -1, 1.5].map(offset => ({ files: [{ path: file }, { path: file, offset }] })), + ...[0, -1, 10001, 1.5].map(limit => ({ files: [{ path: file }, { path: file, limit }] })), + ...['encoding', 'view', 'unknown'].map(key => ({ files: [{ path: file, [key]: 'text' }] })), + ] + for (const args of cases) { + const value = await run(root, 'read_many', args) + noEffects(value); assert.equal(value.error.code, 'INVALID_ARGUMENT'); assert.equal(value.items, undefined) + } + const maximum = await run(root, 'read_many', { files: Array(32).fill({ path: file, offset: 201, limit: 10000 }) }) + assert.equal(maximum.complete, true); assert.equal(maximum.items.length, 32) + assert.deepEqual(snapshot(root), ['file.txt']) + }) +}) + +await test('per-file missing, directory, UTF-8, and size failures preserve successful neighbors', async () => { + await withTempDir('many-errors', async root => { + const good = path.join(root, 'good.txt'); fs.writeFileSync(good, 'visible\n') + const binary = path.join(root, 'binary'); fs.writeFileSync(binary, Buffer.from([255, 254])) + const large = path.join(root, 'large'); const fd = fs.openSync(large, 'w') + try { fs.ftruncateSync(fd, 32 * 1024 * 1024 + 1) } finally { fs.closeSync(fd) } + const paths = [good, path.join(root, 'missing'), root, binary, large, good] + const before = snapshot(root) + const body = await run(root, 'read_many', { files: paths.map(path => ({ path })) }, { readOnly: true }) + assert.equal(body.status, 'partial'); assert.equal(body.complete, false) + assert.equal(body.items.length, 6); assert.equal(body.failed, 4); assert.equal(body.pending, 0) + assert.equal(body.items[0].items[0].text, 'visible'); assert.equal(body.items[5].revision, hash('visible\n')) + for (let index = 1; index <= 4; index++) { + const item = body.items[index]; noEffects(item) + assert.equal(item.path, paths[index]); assert.equal(item.complete, false); assert.deepEqual(item.items, []) + assert.equal(item.error.code, ['NOT_FOUND', 'INVALID_ARGUMENT', 'ERR_INVALID_ENCODING', 'OUTPUT_LIMIT'][index - 1]) + } + assert.match(body.items[3].error.nextAction, /hex/) + assert.deepEqual(snapshot(root), before) + }) +}) + +await test('each batched file rechecks credential roots, names, and no-follow without read artifacts', async () => { + await withTempDir('many-authority', async root => { + const good = path.join(root, 'good.txt'); fs.writeFileSync(good, 'safe') + const secret = path.join(root, 'vault'); fs.mkdirSync(secret) + fs.writeFileSync(path.join(secret, 'token'), 'DO_NOT_DISCLOSE') + const credential = path.join(root, '.env'); fs.writeFileSync(credential, 'SECRET') + const link = path.join(root, 'linked'); fs.symlinkSync(secret, link, process.platform === 'win32' ? 'junction' : 'dir') + const files = [good, path.join(secret, 'token'), credential, path.join(link, 'token')].map(path => ({ path })) + const before = snapshot(root) + for (const readOnly of [false, true]) { + const input = request('read_many', { files }, { root, readOnly, writeRoots: [] }) + input.deny_roots = [secret]; input.deny_credential_names = true + const body = await send(input) + assert.equal(body.items[0].items[0].text, 'safe') + assert.equal(body.items[1].error.code, 'CREDENTIAL_DENIED') + assert.equal(body.items[2].error.code, 'CREDENTIAL_DENIED') + assert.equal(body.items[3].error.code, 'PATH_RESTRICTED') + assert.equal(body.complete, false); assert.equal(body.failed, 3) + assert.doesNotMatch(JSON.stringify(body), /DO_NOT_DISCLOSE|SECRET/) + for (const item of body.items.slice(1)) noEffects(item) + assert.deepEqual(snapshot(root), before) + } + }) +}) + +await test('one escaped huge line retains its prefix and receipts within the aggregate 96KiB cap', async () => { + await withTempDir('many-budget', async root => { + // The long directory/path is part of the encoded budget, not an assumed 96-byte header. + const directory = path.join(root, 'p'.repeat(90)); fs.mkdirSync(directory) + const file = path.join(directory, `${'f'.repeat(90)}.txt`) + const text = '\0"\\中'.repeat(100000); fs.writeFileSync(file, text) + const files = [file, file, file, file].map(path => ({ path })) + for (const tool of ['read_many', 'inspect_local_file']) { + const raw = await callTool(tool, tool === 'read_many' ? { files } : { path: file }, { root }) + assert.ok(Buffer.byteLength(raw.stdout) <= 96 * 1024, `${tool}: ${Buffer.byteLength(raw.stdout)} bytes`) + const body = envelopeOf(raw, assert) + const observed = tool === 'read_many' ? body.items[0] : body + assert.equal(observed.complete, false); assert.equal(observed.truncated, true) + assert.equal(observed.nextOffset, 1, 'a partial line is not consumed') + assert.equal(observed.omitted, 1) + assert.equal(observed.revision, hash(text)); assert.equal(observed.bytes, Buffer.byteLength(text)); assert.equal(observed.lineCount, 1) + assert.equal(observed.items.length, 1) + const item = observed.items[0] + assert.equal(item.truncatedInLine, true); assert.ok(item.text.length > 0) + assert.ok(text.startsWith(item.text)); assert.equal(item.bytesTotal, Buffer.byteLength(text)) + assert.equal(item.bytesOmitted, Buffer.byteLength(text) - Buffer.byteLength(item.text)) + assert.match(observed.nextAction, /view=hex/) + if (tool === 'read_many') { + assert.equal(body.status, 'partial'); assert.equal(body.nextIndex, 1); assert.equal(body.pending, 3) + assert.equal(body.items.length, 1); assert.equal(body.complete, false) + } + } + const tail = path.join(root, 'tail.txt'); fs.writeFileSync(tail, 'remaining') + const mixedRaw = await callTool('read_many', { files: [{ path: tail }, { path: file }, { path: tail }] }, { root }) + assert.ok(Buffer.byteLength(mixedRaw.stdout) <= 96 * 1024) + const mixed = envelopeOf(mixedRaw, assert) + assert.equal(mixed.items[0].complete, true); assert.equal(mixed.items[0].items[0].text, 'remaining') + assert.equal(mixed.items[1].items[0].truncatedInLine, true) + assert.equal(mixed.nextIndex, 2); assert.equal(mixed.pending, 1); assert.equal(mixed.complete, false) + const resumed = await run(root, 'read_many', { files: [{ path: tail }] }) + assert.equal(resumed.complete, true); assert.equal(resumed.items[0].items[0].text, 'remaining') + }) +}) + +await test('transport clipping identifies read_many as read-only even in a writable session', async () => { + await withTempDir('many-transport', async root => { + const file = path.join(root, 'large.txt'); fs.writeFileSync(file, 'x'.repeat(2000)) + const raw = await callWorker(JSON.stringify({ version: 1, requestId: 'many-output', component: 'file', budget: { timeoutMs: 30000, maxBytes: 1024 }, request: request('read_many', { files: [{ path: file }] }, { root, readOnly: false }) })) + const body = envelopeOf(raw, assert) + assert.ok(Buffer.byteLength(raw.stdout) <= 1024) + noEffects(body); assert.equal(body.error.code, 'OUTPUT_LIMIT') + assert.deepEqual(snapshot(root), ['large.txt']) + }) +}) + +await test('edit_many applies unique nonoverlapping edits once and shares edit_file dry-run receipts', async () => { + await withTempDir('many-edits', async root => { + const file = path.join(root, 'file.txt'); const text = 'alpha\nbeta\ngamma\n'; fs.writeFileSync(file, text) + const args = { path: file, expectedRevision: hash(text), edits: [{ oldText: 'alpha', newText: 'A' }, { oldText: 'gamma', newText: 'G' }] } + const before = snapshot(root) + const dry = await run(root, 'edit_many', { ...args, dryRun: true }) + const legacy = await run(root, 'edit_file', { ...args, dryRun: true }) + assert.deepEqual(dry, legacy); assert.equal(dry.complete, true) + assert.equal(fs.readFileSync(file, 'utf8'), text); assert.deepEqual(snapshot(root), before) + const value = await run(root, 'edit_many', args) + assert.equal(value.status, 'completed'); assert.equal(value.changed, 1); assert.equal(value.complete, true) + assert.equal(value.items[0].revisionBefore, hash(text)); assert.equal(value.revision, hash('A\nbeta\nG\n')) + assert.equal(fs.readFileSync(file, 'utf8'), 'A\nbeta\nG\n') + assert.equal(value.items.length, 1) + assert.equal(fs.existsSync(path.join(root, '.dsh-ops')), false) + }) +}) + +await test('edit_many CAS, ambiguity, overlap, and authority refusals leave no effects or sidecars', async () => { + await withTempDir('many-edits-refused', async root => { + const file = path.join(root, 'file.txt'); const text = 'alpha beta gamma alpha'; fs.writeFileSync(file, text) + const args = { path: file, expectedRevision: hash(text), edits: [{ oldText: 'beta', newText: 'B' }, { oldText: 'gamma', newText: 'G' }] } + const before = snapshot(root) + const cases = [ + [{ ...args, expectedRevision: '0'.repeat(64) }, {}, 'REVISION_CONFLICT'], + [{ ...args, edits: [...args.edits, { oldText: 'missing', newText: 'M' }] }, {}, 'EDIT_CONFLICT'], + [{ ...args, edits: [...args.edits, { oldText: 'alpha', newText: 'A' }] }, {}, 'EDIT_CONFLICT'], + [{ ...args, edits: [args.edits[0], { oldText: 'beta gamma', newText: 'BG' }] }, {}, 'EDIT_CONFLICT'], + [args, { readOnly: true }, 'POLICY_DENIED'], [args, { writeRoots: [] }, 'POLICY_DENIED'], + [{ files: [args] }, {}, 'INVALID_ARGUMENT'], + ] + for (const [input, options, code] of cases) { + const value = await run(root, 'edit_many', input, options) + noEffects(value); assert.equal(value.error.code, code) + assert.equal(fs.readFileSync(file, 'utf8'), text); assert.deepEqual(snapshot(root), before) + } + const denied = request('edit_many', args, { root }); denied.deny_roots = [file] + const refusal = await send(denied); noEffects(refusal); assert.equal(refusal.error.code, 'CREDENTIAL_DENIED') + assert.deepEqual(snapshot(root), before) + }) +}) +report('file-batching-worker') From 110616b9ea0981ab86ad5d58b830a4b271dc28cf Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:00:08 +0800 Subject: [PATCH 012/118] feat(search): expose typed continuation and Native coverage --- docs/search-coverage.md | 2 ++ ...06\347\233\226\350\214\203\345\233\264.md" | 2 ++ src/capabilities/file.ts | 18 +++++++++++++-- src/capabilities/registry.ts | 4 ++-- src/file/contract.ts | 4 ++-- src/kernel/errors.ts | 1 + src/modes/native/render.ts | 2 ++ test/capabilities.test.mjs | 4 ++-- test/file-contract.test.mjs | 4 ++-- test/file-sdk-render.test.mjs | 23 +++++++++++++++++++ test/grep-multiline.test.mjs | 3 ++- test/kernel-errors.test.mjs | 2 +- 12 files changed, 57 insertions(+), 12 deletions(-) diff --git a/docs/search-coverage.md b/docs/search-coverage.md index d4f17317..94a11f01 100644 --- a/docs/search-coverage.md +++ b/docs/search-coverage.md @@ -21,6 +21,8 @@ inspect `scanComplete`, `pageComplete` and `complete` before treating it as exha `grep` count/files modes page matching files while `matchedTotal` counts matches; the legacy `total` counts page items. `total` and `omitted` are absent when the scan is incomplete. Existing `matched`, `matches` and `files` are observed counts, never a replacement for the coverage flags. +The Native renderer puts coverage and recovery guidance before matching text. The published +argument schemas include `continuation`; the typed output declares the report fields. An explicit `limit` can produce `scanComplete: true`, `pageComplete: true`, `truncatedBy: row-limit` and a continuation. Legacy `complete` becomes true only on the final fully displayed page of a complete scan. diff --git "a/docs/\346\220\234\347\264\242\350\246\206\347\233\226\350\214\203\345\233\264.md" "b/docs/\346\220\234\347\264\242\350\246\206\347\233\226\350\214\203\345\233\264.md" index 750075e5..aa7f3482 100644 --- "a/docs/\346\220\234\347\264\242\350\246\206\347\233\226\350\214\203\345\233\264.md" +++ "b/docs/\346\220\234\347\264\242\350\246\206\347\233\226\350\214\203\345\233\264.md" @@ -1,5 +1,7 @@ # 搜索覆盖范围与续页 +Native 渲染会在匹配文本之前显示扫描覆盖范围与恢复建议;公开参数声明 `continuation`,结构化输出声明完整报告字段。 + English: [search-coverage.md](search-coverage.md). `glob` 和 `grep` 在原有条目之外返回 `SearchExecutionReport`。旧版信封中的 diff --git a/src/capabilities/file.ts b/src/capabilities/file.ts index a147c273..456258fb 100644 --- a/src/capabilities/file.ts +++ b/src/capabilities/file.ts @@ -27,6 +27,7 @@ export const PARAMETERS = Object.freeze({ sort: { type: 'string', enum: ['path', 'modified'] }, offset: integer, limit: range(1, 10000, 'Default 100.'), + continuation: { ...string, description: 'Opaque cursor from a complete scan. Keep query options unchanged; omit offset or use nextOffset. SEARCH_STALE requires a fresh query.' }, }, ['pattern']), grep: bounded({ path: string, @@ -40,6 +41,7 @@ export const PARAMETERS = Object.freeze({ multiline: { type: 'boolean' }, offset: integer, limit: range(1, 10000, 'Default 100.'), + continuation: { ...string, description: 'Opaque cursor from a complete scan. Keep query options unchanged; omit offset or use nextOffset. SEARCH_STALE requires a fresh query.' }, }, ['pattern']), stat: bounded({ path: string }, ['path']), diff: bounded({ path: string, content: string, offset: integer, limit: range(1, 1000, 'Default 100.') }, ['path', 'content']), @@ -72,12 +74,24 @@ export const OUTPUT: JsonSchemaNode = { }, } +export const SEARCH_OUTPUT: JsonSchemaNode = { + ...OUTPUT, properties: { + ...OUTPUT.properties, + scanComplete: { type: 'boolean' }, pageComplete: { type: 'boolean' }, + scannedFiles: { type: 'integer' }, skippedFiles: { type: 'integer' }, + skipReasons: { type: 'object' }, matchedObserved: { type: 'integer' }, + matchedTotal: { type: 'integer', description: 'Present only after a complete scan.' }, + truncatedBy: { type: 'string', enum: ['scan-budget', 'output-budget', 'time', 'row-limit', 'none'] }, + continuation: string, nextAction: string, + }, +} + // Failures are returned values, never thrown: a try/catch alone misses them (#44). const RESULT = ' Failures resolve as {status:"error"|"rejected", error.code}; they are not thrown.' export const DESCRIPTIONS = Object.freeze({ inspect_local_file: 'Read bounded UTF-8 text lines or a hex view from path relative to the DSH session cwd. Directories are rejected; use ops_glob or ops_grep.' + RESULT, - glob: 'Find regular files below path. pattern is an ARRAY of globs matched against paths relative to path, e.g. ["**/*.md","!vendor/**"]: "*.md" matches the top level only, "!" excludes, at least one positive entry. Symlinks, reparse points, hidden files and .git are not traversed.' + RESULT, - grep: 'Search UTF-8 files below path. pattern is ONE Rust regex string; filter files with glob, an ARRAY of the same relative globs ("!" excludes). There is no include field. Use summary for aggregate file/match totals, count for per-file counts, content for matching text (default); limit defaults to 100 items. Paging uses 0-based offset/nextOffset. Follow nextOffset until complete:true, but do not loop blindly on truncated/failed scans. Totals cover scanned readable UTF-8 files within file-size limits; truncated scans are not exhaustive. Each page rescans candidates to count totals while materializing only its requested window. Optional line context.' + RESULT, + glob: 'Find regular files below path relative to the current session cwd. pattern is an ARRAY of globs matched against paths relative to path: ["**/*.md","!vendor/**"]; "*.md" matches the top level only, "!" excludes, at least one positive entry. Symlinks, reparse points, hidden files and dependency/build directories are excluded. limit defaults to 100; paging uses 0-based offset/nextOffset. Check scanComplete and pageComplete; matchedTotal exists only after a complete scan. Prefer continuation with unchanged options; SEARCH_STALE means restart. Incomplete scans require a narrower query.' + RESULT, + grep: 'Search UTF-8 files below path relative to the current session cwd. pattern is ONE Rust regex string; filter files with glob, an ARRAY of relative globs ("!" excludes). There is no include field. Use summary for aggregate file/match totals, count for per-file counts, files_with_matches for paths, content for text (default); limit defaults to 100 items. Optional line context. Paging uses 0-based offset/nextOffset; prefer continuation with unchanged options. Each page rescans; SEARCH_STALE means restart. Check scanComplete and pageComplete; matchedTotal exists only after a complete scan, matchedObserved is a lower bound otherwise. skipReasons explains gaps. Narrow incomplete scans instead of blindly paging.' + RESULT, stat: 'Inspect one regular file: SHA-256 revision, bytes, UTF-8 lineCount (null for binary), timestamps and permissions; no content. Missing paths return exists:false, revision:absent.' + RESULT, diff: 'Read-only comparison of one UTF-8 file with content. Returns one prefix/suffix-trimmed replacement hunk (not a minimal diff), paged 0-based offset/limit (default 100). Includes old/new hashes and line counts; no publication.' + RESULT, write_file: 'Create or fully overwrite one UTF-8 file; parents must exist. Required expectedRevision is the observed SHA-256, or absent for create-only. Atomic CAS, short receipt with new revision/bytes/lineCount; dryRun only previews. Darwin nonempty extended ACLs fail closed with METADATA_UNSUPPORTED. Read-only denies even dryRun. Windows preserves owner/group/ordered DACL and supported basic attributes/timestamps; readonly overwrite is refused and unsupported streams/EA/special storage fail with METADATA_UNSUPPORTED. SACL/audit labels are not preserved.' + RESULT, diff --git a/src/capabilities/registry.ts b/src/capabilities/registry.ts index d3d80019..dd512e27 100644 --- a/src/capabilities/registry.ts +++ b/src/capabilities/registry.ts @@ -1,5 +1,5 @@ /** One catalogue for owned tools. This module never registers tools or imports host values. */ -import { PARAMETERS as fileParameters, DESCRIPTIONS as fileDescriptions, OUTPUT as fileOutput } from './file.js' +import { PARAMETERS as fileParameters, DESCRIPTIONS as fileDescriptions, OUTPUT as fileOutput, SEARCH_OUTPUT as searchOutput } from './file.js' import { PARAMETERS as ptcParameters, DESCRIPTIONS as ptcDescriptions, OUTPUT as ptcOutput } from './ptc.js' import type { CapabilityMetadata, Component, SchemaCapability } from './types.js' @@ -14,7 +14,7 @@ export const FILE_CAPABILITIES = Object.freeze(fileOrder.map(rawName => Object.f rawName, component: 'file' as const, presentations: allPresentations, availability: 'resident' as const, mutates: fileMutations.has(rawName), concurrencySafe: !fileMutations.has(rawName), - parameters: fileParameters[rawName], description: fileDescriptions[rawName], output: fileOutput, + parameters: fileParameters[rawName], description: fileDescriptions[rawName], output: rawName === 'glob' || rawName === 'grep' ? searchOutput : fileOutput, } satisfies SchemaCapability))) export const PTC_CAPABILITIES = Object.freeze((Object.keys(ptcParameters) as PtcToolName[]).map(rawName => Object.freeze({ rawName, component: 'ptc' as const, presentations: Object.freeze(['ptc'] as const), diff --git a/src/file/contract.ts b/src/file/contract.ts index 40d8a232..44b70151 100644 --- a/src/file/contract.ts +++ b/src/file/contract.ts @@ -2,7 +2,7 @@ import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import { publicToolName } from '../policy.js' import { capability } from '../capabilities/registry.js' import type { FileToolName } from '../capabilities/registry.js' -import { PARAMETERS, DESCRIPTIONS, OUTPUT } from '../capabilities/file.js' +import { PARAMETERS, DESCRIPTIONS, OUTPUT, SEARCH_OUTPUT } from '../capabilities/file.js' export { FILE_TOOLS } from '../capabilities/registry.js' export { PARAMETERS, DESCRIPTIONS, OUTPUT } import { renderNative } from '../modes/native/render.js' @@ -33,7 +33,7 @@ export function definition(rawName: FileToolName, execute: ToolDefinition['execu description: DESCRIPTIONS[rawName], parameters: PARAMETERS[rawName], ...(capability(rawName)?.concurrencySafe ? { isConcurrencySafe: () => true } : {}), - output: { schema: OUTPUT, render: (args, value) => [{ type: 'text', text: renderNative(rawName, args, value) }] }, + output: { schema: rawName === 'glob' || rawName === 'grep' ? SEARCH_OUTPUT : OUTPUT, render: (args, value) => [{ type: 'text', text: renderNative(rawName, args, value) }] }, execute, } } diff --git a/src/kernel/errors.ts b/src/kernel/errors.ts index 7a83e707..f3499210 100644 --- a/src/kernel/errors.ts +++ b/src/kernel/errors.ts @@ -16,6 +16,7 @@ export const ERROR_POLICIES: Readonly> = Object.free REVISION_CONFLICT: policy('conflict', 'after-state-change'), INVALID_ARGUMENT: invalid, EDIT_CONFLICT: invalid, + SEARCH_STALE: invalid, NOT_FOUND: invalid, IS_DIRECTORY: invalid, OUTPUT_LIMIT: invalid, diff --git a/src/modes/native/render.ts b/src/modes/native/render.ts index 94f8eb53..6aba3d26 100644 --- a/src/modes/native/render.ts +++ b/src/modes/native/render.ts @@ -131,6 +131,8 @@ function diff(args: Row, value: Row): string[] { function listing(tool: string, args: Row, value: Row): string[] { const output: string[] = [] + if (value.scanComplete !== undefined) output.push(`[Notice: ${facts(value, ['scanComplete', 'pageComplete', 'scannedFiles', 'skippedFiles', 'skipReasons', 'matchedObserved', 'matchedTotal'])}.]`) + if (typeof value.nextAction === 'string') output.push(`Next: ${label(value.nextAction)}`) const items = Array.isArray(value.items) ? value.items : [] for (const item of items) { if (typeof item === 'string') { output.push(label(item)); continue } diff --git a/test/capabilities.test.mjs b/test/capabilities.test.mjs index 95609f8b..1f6ea223 100644 --- a/test/capabilities.test.mjs +++ b/test/capabilities.test.mjs @@ -43,10 +43,10 @@ const snapshot = (names, definition) => names.map(name => { return { name: d.name, description: d.description, parameters: d.parameters, output: d.output.schema, concurrent: d.isConcurrencySafe?.({}) ?? false, timeoutMs: d.timeoutMs ?? null } }) -await test('serialized host schemas, descriptions, ordering and execution metadata match 00237b8', () => { +await test('unchanged file definitions and PTC definitions retain their original serialized contracts', () => { // Captured before this migration, covering every argument/output contract and published description. const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') - assert.equal(hash(snapshot(FILE_TOOLS, fileDefinition)), '8b89dff420471db5b3bd13729cdf8a30c07e8e7636e5d22795ab343b5ae8e267') + assert.equal(hash(snapshot(FILE_TOOLS.filter(name => !['glob', 'grep'].includes(name)), fileDefinition)), 'f32e33306b4052fdb3d19a11e0fe9d65c255a87d2d485de08a9d7b1275e958fc') assert.equal(hash(snapshot(PTC_TOOLS, ptcDefinition)), '74e6a50db799da1aa80c745e18c67be577426cc41441f9d0f8c87673c04f3c43') }) diff --git a/test/file-contract.test.mjs b/test/file-contract.test.mjs index 00234f9f..014838a5 100644 --- a/test/file-contract.test.mjs +++ b/test/file-contract.test.mjs @@ -9,9 +9,9 @@ const fields = tool => Object.fromEntries(Object.entries(PARAMETERS[tool].proper await test('grep, glob and replace parameters are pinned exactly', () => { assert.deepEqual(fields('grep'), { path: 'string', pattern: 'string', glob: 'array', outputMode: 'string', caseInsensitive: 'boolean', - lineNumbers: 'boolean', beforeContext: 'integer', afterContext: 'integer', multiline: 'boolean', offset: 'integer', limit: 'integer', + lineNumbers: 'boolean', beforeContext: 'integer', afterContext: 'integer', multiline: 'boolean', offset: 'integer', limit: 'integer', continuation: 'string', }) - assert.deepEqual(fields('glob'), { path: 'string', pattern: 'array', outputMode: 'string', sort: 'string', offset: 'integer', limit: 'integer' }) + assert.deepEqual(fields('glob'), { path: 'string', pattern: 'array', outputMode: 'string', sort: 'string', offset: 'integer', limit: 'integer', continuation: 'string' }) assert.deepEqual(fields('replace'), { path: 'string', pattern: 'string', replacement: 'string', glob: 'array', maxReplacements: 'integer', dryRun: 'boolean' }) for (const tool of ['grep', 'glob', 'replace']) assert.equal(PARAMETERS[tool].properties.include, undefined) }) diff --git a/test/file-sdk-render.test.mjs b/test/file-sdk-render.test.mjs index 8ff30f75..28cfa290 100644 --- a/test/file-sdk-render.test.mjs +++ b/test/file-sdk-render.test.mjs @@ -174,4 +174,27 @@ await test('routing and native guide distinguish aggregate totals and errors-as- assert.match(guide, /summary.*aggregate/) assert.equal(typeof policy, 'string') }) +await test('search schemas and Native notices expose scan gaps, stable cursors and recovery', () => { + for (const name of ['glob', 'grep']) { + const tool = definition(name, async () => null) + assertSupportedJsonSchema(tool.parameters) + assertSupportedJsonSchema(tool.output.schema) + assert.equal(tool.parameters.properties.continuation.type, 'string') + for (const field of ['scanComplete', 'pageComplete', 'scannedFiles', 'skipReasons', 'matchedObserved', 'matchedTotal', 'continuation']) { + assert.ok(Object.hasOwn(tool.output.schema.properties, field), `${name}.${field}`) + } + const partial = render(name, { scanComplete: false, pageComplete: true, matchedObserved: 2, skippedFiles: 1, + skipReasons: { 'invalid-encoding': 1 }, complete: false, items: [], nextAction: 'Narrow path/glob and start fresh.' }) + assert.match(partial, /scanComplete=false; pageComplete=true/) + assert.match(partial, /skipReasons=.*invalid-encoding/) + assert.match(partial, /matchedObserved=2/) + assert.doesNotMatch(partial, /matchedTotal=/) + assert.match(partial, /Next: Narrow path\/glob/) + const page = render(name, { scanComplete: true, pageComplete: true, matchedTotal: 3, + complete: false, items: [], continuation: 'opaque-cursor', nextOffset: 2 }) + assert.match(page, /matchedTotal=3/) + assert.match(page, /continuation=opaque-cursor/) + } +}) + report('file-sdk-render') diff --git a/test/grep-multiline.test.mjs b/test/grep-multiline.test.mjs index ccda7140..5e074a84 100644 --- a/test/grep-multiline.test.mjs +++ b/test/grep-multiline.test.mjs @@ -172,7 +172,8 @@ await test('one enormous line is materialized once, not once per match', async ( assert.equal(page.items.length, 1, 'the page is one item wide') assert.equal(page.matched, line.length, 'every match is still counted') assert.equal(page.total, line.length, 'and the envelope still knows how many items there are') - assert.equal(page.omitted, line.length - 1) + assert.equal(page.omitted, line.length, 'a partial line remains omitted until its suffix is recovered') + assert.equal(page.nextOffset, 0, 'never skip the partially displayed match') assert.equal(page.complete, false) // The item is the line, cut to the response budget: the host collects 128 KiB of stdout, so the // envelope reports how much text the line had rather than pretending it fits. diff --git a/test/kernel-errors.test.mjs b/test/kernel-errors.test.mjs index 910855b5..d55b3212 100644 --- a/test/kernel-errors.test.mjs +++ b/test/kernel-errors.test.mjs @@ -5,7 +5,7 @@ import { assert, test, report } from './lib/harness.mjs' await test('permission, input, revision and transient failures have distinct advice', () => { for (const code of ['POLICY_DENIED', 'CREDENTIAL_DENIED', 'PATH_RESTRICTED']) assert.deepEqual(errorPolicy(code), { status: 'rejected', retry: 'never' }) assert.deepEqual(errorPolicy('REVISION_CONFLICT'), { status: 'conflict', retry: 'after-state-change' }) - for (const code of ['INVALID_ARGUMENT', 'EDIT_CONFLICT', 'OUTPUT_LIMIT', 'ERR_INVALID_ENCODING']) assert.deepEqual(errorPolicy(code), { status: 'error', retry: 'after-input-change' }) + for (const code of ['INVALID_ARGUMENT', 'EDIT_CONFLICT', 'OUTPUT_LIMIT', 'ERR_INVALID_ENCODING', 'SEARCH_STALE']) assert.deepEqual(errorPolicy(code), { status: 'error', retry: 'after-input-change' }) assert.deepEqual(errorPolicy('JOB_BUSY'), { status: 'error', retry: 'bounded-transient' }) for (const code of ['ACCESS_DENIED', 'AUTHORITY_CHANGED', 'EXECUTOR_UNAVAILABLE']) assert.deepEqual(errorPolicy(code), { status: 'error', retry: 'after-state-change' }) for (const code of ['METADATA_UNSUPPORTED', 'IO_ERROR', 'NEW_CODE', 'toString', '__proto__']) assert.deepEqual(errorPolicy(code), { status: 'error', retry: 'never' }) From c629963c77d128e561bac56c3618eb273ac544a3 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:02:04 +0800 Subject: [PATCH 013/118] feat(file): expose batch reads and atomic edit contracts --- docs/CHANGELOG.md | 8 ++ docs/file-batching.md | 49 ++++++++ ...71\351\207\217\346\223\215\344\275\234.md" | 36 ++++++ dsh-plugin.json | 4 +- src/capabilities/file.ts | 21 +++- src/capabilities/registry.ts | 8 +- src/file/contract.ts | 4 +- src/file/read-many.ts | 22 ++++ src/file/worker.js | 23 +++- src/modes/native/render.ts | 13 ++- src/policy.ts | 2 + test/capabilities.test.mjs | 8 +- test/file-sdk-render.test.mjs | 4 +- test/read-many-host.test.mjs | 110 ++++++++++++++++++ 14 files changed, 291 insertions(+), 21 deletions(-) create mode 100644 docs/file-batching.md create mode 100644 "docs/\346\226\207\344\273\266\346\211\271\351\207\217\346\223\215\344\275\234.md" create mode 100644 src/file/read-many.ts create mode 100644 test/read-many-host.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 233b9230..92fa7d98 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,14 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Read multiple file ranges and apply atomic multi-edits (#261) + +- Add read_many with 1-32 independently reported UTF-8 ranges in one worker call, current-session + paths, credential preflight, per-file revisions/errors and explicit pending-file/line recovery. +- Add edit_many as a single-file atomic CAS edit operation with short receipts. Bound escaped + read output using actual metadata bytes; an incomplete line retains its offset and hex guidance. +- 新增多文件范围读取与单文件原子多段编辑;逐文件报告版本、错误及未读范围,保持权限边界与短回执。 + ### Render Native file results as bounded, readable text (#261) - Show file headings and physical line numbers, concise mutation receipts, and explicit diff --git a/docs/file-batching.md b/docs/file-batching.md new file mode 100644 index 00000000..1dee46a3 --- /dev/null +++ b/docs/file-batching.md @@ -0,0 +1,49 @@ +# Batched file reads and atomic edits + +中文版:[文件批量操作.md](文件批量操作.md)。 + +`ops_read_many` reads 1-32 UTF-8 ranges with one one-shot worker invocation. Every relative +path resolves against the current session cwd on that call. Each entry has `path`, optional +1-based `offset` (default 1), and `limit` from 1 to 10000 (default 200). Unlike the legacy +inspector, offset 0 is invalid. Unknown fields, invalid bounds and malformed descriptors reject +the request. A binary file requires a binary/document reader or `ops_inspect_local_file` with +`view: "hex"`; text is never decoded lossily. + +```json +{"files":[{"path":"src/index.ts","offset":1,"limit":80},{"path":"README.md","offset":10,"limit":40}]} +``` + +Results contain per-file canonical inspection values in `items`, including revision, bytes, +physical line count, numbered lines and completion facts. Empty files have zero lines; CRLF +counts once and a trailing LF creates no phantom line. A missing, unreadable or invalid-encoding +file has an error entry without hiding successful neighbors. The host checks all credential +paths before spawning and refuses the whole request if any are protected; the worker also +checks every path and refuses links/junctions. Read-only calls create no audit or cache files. +Successful observations update the host read-before-edit table using versions captured before +reading; failed and pending entries do not produce observations. + +`nextIndex` is the first unreturned input entry and `pending` counts remaining entries. Resume +those files by sending the remaining slice of the original `files` array. Separately, an included +file's `complete: false` and `nextOffset` describe unread lines in that file. Aggregate `complete` +is true only when every file is fully read and there are no errors or pending entries. A partial +result with `pending: 0` can still require per-file recovery. Check both levels. + +The worker bounds the complete escaped response to 96 KiB, including paths and metadata. Native +content blocks have an additional 64 KiB escaped display cap. A huge line may return a prefix +with `truncatedInLine`, `bytesTotal` and `bytesOmitted`; its offset stays on that incomplete line. +Use hex inspection's 16-byte rows to recover the suffix. A display-cap notice requires repeating +a smaller original range, never advancing past unseen content. Native renders file headings, +physical line numbers, per-file errors and continuation notices; the programmatic value stays +structured. + +`ops_edit_many` applies 1-100 edits to **one** UTF-8 file, atomically. Supply `path`, the exact +SHA-256 `expectedRevision` from a read/stat, and `edits: [{oldText,newText}]`. Each nonempty +`oldText` must occur exactly once in the same original bytes; overlaps, ambiguity or stale +revision reject all edits before publication. Empty `newText` deletes the match. Preserve +CRLF/BOM explicitly. `dryRun: true` previews the same receipt without writing; read-only policy +still rejects mutation tools. This is not a cross-file transaction. + +The operation shares `ops_edit_file`'s no-follow, CAS and platform metadata protections, and +returns a short receipt containing revisions, changed count, byte/line facts and warnings, +without echoing replacement content. Inspect any `error.effectState` before deciding whether +a write may be retried. It is never declared concurrency-safe. diff --git "a/docs/\346\226\207\344\273\266\346\211\271\351\207\217\346\223\215\344\275\234.md" "b/docs/\346\226\207\344\273\266\346\211\271\351\207\217\346\223\215\344\275\234.md" new file mode 100644 index 00000000..e58a080f --- /dev/null +++ "b/docs/\346\226\207\344\273\266\346\211\271\351\207\217\346\223\215\344\275\234.md" @@ -0,0 +1,36 @@ +# 文件批量操作 + +English: [file-batching.md](file-batching.md). + +`ops_read_many` 在一次临时 worker 调用中读取 1–32 个 UTF-8 文件范围。相对路径始终基于本次调用的 +会话 cwd。每项需要 `path`,可指定从 1 开始的 `offset`(默认 1)与 `limit`(1–10000,默认 200)。 +与旧检查工具不同,offset 0 无效。未知字段、非法范围和无效描述会拒绝整个请求。二进制数据应使用 +文档/二进制读取器,或通过 `ops_inspect_local_file` 的 `view: "hex"` 查看;不会有损解码。 + +```json +{"files":[{"path":"src/index.ts","offset":1,"limit":80},{"path":"README.md","offset":10,"limit":40}]} +``` + +结果的 `items` 包含逐文件的版本哈希、字节数、物理行数、行内容与完成状态。空文件为零行,CRLF +只算一行,末尾 LF 不产生虚构行。不存在、不可读或编码错误的文件产生独立错误项,保留其他文件的 +成功结果。宿主会在启动进程前检查所有凭证路径,只要有一项受保护就拒绝整个请求;worker 还会逐项 +验证路径并拒绝符号链接/junction。只读调用不创建审计或缓存文件。成功项使用读取前观察到的版本更新 +宿主的“读后编辑”记录;失败项与尚未读取的项不更新该记录。 + +`nextIndex` 指向输入数组中首个未返回项,`pending` 是剩余项数。继续读取时提交原始 `files` 数组的 +剩余片段。每个已返回文件还有独立的 `complete` 与 `nextOffset`,表示该文件尚未读取的行。只有所有 +文件均已完整读取、没有错误且没有待处理项,顶层 `complete` 才为 true。`pending: 0` 的部分结果仍 +可能需要逐文件续读;必须检查两层完成状态。 + +worker 按转义后的完整响应限制为 96 KiB,包含路径及元数据;Native 内容块还有 64 KiB 的显示上限。 +超长行可返回前缀并带上 `truncatedInLine`、`bytesTotal`、`bytesOmitted`;续读位置保留在该不完整行。 +使用 hex 视图按 16 字节一行恢复尾部。显示预算截断时,应缩小原始范围重读,不能跳过未显示的内容。 +Native 输出文件标题、物理行号、逐文件错误与续读提示;程序化结果保持结构化数据。 + +`ops_edit_many` 对**单个** UTF-8 文件原子应用 1–100 段修改。参数包括 `path`、读取/stat 得到的准确 +SHA-256 `expectedRevision` 与 `edits: [{oldText,newText}]`。每个非空 `oldText` 必须在同一原始字节中 +唯一出现;重叠、歧义或版本过期会在发布前拒绝全部修改。空 `newText` 表示删除。CRLF/BOM 需显式 +保留。`dryRun: true` 仅预览相同回执;只读策略仍拒绝写入工具。这不是跨文件事务。 + +它共享 `ops_edit_file` 的 no-follow、CAS 与平台元数据保护,返回包含版本、修改计数、字节/行数及 +警告的短回执,不回显替换内容。重试前必须检查 `error.effectState`;该工具不会标记为并发安全。 diff --git a/dsh-plugin.json b/dsh-plugin.json index dd1c65a0..f2b5a090 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -82,7 +82,9 @@ "stat", "copy", "move", - "remove" + "remove", + "read_many", + "edit_many" ], "shellPermission": "Per-call DSH sandbox policy and native subprocess" }, diff --git a/src/capabilities/file.ts b/src/capabilities/file.ts index a147c273..ad5ce1e4 100644 --- a/src/capabilities/file.ts +++ b/src/capabilities/file.ts @@ -12,7 +12,13 @@ const bounded = (properties: Record, required: string[] const revision: JsonSchemaNode = { type: 'string', description: '64 lowercase hex SHA-256 revision returned by inspect/stat; absent is create-only, never an overwrite wildcard.' } const existingRevision: JsonSchemaNode = { type: 'string', description: '64 lowercase hex SHA-256 revision returned by inspect/stat; absent is not accepted.' } +const edits = bounded({ path: string, expectedRevision: existingRevision, edits: { type: 'array', description: '1-100 nonoverlapping edits against the same original bytes.', items: bounded({ oldText: { ...string, description: 'Nonempty text that occurs exactly once in the original.' }, newText: string }, ['oldText', 'newText']) }, dryRun: { type: 'boolean' } }, ['path', 'expectedRevision', 'edits']) + export const PARAMETERS = Object.freeze({ + read_many: bounded({ files: { type: 'array', description: '1-32 UTF-8 file ranges.', items: bounded({ + path: string, offset: { type: 'integer', description: '1-based line; minimum 1, default 1.' }, limit: range(1, 10000, 'Default 200.'), + }, ['path']) } }, ['files']), + edit_many: edits, inspect_local_file: bounded({ path: string, offset: { type: 'integer', description: '1-based line or 16-byte hex row; default 1. Legacy 0 also reads the first unit.' }, @@ -44,7 +50,7 @@ export const PARAMETERS = Object.freeze({ stat: bounded({ path: string }, ['path']), diff: bounded({ path: string, content: string, offset: integer, limit: range(1, 1000, 'Default 100.') }, ['path', 'content']), write_file: bounded({ path: string, content: string, expectedRevision: revision, dryRun: { type: 'boolean' } }, ['path', 'content', 'expectedRevision']), - edit_file: bounded({ path: string, expectedRevision: existingRevision, edits: { type: 'array', description: '1-100 nonoverlapping edits against the same original bytes.', items: bounded({ oldText: { ...string, description: 'Nonempty text that occurs exactly once in the original.' }, newText: string }, ['oldText', 'newText']) }, dryRun: { type: 'boolean' } }, ['path', 'expectedRevision', 'edits']), + edit_file: edits, copy: bounded({ path: string, to: string, expectedRevision: existingRevision }, ['path', 'to', 'expectedRevision']), move: bounded({ path: string, to: string, expectedRevision: existingRevision }, ['path', 'to', 'expectedRevision']), remove: bounded({ path: string, expectedRevision: existingRevision }, ['path', 'expectedRevision']), @@ -72,16 +78,27 @@ export const OUTPUT: JsonSchemaNode = { }, } +export const READ_MANY_OUTPUT: JsonSchemaNode = { + type: 'object', properties: { + status: string, complete: { type: 'boolean' }, nextIndex: { type: 'integer' }, pending: { type: 'integer' }, + total: { type: 'integer' }, failed: { type: 'integer' }, truncated: { type: 'boolean' }, effectState: string, + items: { type: 'array', items: { ...OUTPUT, properties: { ...OUTPUT.properties, path: string } } }, error: { type: 'object' }, + }, +} + // Failures are returned values, never thrown: a try/catch alone misses them (#44). const RESULT = ' Failures resolve as {status:"error"|"rejected", error.code}; they are not thrown.' +const EDIT_DESCRIPTION = 'Precisely patch one UTF-8 file using 1-100 oldText/newText edits against the SAME original bytes. Every nonempty oldText must occur exactly once; overlapping edits reject all. Required expectedRevision from inspect/stat. Atomic CAS; dryRun previews. Darwin nonempty extended ACLs fail closed with METADATA_UNSUPPORTED. Preserve CRLF/BOM explicitly in text. Windows preserves owner/group/ordered DACL and supported basic attributes/timestamps; readonly overwrite is refused and unsupported streams/EA/special storage fail with METADATA_UNSUPPORTED. SACL/audit labels are not preserved.' + RESULT export const DESCRIPTIONS = Object.freeze({ + read_many: 'Read UTF-8 ranges in one call. Paths use the current session cwd. Check each file complete/error; nextOffset continues its lines, nextIndex only resumes pending files. Binary data requires a binary reader.' + RESULT, inspect_local_file: 'Read bounded UTF-8 text lines or a hex view from path relative to the DSH session cwd. Directories are rejected; use ops_glob or ops_grep.' + RESULT, glob: 'Find regular files below path. pattern is an ARRAY of globs matched against paths relative to path, e.g. ["**/*.md","!vendor/**"]: "*.md" matches the top level only, "!" excludes, at least one positive entry. Symlinks, reparse points, hidden files and .git are not traversed.' + RESULT, grep: 'Search UTF-8 files below path. pattern is ONE Rust regex string; filter files with glob, an ARRAY of the same relative globs ("!" excludes). There is no include field. Use summary for aggregate file/match totals, count for per-file counts, content for matching text (default); limit defaults to 100 items. Paging uses 0-based offset/nextOffset. Follow nextOffset until complete:true, but do not loop blindly on truncated/failed scans. Totals cover scanned readable UTF-8 files within file-size limits; truncated scans are not exhaustive. Each page rescans candidates to count totals while materializing only its requested window. Optional line context.' + RESULT, stat: 'Inspect one regular file: SHA-256 revision, bytes, UTF-8 lineCount (null for binary), timestamps and permissions; no content. Missing paths return exists:false, revision:absent.' + RESULT, diff: 'Read-only comparison of one UTF-8 file with content. Returns one prefix/suffix-trimmed replacement hunk (not a minimal diff), paged 0-based offset/limit (default 100). Includes old/new hashes and line counts; no publication.' + RESULT, write_file: 'Create or fully overwrite one UTF-8 file; parents must exist. Required expectedRevision is the observed SHA-256, or absent for create-only. Atomic CAS, short receipt with new revision/bytes/lineCount; dryRun only previews. Darwin nonempty extended ACLs fail closed with METADATA_UNSUPPORTED. Read-only denies even dryRun. Windows preserves owner/group/ordered DACL and supported basic attributes/timestamps; readonly overwrite is refused and unsupported streams/EA/special storage fail with METADATA_UNSUPPORTED. SACL/audit labels are not preserved.' + RESULT, - edit_file: 'Precisely patch one UTF-8 file using 1-100 oldText/newText edits against the SAME original bytes. Every nonempty oldText must occur exactly once; overlapping edits reject all. Required expectedRevision from inspect/stat. Atomic CAS; dryRun previews. Darwin nonempty extended ACLs fail closed with METADATA_UNSUPPORTED. Preserve CRLF/BOM explicitly in text. Windows preserves owner/group/ordered DACL and supported basic attributes/timestamps; readonly overwrite is refused and unsupported streams/EA/special storage fail with METADATA_UNSUPPORTED. SACL/audit labels are not preserved.' + RESULT, + edit_file: EDIT_DESCRIPTION, + edit_many: EDIT_DESCRIPTION, copy: 'Copy ONE regular file to absent to (relative to session cwd); parents must exist. Required expectedRevision checks source. Atomic no-clobber destination; preserves metadata or fails closed. Windows preserves owner/group/ordered DACL and supported basic attributes/timestamps; readonly overwrite is refused and unsupported streams/EA/special storage fail with METADATA_UNSUPPORTED. SACL/audit labels are not preserved. Darwin requires APFS cloning and currently refuses nonempty extended ACLs with METADATA_UNSUPPORTED before staging; use a dedicated metadata-aware tool for ACL-bearing files. No directories/links; recursive work uses ops_batch when available.' + RESULT, move: 'Move ONE regular file to absent to, same filesystem only. Required source expectedRevision. Atomic no-clobber destination then source unlink; failures after publication carry effectState:applied. No copy-delete fallback, directories or links.' + RESULT, remove: 'Unlink ONE regular file with required expectedRevision from inspect/stat. No recursion/directories/links. Atomic unlink; no trash. Read-only denies all mutations.' + RESULT, diff --git a/src/capabilities/registry.ts b/src/capabilities/registry.ts index d3d80019..7a1f9201 100644 --- a/src/capabilities/registry.ts +++ b/src/capabilities/registry.ts @@ -1,20 +1,20 @@ /** One catalogue for owned tools. This module never registers tools or imports host values. */ -import { PARAMETERS as fileParameters, DESCRIPTIONS as fileDescriptions, OUTPUT as fileOutput } from './file.js' +import { PARAMETERS as fileParameters, DESCRIPTIONS as fileDescriptions, OUTPUT as fileOutput, READ_MANY_OUTPUT as readManyOutput } from './file.js' import { PARAMETERS as ptcParameters, DESCRIPTIONS as ptcDescriptions, OUTPUT as ptcOutput } from './ptc.js' import type { CapabilityMetadata, Component, SchemaCapability } from './types.js' export type FileToolName = keyof typeof fileParameters export type PtcToolName = keyof typeof ptcParameters // Preserve the published registration order independently of schema object key order. -const fileOrder = ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove'] as const satisfies readonly FileToolName[] -const fileMutations = new Set(['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove']) +const fileOrder = ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many'] as const satisfies readonly FileToolName[] +const fileMutations = new Set(['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) const allPresentations = Object.freeze(['native', 'ptc', 'both'] as const) export const FILE_CAPABILITIES = Object.freeze(fileOrder.map(rawName => Object.freeze({ rawName, component: 'file' as const, presentations: allPresentations, availability: 'resident' as const, mutates: fileMutations.has(rawName), concurrencySafe: !fileMutations.has(rawName), - parameters: fileParameters[rawName], description: fileDescriptions[rawName], output: fileOutput, + parameters: fileParameters[rawName], description: fileDescriptions[rawName], output: rawName === 'read_many' ? readManyOutput : fileOutput, } satisfies SchemaCapability))) export const PTC_CAPABILITIES = Object.freeze((Object.keys(ptcParameters) as PtcToolName[]).map(rawName => Object.freeze({ rawName, component: 'ptc' as const, presentations: Object.freeze(['ptc'] as const), diff --git a/src/file/contract.ts b/src/file/contract.ts index 40d8a232..c85a350a 100644 --- a/src/file/contract.ts +++ b/src/file/contract.ts @@ -2,7 +2,7 @@ import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import { publicToolName } from '../policy.js' import { capability } from '../capabilities/registry.js' import type { FileToolName } from '../capabilities/registry.js' -import { PARAMETERS, DESCRIPTIONS, OUTPUT } from '../capabilities/file.js' +import { PARAMETERS, DESCRIPTIONS, OUTPUT, READ_MANY_OUTPUT } from '../capabilities/file.js' export { FILE_TOOLS } from '../capabilities/registry.js' export { PARAMETERS, DESCRIPTIONS, OUTPUT } import { renderNative } from '../modes/native/render.js' @@ -33,7 +33,7 @@ export function definition(rawName: FileToolName, execute: ToolDefinition['execu description: DESCRIPTIONS[rawName], parameters: PARAMETERS[rawName], ...(capability(rawName)?.concurrencySafe ? { isConcurrencySafe: () => true } : {}), - output: { schema: OUTPUT, render: (args, value) => [{ type: 'text', text: renderNative(rawName, args, value) }] }, + output: { schema: rawName === 'read_many' ? READ_MANY_OUTPUT : OUTPUT, render: (args, value) => [{ type: 'text', text: renderNative(rawName, args, value) }] }, execute, } } diff --git a/src/file/read-many.ts b/src/file/read-many.ts new file mode 100644 index 00000000..19b16172 --- /dev/null +++ b/src/file/read-many.ts @@ -0,0 +1,22 @@ +import path from 'node:path' + +type Descriptor = Record & { path: string } +type ResolvedReads = { files: Descriptor[] } | { error: string } +const record = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value) + +/** Resolve every range against this invocation's cwd; retain unknown fields for kernel validation. */ +export function resolveReadMany(args: unknown, cwd: string): ResolvedReads { + if (!record(args) || !Array.isArray(args.files) || args.files.length < 1 || args.files.length > 32) { + return { error: 'files must contain 1-32 file ranges.' } + } + const files: Descriptor[] = [] + for (const [index, input] of args.files.entries()) { + if (!record(input) || typeof input.path !== 'string' || input.path.length === 0 || input.path.includes('\0')) { + return { error: `files[${index}].path must be a non-empty path.` } + } + const file: Descriptor = { ...input, path: path.resolve(cwd, input.path) } + for (const key of ['offset', 'limit']) if (file[key] === null || file[key] === undefined) delete file[key] + files.push(file) + } + return { files } +} diff --git a/src/file/worker.js b/src/file/worker.js index afa11a16..700f563d 100644 --- a/src/file/worker.js +++ b/src/file/worker.js @@ -8,6 +8,7 @@ import { inside, canonicalAnchor, anchoredPath, sessionAnchors } from '../author import { resolveWorker } from '../ptc/runtime.js' import { FILE_MUTATIONS } from '../policy.js' import { normalizeArguments } from './contract.js' +import { resolveReadMany } from './read-many.js' export { resolveWorker } @@ -78,15 +79,19 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { args = normalizeArguments(tool, args) const mutates = FILE_MUTATIONS.includes(tool) + const reads = tool === 'read_many' ? resolveReadMany(args, cwd) : undefined + if (reads?.error) return failure('INVALID_ARGUMENT', reads.error) const operationPath = absoluteTarget(args?.path ?? '.', cwd) if (!operationPath) return failure('INVALID_ARGUMENT', 'path must be a non-empty path.') // Credential locations are refused before the worker is spawned, so no read of one ever reaches a // result. On by default; a row can turn the fence off for itself (report: credential fence). if ((config?.denyCredentialReads ?? true) !== false) { - const verdict = credentialPath(operationPath) - if (verdict.denied) { - const refusal = credentialRefusal(operationPath, verdict.matched) - return credentialDenied(refusal.message) + for (const target of reads ? reads.files.map(file => file.path) : [operationPath]) { + const verdict = credentialPath(target) + if (verdict.denied) { + const refusal = credentialRefusal(target, verdict.matched) + return credentialDenied(refusal.message) + } } } const destination = ['copy', 'move'].includes(tool) ? absoluteTarget(args?.to, cwd) : undefined @@ -128,7 +133,9 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { // Do NOT realpath the caller's parents here: that would erase a symlink/junction before // the no-follow native traversal could reject it (especially on create-only writes). - const workerArgs = { ...args, path: trustedPath(operationPath), ...(destination ? { to: trustedPath(destination) } : {}) } + const workerArgs = reads + ? { ...args, files: reads.files.map(file => ({ ...file, path: trustedPath(file.path) })) } + : { ...args, path: trustedPath(operationPath), ...(destination ? { to: trustedPath(destination) } : {}) } // Traversals skip credential directories by identity, so a glob or grep cannot even name a private // key that sits below one. const denyRoots = (config?.denyCredentialReads ?? true) !== false ? credentialRoots() : [] @@ -145,6 +152,7 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { const fsService = hostFs(ctx) // Stat before reading, like host read: a concurrent change records the older version and fails stale later. const readObservation = ['inspect_local_file', 'stat'].includes(tool) ? await hostObservation(fsService, operationPath, cwd, signal) : undefined + const readObservations = reads ? await Promise.all(reads.files.map(file => hostObservation(fsService, file.path, cwd, signal))) : [] signal?.throwIfAborted?.() const current = policyService.resolve({ session }) if (current.mode !== policy.mode || current.workspaceRoot !== policy.workspaceRoot) { @@ -165,6 +173,11 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { try { value = JSON.parse(stdout.text) } catch { return failure('RESULT_UNKNOWN', 'Worker returned invalid JSON; inspect postconditions.', 'verify', 'unknown') } // Report reads/writes to the host's read-before-edit table, exactly like host read/edit. (#17) if (['inspect_local_file', 'stat'].includes(tool) && value?.status === 'completed') emitObserved(ctx, readObservation, exec) + if (reads && Array.isArray(value?.items)) { + for (const [index, item] of value.items.entries()) { + if (item?.status === 'completed') emitObserved(ctx, readObservations[index], exec) + } + } if (mutates) await observeReplaced(ctx, fsService, value, cwd, exec, signal) value = normalizeNativeDenial(value, policy, tool, mutates ? requiredMode(policy) : 'workspace-write') return { ...value, sandbox: { mode: policy.mode, enforcement: 'policy', boundary: 'trusted-native-file-operations' } } diff --git a/src/modes/native/render.ts b/src/modes/native/render.ts index 94f8eb53..e98c58a9 100644 --- a/src/modes/native/render.ts +++ b/src/modes/native/render.ts @@ -42,7 +42,7 @@ function notices(value: Row, displayed: number): string[] { `${displayed} item${displayed === 1 ? '' : 's'} displayed`, `complete=${value.complete === undefined ? (incomplete ? 'false' : 'unspecified') : value.complete}`, `TruncatedBy: ${value.truncatedBy ?? (incomplete ? 'worker-limit' : 'none')}`, - facts(value, ['nextOffset', 'omitted', 'total', 'matched', 'files', 'matches', 'skippedFiles', 'skippedPaths', 'failed', 'continuation']), + facts(value, ['nextOffset', 'nextIndex', 'pending', 'omitted', 'total', 'matched', 'files', 'matches', 'skippedFiles', 'skippedPaths', 'failed', 'continuation']), ].filter(Boolean) const output = [`[Notice: ${details.join('; ')}.]`] for (const item of rows(value.items)) { @@ -159,6 +159,7 @@ export function renderNative(tool: string, args: unknown, result: unknown): stri const input = record(args) ? args : {} let lines: string[] if (record(result.error) || ['error', 'rejected', 'conflict', 'unknown', 'failed'].includes(String(result.status))) lines = failure(result) + else if (tool === 'read_many') lines = readMany(result) else if (tool === 'inspect_local_file') lines = inspect(input, result) else if (mutations.has(tool)) lines = receipts(tool, result) else if (tool === 'stat') lines = stat(input, result) @@ -168,3 +169,13 @@ export function renderNative(tool: string, args: unknown, result: unknown): stri ? 'Inspect current state before another mutation; do not repeat the write to recover undisplayed receipts.' : undefined) } + +function readMany(value: Row): string[] { + const output: string[] = [] + for (const item of rows(value.items)) { + if (record(item.error)) output.push(`=== FILE: ${label(item.path)} ===`, ...failure(item)) + else output.push(...inspect(item, item)) + } + output.push(...notices(value, rows(value.items).length)) + return output +} diff --git a/src/policy.ts b/src/policy.ts index ffe5272b..3130f439 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -46,6 +46,8 @@ export function renderToolingPolicy({ published, mounted, levels, pwsh7 = false, const row = (raw: string, task: string) => { if (names.has(publicToolName(raw))) lines.push(`| ${task} | ${publicToolName(raw)} |`) } + row('read_many', 'Read UTF-8 ranges from multiple files') + row('edit_many', 'Atomic original-text edits in one file (CAS)') row('inspect_local_file', 'Bounded UTF-8 lines / hex') if (hasFiles && names.has('read_image')) lines.push('| Images | read_image (host) |') row('grep', 'Search contents') diff --git a/test/capabilities.test.mjs b/test/capabilities.test.mjs index 95609f8b..6d29b9d8 100644 --- a/test/capabilities.test.mjs +++ b/test/capabilities.test.mjs @@ -9,10 +9,10 @@ import { BASH_TOOL_NAME } from '../lib/shells/constants.js' import { assert, test, report } from './lib/harness.mjs' await test('the catalogue covers every owned file, PTC and shell tool exactly once', () => { - assert.equal(CAPABILITIES.length, 14) + assert.equal(CAPABILITIES.length, 16) assert.equal(new Set(CAPABILITIES.map(item => item.rawName)).size, CAPABILITIES.length) - assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove']) - assert.deepEqual(FILE_MUTATIONS, ['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove']) + assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many']) + assert.deepEqual(FILE_MUTATIONS, ['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) assert.deepEqual(PTC_TOOLS, ['batch', 'result']) assert.deepEqual(IN_PROCESS_TOOLS, ['bash']) assert.equal(BASH_TOOL_NAME, BASH_TOOL) @@ -46,7 +46,7 @@ const snapshot = (names, definition) => names.map(name => { await test('serialized host schemas, descriptions, ordering and execution metadata match 00237b8', () => { // Captured before this migration, covering every argument/output contract and published description. const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') - assert.equal(hash(snapshot(FILE_TOOLS, fileDefinition)), '8b89dff420471db5b3bd13729cdf8a30c07e8e7636e5d22795ab343b5ae8e267') + assert.equal(hash(snapshot(FILE_TOOLS.filter(name => !['read_many', 'edit_many'].includes(name)), fileDefinition)), '8b89dff420471db5b3bd13729cdf8a30c07e8e7636e5d22795ab343b5ae8e267') assert.equal(hash(snapshot(PTC_TOOLS, ptcDefinition)), '74e6a50db799da1aa80c745e18c67be577426cc41441f9d0f8c87673c04f3c43') }) diff --git a/test/file-sdk-render.test.mjs b/test/file-sdk-render.test.mjs index 8ff30f75..1a0b798a 100644 --- a/test/file-sdk-render.test.mjs +++ b/test/file-sdk-render.test.mjs @@ -6,8 +6,8 @@ import { capNativeText, NATIVE_OUTPUT_BYTES } from '../lib/modes/native/render.j import { renderToolingPolicy, publicToolName } from '../lib/policy.js' import { assert, test, report } from './lib/harness.mjs' -await test('all eleven file argument schemas remain discoverable without unknown types', () => { - assert.equal(FILE_TOOLS.length, 11) +await test('all thirteen file argument schemas remain discoverable without unknown types', () => { + assert.equal(FILE_TOOLS.length, 13) for (const tool of FILE_TOOLS) { const schema = PARAMETERS[tool] assertSupportedJsonSchema(schema) diff --git a/test/read-many-host.test.mjs b/test/read-many-host.test.mjs new file mode 100644 index 00000000..4205bd92 --- /dev/null +++ b/test/read-many-host.test.mjs @@ -0,0 +1,110 @@ +/** Multi-range reads cross the real Cordis/subprocess boundary once per call. */ +import fs from 'node:fs' +import path from 'node:path' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { bootHost, shutdown } from './lib/host.mjs' +import { WORKER, requireWorker } from './lib/boundary.mjs' +import { invoke } from '../lib/file/worker.js' +import { resolveReadMany } from '../lib/file/read-many.js' +import { definition } from '../lib/file/contract.js' +import { publicToolName } from '../lib/policy.js' +requireWorker('read-many-host') +const execution = cwd => ({ agent: { session: { id: 'read-many-host', header: { cwd } } }, signal: new AbortController().signal }) + +await test('range normalization preserves caller data, invalid fields and the current cwd', () => { + const input = { files: [{ path: 'a.txt', offset: null, limit: undefined, unknown: '' }, { path: 'b.txt', offset: 0 }] } + const snapshot = structuredClone(input) + const value = resolveReadMany(input, process.cwd()) + assert.deepEqual(input, snapshot) + assert.equal(value.files[0].path, path.resolve('a.txt')) + assert.equal(Object.hasOwn(value.files[0], 'offset'), false) + assert.equal(value.files[0].unknown, '') + assert.equal(value.files[1].offset, 0, 'invalid bounds must reach authoritative validation') + for (const files of [[], Array.from({ length: 33 }, () => ({ path: 'a' })), [null], [{ path: '' }], [{ path: 'a\0b' }]]) { + assert.equal(typeof resolveReadMany({ files }, process.cwd()).error, 'string') + } +}) + +await test('one real spawn reads all ranges, refreshes cwd and observes only returned successful files', async () => { + await withTempDir('read-many-host', async root => { + const first = path.join(root, 'first'), second = path.join(root, 'second') + fs.mkdirSync(first); fs.mkdirSync(second) + fs.writeFileSync(path.join(first, 'a.txt'), 'alpha\nlast\n') + fs.writeFileSync(path.join(first, 'b.txt'), 'beta\n') + fs.writeFileSync(path.join(first, 'binary'), Buffer.from([0xff])) + fs.writeFileSync(path.join(second, 'a.txt'), 'changed cwd\n') + const { ctx } = await bootHost() + const observed = []; const sent = [] + try { + await ctx.plugin(Subprocess) + await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: () => ({ mode: 'read-only', workspaceRoot: root }) })) + await ctx.plugin(inner => inner.provide('fs', { + resolve: async file => file, + stat: async file => ({ type: 'file', version: `before:${file}`, size: 0 }), + })) + ctx.on('fs/observed', (target, observation) => observed.push({ target, observation })) + const processService = ctx.get('subprocess'), spawn = processService.spawn.bind(processService) + processService.spawn = spec => { sent.push(JSON.parse(spec.stdio.stdin.data)); return spawn(spec) } + const exec = execution(first) + const value = await invoke(ctx, WORKER, 'read_many', { files: [{ path: 'a.txt', offset: 2 }, { path: 'binary' }, { path: 'b.txt' }] }, exec, exec.signal) + assert.equal(sent.length, 1) + assert.equal(Object.hasOwn(sent[0].request.args, 'path'), false) + assert.deepEqual(sent[0].request.args.files.map(file => file.path), ['a.txt', 'binary', 'b.txt'].map(file => path.join(first, file))) + assert.equal(value.status, 'partial', JSON.stringify(value)) + assert.equal(value.complete, false); assert.equal(value.pending, 0) + assert.equal(value.items[0].items[0].text, 'last') + assert.equal(value.items[0].items[0].line, 2) + assert.equal(value.items[1].error.code, 'ERR_INVALID_ENCODING') + assert.equal(value.items[2].items[0].text, 'beta') + assert.deepEqual(observed.map(item => item.target), [path.join(first, 'a.txt'), path.join(first, 'b.txt')]) + assert.ok(observed.every(item => item.observation.version.startsWith('before:'))) + exec.agent.session.header.cwd = second + const next = await invoke(ctx, WORKER, 'read_many', { files: [{ path: 'a.txt' }] }, exec, exec.signal) + assert.equal(sent.length, 2) + assert.equal(next.items[0].items[0].text, 'changed cwd') + assert.equal(sent[1].request.args.files[0].path, path.join(second, 'a.txt')) + assert.deepEqual(fs.readdirSync(root).sort(), ['first', 'second'], 'read-only calls leave no audit or job files') + } finally { await shutdown(ctx) } + }) +}) + +await test('every credential path and invalid descriptor is rejected before spawn; preparation races stop the whole read', async () => { + await withTempDir('read-many-preflight', async root => { + const { ctx } = await bootHost() + let spawned = 0, resolves = 0, race = false + try { + await ctx.plugin(inner => inner.provide('subprocess', { spawn() { spawned++; throw new Error('unexpected spawn') } })) + await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: () => ({ mode: race && ++resolves > 1 ? 'read-only' : 'workspace-write', workspaceRoot: root }) })) + const exec = execution(root) + for (const [files, code] of [[[{ path: 'safe' }, { path: '.env' }], 'CREDENTIAL_DENIED'], [[{ path: 'safe' }, { path: '' }], 'INVALID_ARGUMENT'], [[], 'INVALID_ARGUMENT']]) { + const value = await invoke(ctx, WORKER, 'read_many', { files }, exec, exec.signal) + assert.equal(value.error.code, code, JSON.stringify(value)) + assert.equal(value.error.effectState, 'none') + } + race = true + const changed = await invoke(ctx, WORKER, 'read_many', { files: [{ path: 'safe' }] }, exec, exec.signal) + assert.equal(changed.error.code, 'AUTHORITY_CHANGED') + assert.equal(spawned, 0) + } finally { await shutdown(ctx) } + }) +}) + +await test('multi-read rendering separates physical lines, file errors and pending-file continuation', () => { + const value = { status: 'partial', complete: false, nextIndex: 2, pending: 1, items: [ + { path: '/a', status: 'completed', complete: false, lineCount: 8, nextOffset: 3, items: [{ path: '/a', line: 2, text: 'second' }] }, + { path: '/b', status: 'error', complete: false, error: { code: 'ERR_INVALID_ENCODING', effectState: 'none', nextAction: 'Use view=hex.', message: 'Invalid UTF-8.' } }, + ] } + const tool = definition('read_many', async () => value) + assert.equal(tool.name, publicToolName('read_many')) + const before = JSON.stringify(value), view = tool.output.render({}, value)[0].text + assert.match(view, /FILE: \/a \(Lines 2-2 of 8\)/) + assert.match(view, /2 \| second/) + assert.match(view, /nextOffset=3/) + assert.match(view, /FILE: \/b/) + assert.match(view, /ERR_INVALID_ENCODING/) + assert.match(view, /nextIndex=2; pending=1/) + assert.equal(JSON.stringify(value), before) + assert.equal(definition('edit_many', async () => value).isConcurrencySafe, undefined) +}) +report('read-many-host') From 2cd8a53c2d3d73146810bf1f2b372ba52d3ebb89 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:12:52 +0800 Subject: [PATCH 014/118] test(cli): include batched file tools in routing reports --- test/bin.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/bin.test.mjs b/test/bin.test.mjs index 10814780..5b56703a 100644 --- a/test/bin.test.mjs +++ b/test/bin.test.mjs @@ -23,7 +23,7 @@ import { createHash } from 'node:crypto' import fs from 'node:fs' import path from 'node:path' import { resolveConfig } from '../lib/config.js' -import { renderToolingPolicy } from '../lib/policy.js' +import { renderToolingPolicy, publicToolName } from '../lib/policy.js' import { resolveShells } from '../lib/shells.js' import { PACKAGE_ROOT, TEST_HOME, assert, report, test, withTempDir } from './lib/harness.mjs' @@ -272,7 +272,7 @@ function assertLadder(report_) { const { levels, rungs } = report_ assert.deepEqual(rungs, [], 'the retired numbered ladder is empty') const published = [ - ...(levels.files ? ['glob', 'grep', 'inspect_local_file', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove'].map(name => `ops_${name}`) : []), + ...(levels.files ? ['glob', 'grep', 'inspect_local_file', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many'].map(publicToolName) : []), ...(levels.bash ? ['ops_bash'] : []), ...(levels.pwsh ? ['pwsh'] : []), ] From b5e84602ec9999e243c9d3195a64ee11582e92b2 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:23:52 +0800 Subject: [PATCH 015/118] feat(results): add private session snapshot references --- docs/CHANGELOG.md | 10 + docs/native-kernel.md | 46 ++- ...37\347\224\237\345\206\205\346\240\270.md" | 36 ++- native/crates/ops-files/src/cache_io.rs | 67 +++++ native/crates/ops-files/src/lib.rs | 1 + native/crates/ops-protocol/src/envelope.rs | 7 +- native/crates/ops-protocol/src/lib.rs | 5 + native/crates/ops-protocol/src/storage.rs | 50 ++++ native/crates/ops-results/src/cache.rs | 162 +++++++++++ native/crates/ops-results/src/cache_tests.rs | 84 ++++++ native/crates/ops-results/src/lib.rs | 2 + native/crates/ops-results/src/references.rs | 145 ++++++++++ native/crates/ops-results/src/results.rs | 2 + native/src/lib.rs | 5 + native/windows-metadata/src/lib.rs | 66 +++-- test/result-references.test.mjs | 269 ++++++++++++++++++ 16 files changed, 934 insertions(+), 23 deletions(-) create mode 100644 native/crates/ops-files/src/cache_io.rs create mode 100644 native/crates/ops-protocol/src/storage.rs create mode 100644 native/crates/ops-results/src/cache.rs create mode 100644 native/crates/ops-results/src/cache_tests.rs create mode 100644 native/crates/ops-results/src/references.rs create mode 100644 test/result-references.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 233b9230..e75d949d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,16 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Add private session snapshot references (#261, 6a/16) + +- Add trusted-envelope `ref://session/tool/uuid` storage with 10 MiB snapshot and 100 MiB session + quotas, serialized LRU, physical byte accounting, no-follow objects and separate durable audits. +- Recheck live authority/producer scope on access; default TTL follows session lifetime. Bounded + pages never nest references. Readonly reads leave storage unchanged; guarded purge preserves + the lock and audit namespace and reports partial cleanup honestly. +- Preserve Windows private-temp capability isolation; real confined create/reopen/readonly and + foreign-session denial assertions cover the protected cache ACL. + ### Render Native file results as bounded, readable text (#261) - Show file headings and physical line numbers, concise mutation receipts, and explicit diff --git a/docs/native-kernel.md b/docs/native-kernel.md index e68c4c24..7e28a926 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -44,4 +44,48 @@ The complete request remains capped at 2 MiB. Explicit legacy unversioned reques for 0.3.x benchmark comparisons, with the same authority checks and bounded default transport budget. Any envelope marker selects strict v1 decoding: partial envelopes, unknown fields, invalid IDs, unsupported components/versions and unusable budgets are rejected before filesystem effects. -Request IDs do not change result hashes. Scan/storage/reference budgets are separate later work. +Request IDs do not change result hashes. Scan and automatic output-spill integration are separate work. + +## Session snapshot references (6a/16) + +The worker accepts optional trusted envelope metadata `storage: { root, authorityId, allowedTools, +windowsWriteSid? }`. Tool arguments and the inner request cannot supply it. The host provides an +existing absolute cache root, a 64-character lowercase SHA-256 authority identity, and the current +canonical producer-tool allowlist. Its authority identity must bind the live session nonce and +current policy/credential boundaries; knowing a reference never grants authority. Each access also +rechecks scope, selected canonical root and directory identity, producer and current credential +fences. The cache root remains independent of operation-root selections. + +With this context, result put returns `ref:////` and `reference` +metadata: refId, createdAt (epoch milliseconds), ttlMs, schemaVersion, producerTool, totalItems, +totalBytes (physical serialized snapshot bytes), and a preview of at most ten rows/512 characters. +The default `ttlMs: 0` means session lifetime; explicit positive TTLs are bounded to seven days. +The host must expire references on session exit and rotate the authority identity when reopening a +session. The worker caps snapshots at 10 MiB and each session cache at 100 MiB/4096 entries, counting +crash stages and unknown files. Writers serialize quota/LRU decisions with one shared OS lock. +Only exact snapshot UUID names are evicted; immutable audit records are never LRU candidates. + +Pages return sourceTotal, filtered total, items, nextOffset, omitted and complete; complete means +all selected stored rows have been displayed, not that the original business traversal was complete. +The 512..65536-byte page budget includes JSON escaping, handle metadata and the newline. Oversized +rows require projection or a larger page; reference pages never create another reference. Nested +reference objects are refused, although ordinary strings containing a URI remain ordinary data. +Readonly pages acquire an existing shared lock without creating files or changing LRU timestamps. +Missing, expired, released or evicted snapshots return `REFERENCE_EXPIRED`. Legacy r-/j- access +retains its existing protocol and durable audit location. + +On Windows the host supplies the actual private `--temp` root and `--temp-write-sid` from its +confined invocation, retaining that location for readonly access. Protected cache DACLs grant only +the current user and this exact private-temp capability, which must already exist on the held +parent. Public/logon/workspace capability SIDs are refused. Real readonly and foreign-session tokens +cannot write these objects. On POSIX, files/directories are current-user-owned and private. Cache +parents, directories, snapshots and the stable lock reject symlinks/reparse points; files must have +one hard link. No cache operation changes business-file permissions. + +For host lifecycle cleanup, `result { action: "purge" }` with the same trusted storage context +accepts no other arguments. It removes only validated snapshot/stage names for that scope, retaining +the lock inode, directories and unknown/audit names. It returns `removed`, `retained`, `complete` +and `effectState`; partial cleanup failures identify prior effects. Readonly purge is refused. +The host must first invalidate references logically and await active calls; a denied or interrupted +purge is deferred cleanup, never a reason to rerun business operations. This worker slice does not +yet attach host lifecycle hooks or spill ordinary tool output automatically. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index 04b3da37..0021a5da 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -38,4 +38,38 @@ English: [native-kernel.md](native-kernel.md). 完整请求仍以 2 MiB 为上限。为兼容 0.3.x 基准对比,显式保留旧版无版本请求,采用相同权限检查和 有界默认传输预算。只要出现任一信封字段,就严格按 v1 解析;缺字段、未知字段、非法 ID、 不支持的组件或版本、无法使用的预算,均在文件系统副作用前被拒绝。请求 ID 不改变结果哈希。 -扫描、存储和引用预算由后续切片实现。 +扫描及自动输出溢出集成由其他切片实现。 + +## 会话快照引用(6a/16) + +worker 接受可选的可信信封元数据 `storage: { root, authorityId, allowedTools, windowsWriteSid? }`。 +工具参数和内层 request 均不能注入这些字段。宿主提供已存在的绝对缓存根目录、64 位小写 SHA-256 +权限身份,以及当前规范化生产工具白名单。权限身份必须绑定本次会话随机标识、当前策略和凭据 +边界;知道引用不等于获得权限。每次访问仍核实会话、选定的规范化根路径及目录身份、生产工具和 +当前凭据边界。缓存位置不随操作根目录选择而改变。 + +带此上下文的 result put 返回 `ref://<编码会话>/<工具>/`,并提供 `reference` 元数据: +refId、createdAt(Unix 毫秒)、ttlMs、schemaVersion、producerTool、totalItems、totalBytes +(实际序列化快照字节数),以及最多十行、512 字符的预览。默认 `ttlMs: 0` 表示会话生命周期; +显式正数 TTL 最多七天。宿主须在会话结束时使引用失效,重新打开会话时更新权限身份。单个快照 +最多 10 MiB,每会话缓存最多 100 MiB/4096 项;崩溃暂存和未知文件也计入配额。写入者通过同一 +进程间锁串行作出配额/LRU 决策。只淘汰规范 UUID 快照名;不可变审计记录永不参与 LRU。 + +分页返回 sourceTotal、筛选后的 total、items、nextOffset、omitted、complete;complete 仅表示 +选定的已存储行全部展示,不意味着原始业务扫描完整。512..65536 字节分页预算计入 JSON 转义、 +引用元数据和末尾换行。单行过大时必须选择更少字段或增大页预算;引用分页不会生成另一层引用。 +拒绝嵌套引用对象,普通字符串中的 URI 仍是普通数据。只读分页仅打开已有共享锁,不创建文件、 +不更新 LRU 时间。丢失、过期、释放或淘汰的快照返回 `REFERENCE_EXPIRED`。旧 r-/j- 访问保持 +原有协议和持久审计位置。 + +Windows 宿主提供受限调用实际的私有 `--temp` 和 `--temp-write-sid`,并为只读访问保留该位置。 +受保护缓存 DACL 仅授予当前用户及这个私有临时能力,且该能力必须已存在于持有的父目录句柄上。 +拒绝公共组、登录组和工作区能力 SID。真实只读及其他会话令牌无法写入这些对象。POSIX 文件和 +目录归当前用户所有且权限私有。缓存父目录、子目录、快照和稳定锁均拒绝链接或 reparse point; +文件只允许单一硬链接。缓存操作不会修改业务文件权限。 + +宿主生命周期清理使用相同可信上下文发送 `result { action: "purge" }`,不接受其他参数。仅删除 +该会话范围内已验证的快照/暂存名,保留锁 inode、目录以及未知/审计名称。结果包含 removed、 +retained、complete、effectState;部分清理失败会明确已有副作用。只读模式拒绝 purge。宿主必须 +先在逻辑上使引用失效,再等待活动调用结束;清理被拒绝或中断时延后清理,绝不能重跑业务操作。 +本 worker 切片尚未接入宿主生命周期,也不会自动缓存普通工具的过大输出。 diff --git a/native/crates/ops-files/src/cache_io.rs b/native/crates/ops-files/src/cache_io.rs new file mode 100644 index 00000000..01fc76ef --- /dev/null +++ b/native/crates/ops-files/src/cache_io.rs @@ -0,0 +1,67 @@ +//! Private no-follow cache objects. These helpers never alter business-file permissions. +use crate::contract::{Failure, Result}; +use cap_fs_ext::{DirExt, FollowSymlinks, MetadataExt, OpenOptionsFollowExt}; +use cap_std::fs::{Dir, File, OpenOptions}; + +pub fn verify(file: &File, directory: bool, write_sid: Option<&str>) -> Result<()> { + #[cfg(not(windows))] let _ = write_sid; + let metadata = file.metadata()?; + crate::file_path::regular(&metadata, directory)?; + if !directory && (!metadata.is_file() || metadata.nlink() != 1) { + return Err(Failure::new("PATH_RESTRICTED", "Cache files must be regular single-link objects")); + } + #[cfg(unix)] { + use cap_std::fs::MetadataExt; + if metadata.uid() != rustix::process::geteuid().as_raw() || metadata.mode() & 0o077 != 0 { + return Err(Failure::new("PATH_RESTRICTED", "Cache objects must be private to the current user")); + } + } + #[cfg(windows)] dsh_ops_windows_metadata::verify_cache(&file.try_clone()?.into_std(), write_sid) + .map_err(|error| Failure::new("PATH_RESTRICTED", error))?; + Ok(()) +} +pub fn directory(parent: &Dir, name: &str, create: bool, write_sid: Option<&str>) -> Result { + crate::file_path::validate_name(name)?; + if create { + #[cfg(windows)] let created = dsh_ops_windows_metadata::create_cache_object(&parent.try_clone()?.into_std_file(), name, true, write_sid).map(|_| ()); + #[cfg(unix)] let created = { + use cap_std::fs::DirBuilderExt; + let mut builder = cap_std::fs::DirBuilder::new(); builder.mode(0o700); + parent.create_dir_with(name, &builder) + }; + match created { Ok(()) => (), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => (), Err(error) => return Err(error.into()) } + } + crate::file_path::regular(&parent.symlink_metadata(name)?, true)?; + let directory = parent.open_dir_nofollow(name)?; + verify(&File::from_std(directory.try_clone()?.into_std_file()), true, write_sid)?; + Ok(directory) +} +pub fn create_file(parent: &Dir, name: &str, write_sid: Option<&str>) -> Result { + crate::file_path::validate_name(name)?; + #[cfg(windows)] let file = dsh_ops_windows_metadata::create_cache_object(&parent.try_clone()?.into_std_file(), name, false, write_sid) + .map(File::from_std).map_err(Failure::from)?; + #[cfg(unix)] let file = { + use cap_std::fs::OpenOptionsExt; + parent.open_with(name, OpenOptions::new().read(true).write(true).create_new(true).mode(0o600).follow(FollowSymlinks::No))? + }; + verify(&file, false, write_sid)?; + Ok(file) +} +pub fn open_file(parent: &Dir, name: &str, writable: bool, write_sid: Option<&str>) -> Result { + crate::file_path::validate_name(name)?; + crate::file_path::regular(&parent.symlink_metadata(name)?, false)?; + let file = parent.open_with(name, OpenOptions::new().read(true).write(writable).follow(FollowSymlinks::No))?; + verify(&file, false, write_sid)?; + Ok(file) +} +/// Exclusive creation elects one stable lock inode. Never unlink a live lock. +pub fn lock_file(parent: &Dir, writable: bool, write_sid: Option<&str>) -> Result { + if writable { + match create_file(parent, "lock", write_sid) { + Ok(file) => return Ok(file), + Err(error) if error.code == "REVISION_CONFLICT" => (), + Err(error) => return Err(error), + } + } + open_file(parent, "lock", writable, write_sid) +} diff --git a/native/crates/ops-files/src/lib.rs b/native/crates/ops-files/src/lib.rs index c4749963..15bf4ea8 100644 --- a/native/crates/ops-files/src/lib.rs +++ b/native/crates/ops-files/src/lib.rs @@ -2,6 +2,7 @@ use ops_protocol as contract; mod file; pub mod file_path; +pub mod cache_io; pub mod paths; pub use file::{dispatch, target, relative_display, read_bytes, result, lock_and_read_for_update, atomic_write, authorize_write, check_revision, MAX_OUTPUT_BYTES}; #[cfg(unix)] diff --git a/native/crates/ops-protocol/src/envelope.rs b/native/crates/ops-protocol/src/envelope.rs index 79ae7dac..6c7979d1 100644 --- a/native/crates/ops-protocol/src/envelope.rs +++ b/native/crates/ops-protocol/src/envelope.rs @@ -40,6 +40,7 @@ struct Envelope { component: Component, budget: Budget, request: Request, + storage: Option, } pub struct Decoded { @@ -55,8 +56,8 @@ pub fn decode(bytes: &[u8]) -> Result { } let value: Value = serde_json::from_slice(bytes)?; // A malformed or unsupported envelope must not be retried as an unversioned request. - if ["version", "requestId", "component", "budget", "request"].iter().any(|key| value.get(key).is_some()) { - let envelope: Envelope = serde_json::from_slice(bytes)?; + if ["version", "requestId", "component", "budget", "request", "storage"].iter().any(|key| value.get(key).is_some()) { + let mut envelope: Envelope = serde_json::from_slice(bytes)?; if envelope.version != 1 { return Err(Failure::new("PROTOCOL_VERSION", "Supported worker request version is 1")); } @@ -65,6 +66,8 @@ pub fn decode(bytes: &[u8]) -> Result { { return Err(Failure::new("INVALID_ARGUMENT", "requestId must contain 1..128 ASCII letters, digits, underscores or hyphens")); } + if let Some(storage) = &envelope.storage { storage.validate()?; } + envelope.request.storage = envelope.storage; Ok(Decoded { request: envelope.request, budget: envelope.budget.validate()?, request_id: Some(envelope.request_id), component: Some(envelope.component) }) } else { diff --git a/native/crates/ops-protocol/src/lib.rs b/native/crates/ops-protocol/src/lib.rs index ca38fca1..95087151 100644 --- a/native/crates/ops-protocol/src/lib.rs +++ b/native/crates/ops-protocol/src/lib.rs @@ -1,5 +1,6 @@ #![forbid(unsafe_code)] pub mod envelope; +pub mod storage; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -150,6 +151,9 @@ pub type Result = std::result::Result; #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub struct Request { + /// Trusted envelope metadata; never accepted from the tool argument/request data plane. + #[serde(skip)] + pub storage: Option, pub root: String, #[serde(default)] pub read_only: bool, @@ -191,6 +195,7 @@ pub struct ResultArgs { pub offset: usize, pub limit: Option, pub max_bytes: Option, + pub ttl_ms: Option, pub fields: Option>, pub equals: Option>, } diff --git a/native/crates/ops-protocol/src/storage.rs b/native/crates/ops-protocol/src/storage.rs new file mode 100644 index 00000000..f8c37d1f --- /dev/null +++ b/native/crates/ops-protocol/src/storage.rs @@ -0,0 +1,50 @@ +//! Reference metadata belongs to the trusted worker envelope, never tool arguments. +use crate::{Failure, Result}; +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct StorageContext { + pub root: String, + #[serde(default)] + pub windows_write_sid: Option, + pub authority_id: String, + pub allowed_tools: Vec, +} +impl StorageContext { + pub fn validate(&self) -> Result<()> { + if !std::path::Path::new(&self.root).is_absolute() || self.root.len() > 4096 || self.root.contains('\0') + || self.authority_id.len() != 64 || !self.authority_id.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) + || self.windows_write_sid.as_deref().is_some_and(|sid| !valid_temp_sid(sid)) + || self.allowed_tools.len() > 64 || self.allowed_tools.iter().any(|tool| !valid_tool(tool)) + { return Err(Failure::new("INVALID_ARGUMENT", "Invalid trusted storage context")); } + Ok(()) + } + pub fn authorize(&self, tool: &str) -> Result<()> { + if !self.allowed_tools.iter().any(|allowed| allowed == tool) { + return Err(Failure::new("POLICY_DENIED", "Reference producer is outside the current capability set")); + } + Ok(()) + } +} +/// Exact private-temp family emitted by the pinned Windows confinement provider. +pub fn valid_temp_sid(sid: &str) -> bool { + let parts: Vec<_> = sid.split('-').collect(); + parts.len() == 6 && parts[..3] == ["S", "1", "4"] && parts[5] == "1" + && parts[3..5].iter().all(|part| part.parse::().is_ok_and(|value| value > 0 && value < 1 << 30 && value.to_string() == *part)) +} +pub fn valid_tool(tool: &str) -> bool { + !tool.is_empty() && tool.len() <= 64 && tool.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-') +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct StorageReference { + pub ref_id: String, + pub created_at: u64, + /// Zero means session lifetime; host lifecycle cleanup invalidates all session references. + pub ttl_ms: u64, + pub schema_version: String, + pub producer_tool: String, + pub total_items: usize, + pub total_bytes: usize, + pub preview: String, +} diff --git a/native/crates/ops-results/src/cache.rs b/native/crates/ops-results/src/cache.rs new file mode 100644 index 00000000..23ced186 --- /dev/null +++ b/native/crates/ops-results/src/cache.rs @@ -0,0 +1,162 @@ +//! Immutable session snapshots with quota and LRU decisions under one process-shared lock. +use crate::contract::{Failure, Request, Result}; +use cap_fs_ext::DirExt; +use cap_std::fs::Dir; +use fs2::FileExt; +use ops_files::{cache_io, file_path}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::io::{Read, Write}; +use std::time::{Duration, SystemTime}; +pub const MAX_RESULT_BYTES: usize = 10 * 1024 * 1024; +pub const MAX_SESSION_BYTES: u64 = 100 * 1024 * 1024; +const MAX_ENTRIES: usize = 4096; + +pub struct Cache { dir: Dir, _lock: std::fs::File, read_only: bool, write_sid: Option } +struct Entry { name: String, bytes: u64, modified: SystemTime, evictable: bool, temporary: bool } +impl Cache { + pub fn open(request: &Request) -> Result { Self::open_mode(request, true) } + pub fn existing(request: &Request) -> Result { Self::open_mode(request, false) } + fn open_mode(request: &Request, create: bool) -> Result { + let context = request.storage.as_ref().ok_or_else(|| Failure::new("POLICY_DENIED", "Trusted storage context is required for this reference"))?; + context.validate()?; + request.authorize_path(std::path::Path::new(&context.root))?; + let checked = file_path::checked(&context.root)?; + let root = checked.parent.open_dir_nofollow(&checked.name)?; + if !request.read_only && let Some(roots) = &request.write_roots { + let actual = std::fs::canonicalize(&context.root)?; + if !roots.iter().any(|root| std::fs::canonicalize(root).is_ok_and(|root| actual.starts_with(root))) { + return Err(Failure::policy("write-roots", "Cache root is outside authorized write roots")); + } + } + let name = format!("s-{:x}", Sha256::digest(request.scope.as_bytes())); + let dir = cache_io::directory(&root, &name, create && !request.read_only, context.windows_write_sid.as_deref())?; + let lock = if create { cache_io::lock_file(&dir, !request.read_only, context.windows_write_sid.as_deref())? } else { cache_io::open_file(&dir, "lock", !request.read_only, context.windows_write_sid.as_deref())? }.into_std(); + let locked = if request.read_only { FileExt::try_lock_shared(&lock) } else { FileExt::try_lock_exclusive(&lock) }; + locked.map_err(|_| Failure::new("JOB_BUSY", "Another process owns this session cache; no business operation was retried"))?; + Ok(Self { dir, _lock: lock, read_only: request.read_only, write_sid: context.windows_write_sid.clone() }) + } + fn name(id: &str) -> Result { + if uuid::Uuid::parse_str(id).is_err() || id.to_ascii_lowercase() != id || id.len() != 36 { + return Err(Failure::new("INVALID_ARGUMENT", "Invalid reference UUID")); + } + Ok(format!("{id}.json")) + } + fn entries(&self) -> Result> { + let mut entries = Vec::new(); + for entry in self.dir.entries()? { + if entries.len() >= MAX_ENTRIES { return Err(Failure::new("QUOTA_EXCEEDED", "Session cache entry quota exhausted")); } + let name = entry?.file_name().into_string().map_err(|_| Failure::new("PATH_RESTRICTED", "Non-UTF8 cache entry"))?; + let file = cache_io::open_file(&self.dir, &name, false, self.write_sid.as_deref())?; + let metadata = file.metadata()?; + let is_uuid = |suffix: &str| name.strip_suffix(suffix).is_some_and(|id| Self::name(id).is_ok()); + entries.push(Entry { evictable: is_uuid(".json"), temporary: is_uuid(".tmp"), name, + bytes: metadata.len(), modified: metadata.modified()?.into_std() }); + } + Ok(entries) + } + fn next_touch(entries: &[Entry]) -> SystemTime { + entries.iter().map(|entry| entry.modified).max().unwrap_or(SystemTime::UNIX_EPOCH) + .max(SystemTime::now()).checked_add(Duration::from_millis(1)).unwrap_or(SystemTime::now()) + } + pub fn read Deserialize<'de>>(&self, id: &str) -> Result<(T, usize)> { + let name = Self::name(id)?; + let file = cache_io::open_file(&self.dir, &name, false, self.write_sid.as_deref()).map_err(|error| { + if error.code == "NOT_FOUND" { Failure::new("REFERENCE_EXPIRED", "Reference is missing, expired, released, or evicted") } else { error } + })?; + let mut bytes = Vec::new(); + file.take((MAX_RESULT_BYTES + 1) as u64).read_to_end(&mut bytes)?; + if bytes.len() > MAX_RESULT_BYTES { return Err(Failure::new("QUOTA_EXCEEDED", "Cached snapshot exceeds 10 MiB")); } + Ok((serde_json::from_slice(&bytes)?, bytes.len())) + } + pub fn touch(&self, id: &str) -> Result<()> { + if self.read_only { return Ok(()); } + let file = cache_io::open_file(&self.dir, &Self::name(id)?, true, self.write_sid.as_deref())?; + let time = Self::next_touch(&self.entries()?); + file.into_std().set_times(std::fs::FileTimes::new().set_modified(time))?; + Ok(()) + } + pub fn save(&self, id: &str, record: &T) -> Result { + if self.read_only { return Err(Failure::policy("read-only", "Read-only calls cannot create references")); } + let name = Self::name(id)?; + let bytes = serde_json::to_vec(record)?; + if bytes.len() > MAX_RESULT_BYTES { return Err(Failure::new("QUOTA_EXCEEDED", "One result may occupy at most 10 MiB")); } + // Reject any collision; never overwrite an existing snapshot. + match self.dir.symlink_metadata(&name) { + Ok(_) => return Err(Failure::new("REVISION_CONFLICT", "Reference already exists")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => (), + Err(error) => return Err(error.into()), + } + let mut entries = self.entries()?; + // Orphans count toward the measured quota before cleanup. Only our exact temporary + // namespace is disposable, and the exclusive session lock proves no writer owns it. + let mut used: u64 = entries.iter().map(|entry| entry.bytes).sum(); + let touch = Self::next_touch(&entries); + let mut remove: Vec<_> = entries.iter().filter(|entry| entry.temporary).map(|entry| entry.name.clone()).collect(); + used = used.saturating_sub(entries.iter().filter(|entry| entry.temporary).map(|entry| entry.bytes).sum()); + entries.retain(|entry| !entry.temporary); + entries.sort_by(|a, b| a.modified.cmp(&b.modified).then(a.name.cmp(&b.name))); + let mut count = entries.len(); + for entry in entries.iter().filter(|entry| entry.evictable) { + if used + bytes.len() as u64 <= MAX_SESSION_BYTES && count + 1 < MAX_ENTRIES { break; } + remove.push(entry.name.clone()); used = used.saturating_sub(entry.bytes); count -= 1; + } + if used + bytes.len() as u64 > MAX_SESSION_BYTES || count + 1 >= MAX_ENTRIES { + return Err(Failure::new("QUOTA_EXCEEDED", "Session cache quota exhausted; non-cache records are never evicted")); + } + let temporary = format!("{id}.tmp"); + let mut changed = false; + let prepared = (|| -> Result<()> { + for name in remove { self.dir.remove_file(name)?; changed = true; } + let mut file = cache_io::create_file(&self.dir, &temporary, self.write_sid.as_deref())?; + file.write_all(&bytes)?; + file.try_clone()?.into_std().set_times(std::fs::FileTimes::new().set_modified(touch))?; + file.sync_all()?; drop(file); + self.dir.rename(&temporary, &self.dir, &name)?; changed = true; + #[cfg(unix)] ops_files::sync_directory(&self.dir)?; + Ok(()) + })(); + if let Err(mut error) = prepared { + if let Err(cleanup) = self.dir.remove_file(&temporary) { + if cleanup.kind() != std::io::ErrorKind::NotFound { changed = true; } + } + if changed { + error.phase = "execute".into(); error.effect_state = "partial".into(); + error.next_action = "Cache cleanup or publication may have changed references; inspect cache state. Never rerun business operations.".into(); + } + return Err(error); + } + Ok(bytes.len()) + } + /// Session exit destroys only validated snapshot/stage names. The lock inode remains + /// stable so a concurrent opener cannot form a second serialization domain. + pub fn purge(&self) -> Result<(usize, usize)> { + if self.read_only { return Err(Failure::policy("read-only", "Read-only calls cannot purge references")); } + let mut entries = self.entries()?; entries.sort_by(|a, b| a.name.cmp(&b.name)); + let mut removed = 0; let mut retained = 0; + for entry in entries { + if !entry.evictable && !entry.temporary { retained += 1; continue; } + if let Err(error) = self.dir.remove_file(&entry.name) { + let mut failure = Failure::new("CACHE_PURGE_FAILED", format!("Removed {removed} cache objects before cleanup failed: {error}")); + failure.phase = "execute".into(); + failure.effect_state = if removed == 0 { "none" } else { "partial" }.into(); + failure.next_action = "Keep this session's references logically expired; inspect deferred cache cleanup. Never rerun business operations.".into(); + return Err(failure); + } + removed += 1; + } + Ok((removed, retained)) + } + pub fn release(&self, id: &str) -> Result<()> { + if self.read_only { return Err(Failure::policy("read-only", "Read-only calls cannot release references")); } + let name = Self::name(id)?; + let _file = cache_io::open_file(&self.dir, &name, false, self.write_sid.as_deref())?; + self.dir.remove_file(&name)?; + #[cfg(unix)] ops_files::sync_directory(&self.dir)?; + Ok(()) + } +} + +#[cfg(test)] +#[path = "cache_tests.rs"] +mod tests; diff --git a/native/crates/ops-results/src/cache_tests.rs b/native/crates/ops-results/src/cache_tests.rs new file mode 100644 index 00000000..d221bf26 --- /dev/null +++ b/native/crates/ops-results/src/cache_tests.rs @@ -0,0 +1,84 @@ +use super::*; +use crate::contract::storage::StorageContext; +use serde_json::json; +fn fixture(body: impl FnOnce(&Cache, &std::path::Path)) { + let root = std::fs::canonicalize(std::env::temp_dir()).unwrap().join(format!("ops-cache-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&root).unwrap(); + let mut request: Request = serde_json::from_value(json!({"root":root,"scope":"test","tool":"result","args":{}})).unwrap(); + request.storage = Some(StorageContext { windows_write_sid: None, root: root.to_string_lossy().into(), authority_id: "a".repeat(64), allowed_tools: vec!["result".into()] }); + { + let cache = Cache::open(&request).unwrap(); + body(&cache, &root); + } + std::fs::remove_dir_all(root).unwrap(); +} +#[test] +fn production_caps_lru_and_orphans_preserve_non_cache_audits() { + assert_eq!(MAX_RESULT_BYTES, 10 * 1024 * 1024); + assert_eq!(MAX_SESSION_BYTES, 100 * 1024 * 1024); + fixture(|cache, _| { + let data = json!("x".repeat(MAX_RESULT_BYTES - 2)); // exact physical JSON cap + let oversized = json!("x".repeat(MAX_RESULT_BYTES - 1)); + assert_eq!(cache.save(&uuid::Uuid::new_v4().to_string(), &oversized).unwrap_err().code, "QUOTA_EXCEEDED"); + let ids: Vec<_> = (0..10).map(|_| uuid::Uuid::new_v4().to_string()).collect(); + for id in &ids { assert_eq!(cache.save(id, &data).unwrap(), MAX_RESULT_BYTES); } + // Touching the oldest result protects it; eviction must choose the next-oldest. + cache.touch(&ids[0]).unwrap(); + let next = uuid::Uuid::new_v4().to_string(); cache.save(&next, &data).unwrap(); + assert!(cache.read::(&ids[0]).is_ok()); + assert_eq!(cache.read::(&ids[1]).unwrap_err().code, "REFERENCE_EXPIRED"); + let mut audit = cache_io::create_file(&cache.dir, "j-audit.json", None).unwrap(); + audit.write_all(b"never evict this audit").unwrap(); drop(audit); + // An interrupted stage consumes disk; save reclaims that exact temporary namespace. + let orphan = format!("{}.tmp", uuid::Uuid::new_v4()); + let mut temporary = cache_io::create_file(&cache.dir, &orphan, None).unwrap(); + temporary.write_all(&vec![b'x'; 4096]).unwrap(); drop(temporary); + cache.save(&uuid::Uuid::new_v4().to_string(), &data).unwrap(); + assert!(cache.dir.symlink_metadata(&orphan).is_err()); + let mut audit = cache_io::open_file(&cache.dir, "j-audit.json", false, None).unwrap(); + let mut contents = String::new(); audit.read_to_string(&mut contents).unwrap(); + assert_eq!(contents, "never evict this audit"); + assert!(cache.entries().unwrap().iter().map(|entry| entry.bytes).sum::() <= MAX_SESSION_BYTES); + // A repeated identity cannot evict its old record and then replace it. + assert_eq!(cache.save(&next, &json!("replacement")).unwrap_err().code, "REVISION_CONFLICT"); + let (unchanged, bytes) = cache.read::(&next).unwrap(); + assert_eq!(unchanged, data); assert_eq!(bytes, MAX_RESULT_BYTES); + }); +} +#[test] +fn private_objects_reject_special_and_shared_inodes() { + fixture(|cache, _| { + let id = uuid::Uuid::new_v4().to_string(); cache.save(&id, &json!({"x":1})).unwrap(); + let name = Cache::name(&id).unwrap(); + cache.dir.hard_link(&name, &cache.dir, "alias").unwrap(); + assert_eq!(cache.read::(&id).unwrap_err().code, "PATH_RESTRICTED"); + cache.dir.remove_file("alias").unwrap(); + assert!(cache.read::(&id).is_ok()); + #[cfg(unix)] { + use cap_std::fs::PermissionsExt; + cache.dir.set_permissions(&name, cap_std::fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!(cache.read::(&id).unwrap_err().code, "PATH_RESTRICTED"); + } + }); +} + +#[cfg(windows)] +#[test] +fn failed_purge_reports_partial_cache_effects_and_retains_the_lock() { + use std::os::windows::fs::OpenOptionsExt; + fixture(|cache, root| { + let first = "00000000-0000-4000-8000-000000000001"; + let blocked = "00000000-0000-4000-8000-000000000002"; + cache.save(first, &json!({"x":1})).unwrap(); cache.save(blocked, &json!({"x":2})).unwrap(); + let session = root.join(format!("s-{:x}", Sha256::digest(b"test"))); + // Holding the last canonical name without FILE_SHARE_DELETE deterministically + // fails after the earlier snapshot was removed. This is an actual OS denial. + let held = std::fs::OpenOptions::new().read(true).share_mode(3).open(session.join(Cache::name(blocked).unwrap())).unwrap(); + let failure = cache.purge().unwrap_err(); + assert_eq!(failure.code, "CACHE_PURGE_FAILED"); assert_eq!(&*failure.phase, "execute"); + assert_eq!(&*failure.effect_state, "partial"); assert!(failure.next_action.contains("Never rerun business")); + assert!(!session.join(Cache::name(first).unwrap()).exists()); + assert!(session.join(Cache::name(blocked).unwrap()).exists()); assert!(session.join("lock").exists()); + drop(held); assert_eq!(cache.purge().unwrap(), (1, 1)); + }); +} diff --git a/native/crates/ops-results/src/lib.rs b/native/crates/ops-results/src/lib.rs index 6f34c4ce..06a793d7 100644 --- a/native/crates/ops-results/src/lib.rs +++ b/native/crates/ops-results/src/lib.rs @@ -4,5 +4,7 @@ use ops_files::paths; #[cfg(unix)] use ops_files::sync_directory; pub mod store; +pub mod cache; +pub mod references; mod results; pub use results::{run, referenced_items}; diff --git a/native/crates/ops-results/src/references.rs b/native/crates/ops-results/src/references.rs new file mode 100644 index 00000000..e269f5ad --- /dev/null +++ b/native/crates/ops-results/src/references.rs @@ -0,0 +1,145 @@ +//! Session reference wire protocol. Handles identify snapshots and grant no authority. +use crate::{cache::Cache, contract::*}; +use ops_protocol::storage::StorageReference; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Record { + #[serde(skip)] + stored_bytes: usize, + version: u32, scope: String, root: String, root_identity: String, authority_id: String, + producer_tool: String, created_at: u64, ttl_ms: u64, items: Vec, +} +fn now_ms() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64 } +fn segment(text: &str) -> String { + text.bytes().map(|byte| if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { (byte as char).to_string() } else { format!("%{byte:02X}") }).collect() +} +fn parse(request: &Request, reference: &str) -> Result<(String, String)> { + let parts: Vec<_> = reference.strip_prefix("ref://").unwrap_or("").split('/').collect(); + if parts.len() != 3 || !ops_protocol::storage::valid_tool(parts[1]) || uuid::Uuid::parse_str(parts[2]).is_err() || parts[2] != uuid::Uuid::parse_str(parts[2]).unwrap().to_string() { + return Err(Failure::new("INVALID_ARGUMENT", "Expected ref://session/tool/uuid")); + } + if parts[0] != segment(&request.scope) { return Err(Failure::new("POLICY_DENIED", "Reference belongs to another session")); } + let context = request.storage.as_ref().ok_or_else(|| Failure::new("POLICY_DENIED", "Trusted storage context is required"))?; + context.authorize(parts[1])?; + Ok((parts[1].into(), parts[2].into())) +} +fn nested_reference(value: &Value) -> bool { + match value { + Value::Object(object) => object.get("refId").and_then(Value::as_str).is_some_and(|id| id.starts_with("ref://")) || object.values().any(nested_reference), + Value::Array(items) => items.iter().any(nested_reference), _ => false, + } +} +impl Record { + fn authorize(&self, request: &Request, producer: &str, allow_expired: bool) -> Result<()> { + let context = request.storage.as_ref().ok_or_else(|| Failure::new("POLICY_DENIED", "Trusted storage context is required"))?; + if self.version != 1 || self.scope != request.scope || self.root != request.root || self.root_identity != request.root_identity || self.authority_id != context.authority_id || self.producer_tool != producer { + return Err(Failure::new("POLICY_DENIED", "Reference session, root, producer, or current authority differs")); + } + context.authorize(producer)?; + if !allow_expired && self.ttl_ms != 0 && now_ms() >= self.created_at.saturating_add(self.ttl_ms) { + return Err(Failure::new("REFERENCE_EXPIRED", "Result observation expired")); + } + Ok(()) + } + fn reference(&self, id: &str) -> Result { + // The public handle is not embedded in the blob, so physical byte accounting has no cycle. + let text = self.items.iter().take(10).map(serde_json::to_string).collect::, _>>()?.join("\n"); + let preview: String = text.chars().take(512).collect(); + Ok(StorageReference { ref_id: format!("ref://{}/{}/{}", segment(&self.scope), self.producer_tool, id), created_at: self.created_at, ttl_ms: self.ttl_ms, + schema_version: "1".into(), producer_tool: self.producer_tool.clone(), total_items: self.items.len(), total_bytes: if self.stored_bytes == 0 { serde_json::to_vec(self)?.len() } else { self.stored_bytes }, preview }) + } +} +fn existing_cache(request: &Request) -> Result { + Cache::existing(request).map_err(|error| if error.code == "NOT_FOUND" { + Failure::new("REFERENCE_EXPIRED", "Reference cache is missing, expired, or destroyed") + } else { error }) +} +pub fn referenced_items(request: &Request, reference: &str) -> Result> { + let (producer, id) = parse(request, reference)?; + let cache = existing_cache(request)?; + let (mut record, bytes): (Record, usize) = cache.read(&id)?; + record.stored_bytes = bytes; + record.authorize(request, &producer, false)?; + cache.touch(&id)?; + Ok(record.items) +} +pub fn run(request: &Request) -> Result { + let args: ResultArgs = serde_json::from_value(request.args.clone())?; + if args.action == "purge" { + if request.read_only { return Err(Failure::policy("read-only", "Read-only calls cannot purge references")); } + if args.id.is_some() || args.items.is_some() || args.ttl_ms.is_some() || args.offset != 0 || args.limit.is_some() || args.max_bytes.is_some() || args.fields.is_some() || args.equals.is_some() { + return Err(Failure::new("INVALID_ARGUMENT", "purge accepts only action and trusted envelope storage metadata")); + } + let counts = match Cache::existing(request) { + Ok(cache) => cache.purge()?, + Err(error) if error.code == "NOT_FOUND" => (0, 0), + Err(error) => return Err(error), + }; + return Ok(json!({"status":"completed","complete":true,"removed":counts.0,"retained":counts.1,"effectState":if counts.0 == 0 {"none"} else {"applied"}})); + } + if args.action == "put" { + if args.id.is_some() { return Err(Failure::new("INVALID_ARGUMENT", "put forbids id")); } + let items = args.items.ok_or_else(|| Failure::new("INVALID_ARGUMENT", "put requires items"))?; + if items.len() > 10_000 || items.iter().any(|item| !item.is_object() || nested_reference(item)) { + return Err(Failure::new("INVALID_ARGUMENT", "put accepts at most 10000 objects without nested reference handles")); + } + let ttl_ms = args.ttl_ms.unwrap_or(0); + if ttl_ms > 7 * 86400 * 1000 { return Err(Failure::new("INVALID_ARGUMENT", "ttlMs is 0 (session lifetime) or at most seven days")); } + let context = request.storage.as_ref().ok_or_else(|| Failure::new("POLICY_DENIED", "Trusted storage context is required"))?; + context.authorize(&request.tool)?; + if request.read_only { return Err(Failure::policy("read-only", "Read-only calls cannot create references")); } + let id = uuid::Uuid::new_v4().to_string(); + let record = Record { stored_bytes: 0, version: 1, scope: request.scope.clone(), root: request.root.clone(), root_identity: request.root_identity.clone(), + authority_id: context.authority_id.clone(), producer_tool: request.tool.clone(), created_at: now_ms(), ttl_ms, items }; + let reference = record.reference(&id)?; + Cache::open(request)?.save(&id, &record)?; + return Ok(json!({"status":"completed","id":reference.ref_id,"reference":reference,"total":record.items.len(),"complete":false,"omitted":record.items.len(),"nextAction":"Page this reference; it grants no authority. Reference pages never spill into another reference."})); + } + if !matches!(args.action.as_str(), "page" | "release") || args.items.is_some() || args.ttl_ms.is_some() { + return Err(Failure::new("INVALID_ARGUMENT", "Expected page/release without items or ttlMs")); + } + let reference = args.id.as_deref().ok_or_else(|| Failure::new("INVALID_ARGUMENT", "Reference id required"))?; + let (producer, id) = parse(request, reference)?; + if args.action == "release" && request.read_only { return Err(Failure::policy("read-only", "Read-only calls cannot release references")); } + let cache = existing_cache(request)?; + let (mut record, bytes): (Record, usize) = cache.read(&id)?; + record.stored_bytes = bytes; + record.authorize(request, &producer, args.action == "release")?; + if args.action == "release" { cache.release(&id)?; return Ok(json!({"id":reference,"status":"released"})); } + let value = page(&record, &args, &id)?; + cache.touch(&id)?; + Ok(value) +} +fn page(record: &Record, args: &ResultArgs, id: &str) -> Result { + let limit = args.limit.unwrap_or(64); let budget = args.max_bytes.unwrap_or(16_384); + if limit == 0 || limit > 512 || !(512..=65536).contains(&budget) + || args.fields.as_ref().is_some_and(|fields| fields.len() > 32 || fields.iter().any(|field| field.len() > 128)) + || args.equals.as_ref().is_some_and(|filter| filter.len() > 16) { + return Err(Failure::new("INVALID_ARGUMENT", "Invalid result page bounds")); + } + let reference = record.reference(id)?; + let rows: Vec<_> = record.items.iter().filter(|item| args.equals.as_ref().is_none_or(|filter| filter.iter().all(|(key, value)| item.get(key) == Some(value)))).collect(); + let total = rows.len(); let start = args.offset.min(total); + let mut value = json!({"id":reference.ref_id,"reference":reference,"items":[],"sourceTotal":record.items.len(),"contentHash":format!("sha256:{:x}", Sha256::digest(serde_json::to_vec(&record.items)?)),"total":total,"nextOffset":total,"complete":false,"omitted":total}); + let header = serde_json::to_vec(&value)?.len() + 1; + if header > budget { return Err(Failure::new("OUTPUT_LIMIT", "Reference metadata exceeds page budget; increase maxBytes. A reference page never creates another reference.")); } + let mut room = budget - header; let mut items = Vec::new(); + for item in rows.into_iter().skip(start).take(limit) { + let item = match &args.fields { + Some(fields) => Value::Object(fields.iter().filter_map(|key| item.get(key).map(|value| (key.clone(), value.clone()))).collect()), + None => item.clone(), + }; + let size = serde_json::to_vec(&item)?.len() + 1; + if size > room { + if items.is_empty() { return Err(Failure::new("OUTPUT_LIMIT", "One row exceeds page budget; project fewer fields or increase maxBytes. Reference pages never create new references.")); } + break; + } + room -= size; items.push(item); + } + let next = start + items.len(); value["items"] = json!(items); value["nextOffset"] = json!(next); + value["omitted"] = json!(total - next); value["complete"] = json!(next == total); + Ok(value) +} diff --git a/native/crates/ops-results/src/results.rs b/native/crates/ops-results/src/results.rs index bf577612..3a8adb26 100644 --- a/native/crates/ops-results/src/results.rs +++ b/native/crates/ops-results/src/results.rs @@ -19,6 +19,7 @@ struct Record { const TTL: u64 = 86400; pub fn referenced_items(store: &Store, request: &Request, id: &str) -> Result> { + if id.starts_with("ref://") { return crate::references::referenced_items(request, id); } Store::validate_id(id)?; if !id.starts_with("r-") { return Err(Failure::new( @@ -44,6 +45,7 @@ pub fn referenced_items(store: &Store, request: &Request, id: &str) -> Result Result { let args: ResultArgs = serde_json::from_value(request.args.clone())?; + if args.ttl_ms.is_some() { return Err(Failure::new("INVALID_ARGUMENT", "ttlMs requires the versioned session cache")); } match args.action.as_str() { "put" => { if args.id.is_some() { diff --git a/native/src/lib.rs b/native/src/lib.rs index bf39e7aa..aec212de 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -37,6 +37,11 @@ pub fn dispatch(request: Request) -> Result { use cap_fs_ext::MetadataExt; let metadata = dir.dir_metadata()?; request.root_identity = format!("{}:{}", metadata.dev(), metadata.ino()); + if request.tool == "result" && ((request.storage.is_some() && request.args.get("action").and_then(Value::as_str) == Some("put")) + || request.args.get("action").and_then(Value::as_str) == Some("purge") + || request.args.get("id").and_then(Value::as_str).is_some_and(|id| id.starts_with("ref://"))) { + return ops_results::references::run(&request); + } if request.read_only && !(request.tool == "result" && request.args.get("action").and_then(Value::as_str) == Some("page")) diff --git a/native/windows-metadata/src/lib.rs b/native/windows-metadata/src/lib.rs index 2f82fee7..e9b4ad63 100644 --- a/native/windows-metadata/src/lib.rs +++ b/native/windows-metadata/src/lib.rs @@ -86,15 +86,46 @@ fn control(sd: &SecurityDescriptor) -> io::Result { /// CREATE_NEW relative to a held directory, with a protected current-process /// user DACL at creation, before any bytes can be written. No inherited grants. -pub fn create_private(parent: &File, name: &str) -> io::Result { +pub fn create_private(parent: &File, name: &str) -> io::Result { create_private_object(parent, name, false, None) } +/// A private cache directory, created relative to an already-held directory. +pub fn create_cache_object(parent: &File, name: &str, directory: bool, write_sid: Option<&str>) -> io::Result { + if let Some(capability) = write_sid { + let _ = private_descriptor(Some(capability))?; + // The envelope names the host's exact private-temp capability, and the held + // parent must already grant it. Never synthesize workspace/public grants. + let inherited = sddl(&*descriptor(parent)?)?.to_string_lossy().split('(').any(|ace| { + let parts: Vec<_> = ace.trim_end_matches(')').split(';').collect(); + if parts.len() != 6 || parts[0] != "A" || parts[5] != capability { return false; } + let mask = if parts[2] == "FA" { 0x1f01ff } else { u32::from_str_radix(parts[2].trim_start_matches("0x"), 16).unwrap_or(0) }; + mask & 0x116 == 0x116 + }); + if !inherited { return Err(unsupported("Cache parent lacks the trusted private-temp write capability")); } + } + create_private_object(parent, name, directory, write_sid) +} +fn private_descriptor(write_sid: Option<&str>) -> io::Result> { + let sid = windows_permissions::utilities::current_process_sid()?; + let extra = match write_sid { + None => String::new(), + Some(capability) => { + let parts: Vec<_> = capability.split('-').collect(); + if parts.len() != 6 || parts[..3] != ["S", "1", "4"] || parts[5] != "1" + || !parts[3..5].iter().all(|part| part.parse::().is_ok_and(|value| value > 0 && value < 1 << 30 && value.to_string() == *part)) { + return Err(unsupported("Expected the host's private-temp capability SID")); + } + format!("(A;;FA;;;{capability})") + } + }; + format!("D:P(A;;FA;;;{sid}){extra}").parse() +} +fn create_private_object(parent: &File, name: &str, directory: bool, write_sid: Option<&str>) -> io::Result { if name.is_empty() || name.contains(['/', '\\', ':', '\0']) || name == "." || name == ".." { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Expected a single sibling name", )); } - let sid = windows_permissions::utilities::current_process_sid()?; - let private: LocalBox = format!("D:P(A;;FA;;;{sid})").parse()?; + let private = private_descriptor(write_sid)?; let mut wide: Vec = name.encode_utf16().collect(); let length = u16::try_from(wide.len() * 2) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Sibling name too long"))?; @@ -126,10 +157,10 @@ pub fn create_private(parent: &File, name: &str) -> io::Result { &attributes, &mut status, std::ptr::null(), - 0x80, // FILE_ATTRIBUTE_NORMAL + if directory { 0x10 } else { 0x80 }, // DIRECTORY or NORMAL 7, // FILE_SHARE_READ|WRITE|DELETE 2, // FILE_CREATE (never overwrite) - 0x00200000 | 0x40 | 0x20, // OPEN_REPARSE_POINT|NON_DIRECTORY|SYNCHRONOUS_IO_NONALERT + 0x00200000 | (if directory { 0x1 } else { 0x40 }) | 0x20, // no-follow, object kind, synchronous std::ptr::null(), 0, ) @@ -139,20 +170,7 @@ pub fn create_private(parent: &File, name: &str) -> io::Result { } // SAFETY: successful NtCreateFile transferred one live owned file handle. let file = unsafe { File::from_raw_handle(handle) }; - let verified = (|| -> io::Result<()> { - let actual = descriptor(&file)?; - let actual_dacl = sddl(&actual)? - .to_string_lossy() - .split("D:") - .nth(1) - .unwrap_or("") - .replace("AI", ""); - let expected = sddl(&private)?.to_string_lossy().into_owned(); - if actual_dacl != expected.trim_start_matches("D:") { - return Err(unsupported("Private creation DACL verification failed")); - } - Ok(()) - })(); + let verified = verify_cache(&file, write_sid); if let Err(error) = verified { let _ = discard(&file); return Err(error); @@ -160,6 +178,16 @@ pub fn create_private(parent: &File, name: &str) -> io::Result { Ok(file) } +/// Cache objects are private to the current user plus exactly the trusted temp capability. +pub fn verify_cache(file: &File, write_sid: Option<&str>) -> io::Result<()> { + let private = private_descriptor(write_sid)?; + let actual = descriptor(file)?; + let actual_dacl = sddl(&actual)?.to_string_lossy().split("D:").nth(1).unwrap_or("").replace("AI", ""); + let expected = sddl(&private)?.to_string_lossy().into_owned(); + if actual_dacl != expected.trim_start_matches("D:") { return Err(unsupported("Private creation DACL verification failed")); } + Ok(()) +} + /// Captures mandatory security and supported basic metadata. Named streams, /// extended attributes, EFS/compression/sparse/other special storage fail closed. pub struct Metadata { diff --git a/test/result-references.test.mjs b/test/result-references.test.mjs new file mode 100644 index 00000000..db8acb4d --- /dev/null +++ b/test/result-references.test.mjs @@ -0,0 +1,269 @@ +/** Session cache references remain data handles, never authority or nested output wrappers. */ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import Sandbox from '@deepseek-ai/dsh-sandbox-local' +import { bootHost, shutdown } from './lib/host.mjs' +import { prepareWorkspace } from '../lib/workspace-preparation.js' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { callTool, callWorker, request, envelopeOf, requireWorker, WORKER } from './lib/boundary.mjs' +requireWorker('result-references') +const authorityId = 'a'.repeat(64) +const scope = 'session / 測試' +const directory = cache => path.join(cache, `s-${createHash('sha256').update(scope).digest('hex')}`) +const input = (root, cache, args, options = {}) => ({ version: 1, requestId: 'reference-test', component: 'ptc', budget: { timeoutMs: 30000, maxBytes: 131072 }, + storage: { root: cache, authorityId, allowedTools: ['result', 'batch'] }, request: request('result', args, { root, scope, ...options }) }) +const send = async value => envelopeOf(await callWorker(JSON.stringify(value)), assert) +const call = (root, cache, args, options) => send(input(root, cache, args, options)) +const fixture = body => withTempDir('refs', async root => { const cache = path.join(root, 'cache'); fs.mkdirSync(cache, { mode: 0o700 }); await body(root, cache) }) +const blob = (cache, handle) => path.join(directory(cache), `${handle.id.split('/').at(-1)}.json`) +const snapshot = root => fs.readdirSync(root, { recursive: true }).sort() + +await test('handles expose physical snapshot metadata, session lifetime, and lossless bounded pages', async () => { + await fixture(async (root, cache) => { + const rows = Array.from({ length: 200 }, (_, index) => ({ index, text: `row ${index} \0 中`, kind: index % 2 })) + const stored = await call(root, cache, { action: 'put', items: rows }) + assert.equal(stored.status, 'completed', JSON.stringify(stored)) + assert.match(stored.id, /^ref:\/\/session%20%2F%20%E6%B8%AC%E8%A9%A6\/result\/[a-f0-9-]{36}$/) + assert.equal(stored.reference.refId, stored.id); assert.equal(stored.reference.ttlMs, 0) + assert.equal(stored.reference.producerTool, 'result'); assert.equal(stored.reference.schemaVersion, '1') + assert.equal(stored.reference.totalItems, 200); assert.equal(stored.reference.totalBytes, fs.statSync(blob(cache, stored)).size) + assert.ok(stored.reference.createdAt > 1e12); assert.ok(stored.reference.preview.split('\n').length <= 10) + assert.equal(fs.existsSync(path.join(root, '.dsh-ops')), false) + const collected = []; let offset = 0 + do { + const raw = await callWorker(JSON.stringify(input(root, cache, { action: 'page', id: stored.id, offset, limit: 17, maxBytes: 4096 }))) + const page = envelopeOf(raw, assert); assert.ok(Buffer.byteLength(raw.stdout) <= 4096) + assert.equal(page.reference.totalBytes, stored.reference.totalBytes) + collected.push(...page.items); assert.equal(page.nextOffset, offset + page.items.length); offset = page.nextOffset + if (page.complete) break + assert.ok(page.items.length > 0) + } while (offset < rows.length) + assert.deepEqual(collected, rows) + const filtered = await call(root, cache, { action: 'page', id: stored.id, equals: { kind: 1 }, fields: ['index'], limit: 512 }) + assert.equal(filtered.total, 100); assert.equal(filtered.sourceTotal, 200) + assert.deepEqual(filtered.items[1], { index: 3 }); assert.equal(filtered.complete, true) + }) +}) + +await test('references recheck session, root, authority, capability and credentials on every access', async () => { + await fixture(async (root, cache) => { + const stored = await call(root, cache, { action: 'put', items: [{ text: 'private snapshot' }] }) + const other = path.join(root, 'other'); fs.mkdirSync(other) + for (const mutate of [ + value => { value.request.scope = 'foreign' }, + value => { value.request.root = other }, + value => { value.storage.authorityId = 'b'.repeat(64) }, + value => { value.storage.allowedTools = [] }, + value => { value.request.args.id = stored.id.replace('/result/', '/batch/') }, + value => { value.request.deny_roots = [cache] }, + ]) { + const value = input(root, cache, { action: 'page', id: stored.id }); mutate(value) + const denied = await send(value) + assert.ok(['POLICY_DENIED', 'CREDENTIAL_DENIED'].includes(denied.error?.code), JSON.stringify(denied)) + assert.equal(denied.error.effectState, 'none'); assert.equal(denied.items, undefined) + } + assert.equal((await call(root, cache, { action: 'page', id: stored.id })).items[0].text, 'private snapshot') + }) +}) + +await test('read-only pages do not touch LRU or create storage and cannot put or release', async () => { + await fixture(async (root, cache) => { + const stored = await call(root, cache, { action: 'put', items: [{ value: 1 }] }) + const filename = blob(cache, stored); const beforeTime = fs.statSync(filename).mtimeMs; const before = snapshot(root) + const page = await call(root, cache, { action: 'page', id: stored.id }, { readOnly: true }) + assert.equal(page.complete, true); assert.equal(fs.statSync(filename).mtimeMs, beforeTime) + assert.deepEqual(snapshot(root), before) + for (const args of [{ action: 'put', items: [] }, { action: 'release', id: stored.id }]) { + const denied = await call(root, cache, args, { readOnly: true }) + assert.equal(denied.error.code, 'POLICY_DENIED'); assert.equal(denied.error.effectState, 'none') + } + const other = path.join(root, 'empty-cache'); fs.mkdirSync(other) + const missing = await call(root, other, { action: 'page', id: stored.id }, { readOnly: true }) + assert.equal(missing.error.code, 'REFERENCE_EXPIRED'); assert.deepEqual(fs.readdirSync(other), []) + assert.equal((await call(root, other, { action: 'page', id: stored.id })).error.code, 'REFERENCE_EXPIRED') + assert.deepEqual(fs.readdirSync(other), []) + }) +}) + +await test('trusted metadata cannot be injected through args, legacy requests or partial envelopes', async () => { + await fixture(async (root, cache) => { + const cases = [] + const valid = input(root, cache, { action: 'put', items: [] }) + cases.push({ ...valid, storage: { ...valid.storage, authorityId: 'invalid' } }) + cases.push({ ...valid, storage: { ...valid.storage, allowedTools: ['../result'] } }) + for (const sid of ['S-1-1-0', 'S-1-5-5-1-2', 'S-1-4-1-2', 'S-1-4-0-1-1', 'S-1-4-01-2-1', 'S-1-4-1073741824-1-1']) { + cases.push({ ...valid, storage: { ...valid.storage, windowsWriteSid: sid } }) + } + cases.push({ ...valid, request: { ...valid.request, storage: valid.storage } }) + cases.push({ ...valid, request: { ...valid.request, args: { ...valid.request.args, storage: valid.storage } } }) + cases.push({ ...valid.request, storage: valid.storage }) + for (const value of cases) { const denied = await send(value); assert.equal(denied.error.code, 'INVALID_ARGUMENT', JSON.stringify(denied)); assert.equal(denied.error.effectState, 'none') } + const noStorage = input(root, cache, { action: 'purge' }); delete noStorage.storage + assert.equal((await send(noStorage)).error.code, 'POLICY_DENIED') + assert.deepEqual(fs.readdirSync(cache), []) + assert.equal(fs.existsSync(path.join(root, '.dsh-ops')), false) + }) +}) + +await test('nested handles are refused and oversized reference pages never produce another handle', async () => { + await fixture(async (root, cache) => { + const stored = await call(root, cache, { action: 'put', items: [{ large: 'x'.repeat(10000), small: 1 }] }) + for (const nested of [stored.reference, { deep: [stored.reference] }]) { + const denied = await call(root, cache, { action: 'put', items: [nested] }) + assert.equal(denied.error.code, 'INVALID_ARGUMENT') + } + const before = snapshot(cache) + const limited = await call(root, cache, { action: 'page', id: stored.id, maxBytes: 2048 }) + assert.equal(limited.error.code, 'OUTPUT_LIMIT'); assert.equal(limited.reference, undefined) + assert.deepEqual(snapshot(cache), before) + const projected = await call(root, cache, { action: 'page', id: stored.id, fields: ['small'], maxBytes: 2048 }) + assert.deepEqual(projected.items, [{ small: 1 }]); assert.equal(projected.complete, true) + const text = await call(root, cache, { action: 'put', items: [{ text: stored.id }] }) + assert.equal(text.status, 'completed', 'literal text is not a nested StorageReference') + }) +}) + +await test('TTL expiry and release return expired references while retaining session ownership checks', async () => { + await fixture(async (root, cache) => { + const stored = await call(root, cache, { action: 'put', items: [{ value: 1 }], ttlMs: 1 }) + const expired = await call(root, cache, { action: 'page', id: stored.id }) + assert.equal(expired.error.code, 'REFERENCE_EXPIRED') + const foreign = input(root, cache, { action: 'release', id: stored.id }); foreign.request.scope = 'other' + assert.equal((await send(foreign)).error.code, 'POLICY_DENIED') + assert.equal((await call(root, cache, { action: 'release', id: stored.id })).status, 'released') + assert.equal((await call(root, cache, { action: 'page', id: stored.id })).error.code, 'REFERENCE_EXPIRED') + const tooLong = await call(root, cache, { action: 'put', items: [], ttlMs: 604800001 }) + assert.equal(tooLong.error.code, 'INVALID_ARGUMENT') + }) +}) + +await test('no-follow protects cache parents, session directories, locks and stored snapshots', async () => { + await fixture(async (root, cache) => { + const stored = await call(root, cache, { action: 'put', items: [{ value: 1 }] }) + const alias = path.join(root, 'alias'); fs.symlinkSync(cache, alias, process.platform === 'win32' ? 'junction' : 'dir') + assert.equal((await call(root, alias, { action: 'page', id: stored.id })).error.code, 'PATH_RESTRICTED') + const session = directory(cache); const real = `${session}-real`; fs.renameSync(session, real) + fs.symlinkSync(real, session, process.platform === 'win32' ? 'junction' : 'dir') + assert.equal((await call(root, cache, { action: 'page', id: stored.id })).error.code, 'PATH_RESTRICTED') + assert.equal(fs.lstatSync(session).isSymbolicLink(), true); fs.unlinkSync(session); fs.renameSync(real, session) + const snapshotPath = blob(cache, stored); fs.linkSync(snapshotPath, `${snapshotPath}.alias`) + assert.equal((await call(root, cache, { action: 'page', id: stored.id })).error.code, 'PATH_RESTRICTED') + fs.unlinkSync(`${snapshotPath}.alias`) + const lock = path.join(session, 'lock'); fs.linkSync(lock, `${lock}.alias`) + assert.equal((await call(root, cache, { action: 'page', id: stored.id })).error.code, 'PATH_RESTRICTED') + fs.unlinkSync(`${lock}.alias`) + assert.equal((await call(root, cache, { action: 'page', id: stored.id })).complete, true) + }) +}) + +await test('new operation references feed existing batch authority and leave durable audit records separate', async () => { + await fixture(async (root, cache) => { + fs.writeFileSync(path.join(root, 'source'), 'content') + const stored = await call(root, cache, { action: 'put', items: [{ op: 'copy', path: 'source', to: 'destination' }] }) + const value = input(root, cache, { action: 'plan', operationsRef: stored.id }); value.request.tool = 'batch' + const plan = await send(value) + assert.match(plan.id, /^j-/); assert.equal(fs.existsSync(path.join(root, 'destination')), false) + const audit = path.join(root, '.dsh-ops'); const before = Object.fromEntries(fs.readdirSync(audit).map(name => [name, fs.readFileSync(path.join(audit, name)).toString('hex')])) + assert.equal((await call(root, cache, { action: 'release', id: stored.id })).status, 'released') + const after = Object.fromEntries(fs.readdirSync(audit).map(name => [name, fs.readFileSync(path.join(audit, name)).toString('hex')])) + assert.deepEqual(after, before) + const page = envelopeOf(await callTool('result', { action: 'page', id: plan.id }, { root, scope }), assert) + assert.equal(page.items[0].state, 'pending') + }) +}) + +await test('cross-process writers serialize quota decisions and never evict audit records', async () => { + await fixture(async (root, cache) => { + fs.writeFileSync(path.join(root, 'source'), 'audit source') + const audit = envelopeOf(await callTool('batch', { action: 'plan', operations: [{ op: 'copy', path: 'source', to: 'dest' }] }, { root, scope }), assert) + assert.match(audit.id, /^j-/) + const auditDir = path.join(root, '.dsh-ops') + const before = Object.fromEntries(fs.readdirSync(auditDir).map(name => [name, fs.readFileSync(path.join(auditDir, name)).toString('hex')])) + const payload = 'x'.repeat(1_900_000); const handles = [] + for (let index = 0; index < 57; index++) { + const value = await call(root, cache, { action: 'put', items: [{ index, payload }] }) + assert.equal(value.status, 'completed', JSON.stringify(value)); handles.push(value) + } + assert.equal((await call(root, cache, { action: 'page', id: handles[0].id })).error.code, 'REFERENCE_EXPIRED') + const values = await Promise.all(Array.from({ length: 8 }, (_, index) => call(root, cache, { action: 'put', items: [{ concurrent: index, payload }] }))) + assert.ok(values.some(value => value.status === 'completed')) + for (const value of values) assert.ok(value.status === 'completed' || value.error?.code === 'JOB_BUSY', JSON.stringify(value)) + const session = directory(cache) + const size = fs.readdirSync(session).reduce((sum, name) => sum + fs.statSync(path.join(session, name)).size, 0) + assert.ok(size <= 100 * 1024 * 1024, String(size)) + const after = Object.fromEntries(fs.readdirSync(auditDir).map(name => [name, fs.readFileSync(path.join(auditDir, name)).toString('hex')])) + assert.deepEqual(after, before) + assert.equal(fs.existsSync(path.join(root, 'dest')), false) + }) +}) +await test('session purge removes only validated snapshots and stages, preserving lock and audit namespaces', async () => { + await fixture(async (root, cache) => { + const store = () => call(root, cache, { action: 'put', items: [{ value: 1 }] }) + const first = await store(); await store() + const preserved = await store(); const auditPath = path.join(directory(cache), 'j-audit.json') + fs.renameSync(blob(cache, preserved), auditPath) + const auditBytes = fs.readFileSync(auditPath) + const stage = await store(); fs.renameSync(blob(cache, stage), blob(cache, stage).replace(/\.json$/, '.tmp')) + const foreign = await call(root, cache, { action: 'put', items: [{ private: 'other' }] }, { scope: 'other' }) + const lockPath = path.join(directory(cache), 'lock'); const inode = fs.statSync(lockPath, { bigint: true }).ino + const before = snapshot(cache) + assert.equal((await call(root, cache, { action: 'purge' }, { readOnly: true })).error.code, 'POLICY_DENIED') + assert.equal((await call(root, cache, { action: 'purge', id: first.id })).error.code, 'INVALID_ARGUMENT') + assert.deepEqual(snapshot(cache), before) + const purged = await call(root, cache, { action: 'purge' }) + assert.equal(purged.status, 'completed'); assert.equal(purged.complete, true) + assert.equal(purged.removed, 3); assert.equal(purged.retained, 2); assert.equal(purged.effectState, 'applied') + assert.equal(fs.statSync(lockPath, { bigint: true }).ino, inode); assert.deepEqual(fs.readFileSync(auditPath), auditBytes) + assert.equal((await call(root, cache, { action: 'page', id: first.id })).error.code, 'REFERENCE_EXPIRED') + assert.equal((await call(root, cache, { action: 'page', id: foreign.id }, { scope: 'other' })).items[0].private, 'other') + assert.equal((await call(root, cache, { action: 'purge' })).effectState, 'none') + const absent = path.join(root, 'never-created') + assert.equal((await call(root, absent, { action: 'purge' })).removed, 0); assert.equal(fs.existsSync(absent), false) + }) +}) + +if (process.platform === 'win32') await test('real Windows confinement permits the private temp capability but denies read-only and foreign-session writes', async () => { + await withTempDir('refs-confined', async root => { + const { ctx } = await bootHost() + try { + await ctx.plugin(Sandbox) + const policy = { mode: 'workspace-write', workspaceRoot: root, sessionId: 'ref-capability-owner' } + await prepareWorkspace(policy) + const confine = (argv, changes = {}) => ctx.get('sandbox').confine(argv, { ...policy, ...changes }, new AbortController().signal) + const confined = await confine([WORKER]) + assert.equal(confined.enforcement, 'partial') + const cache = confined.argv[confined.argv.indexOf('--temp') + 1] + const sid = confined.argv[confined.argv.indexOf('--temp-write-sid') + 1] + assert.match(sid, /^S-1-4-\d+-\d+-1$/) + const execute = (argv, value) => { + const result = spawnSync(argv[0], argv.slice(1), { input: JSON.stringify(value), encoding: 'utf8', timeout: 30_000 }) + assert.equal(result.status, 0, result.stderr); return JSON.parse(result.stdout) + } + const value = input(root, cache, { action: 'put', items: [{ private: true }] }, { writeRoots: [root, cache] }) + value.storage.windowsWriteSid = sid + const stored = execute(confined.argv, value); assert.equal(stored.status, 'completed', JSON.stringify(stored)) + value.request.args = { action: 'page', id: stored.id } + assert.deepEqual(execute(confined.argv, value).items, [{ private: true }]) + // Trusted fixed-file execution can use the same privately granted storage without changing its executor. + assert.deepEqual(execute([WORKER], value).items, [{ private: true }]) + const readonly = await confine([WORKER], { mode: 'read-only' }); value.request.read_only = true + assert.deepEqual(execute(readonly.argv, value).items, [{ private: true }]) + const filename = blob(cache, stored); const original = fs.readFileSync(filename) + const attempt = [process.execPath, '-e', 'try { require("node:fs").appendFileSync(process.argv[1],"bad"); process.exit(42) } catch(e) { console.log(e.code) }', filename] + for (const changes of [{ mode: 'read-only' }, { sessionId: 'ref-capability-foreign' }]) { + const denied = await confine(attempt, changes) + const result = spawnSync(denied.argv[0], denied.argv.slice(1), { encoding: 'utf8', timeout: 30_000 }) + assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /EACCES|EPERM/) + assert.deepEqual(fs.readFileSync(filename), original) + } + // Even a deliberately false worker read-only flag cannot bypass the OS token's denial. + value.request.read_only = false; value.request.args = { action: 'put', items: [{ forbidden: true }] } + assert.equal(execute(readonly.argv, value).error.code, 'ACCESS_DENIED') + value.storage.windowsWriteSid = 'S-1-4-1-2-1' + assert.ok(execute(confined.argv, value).error, 'a valid-shaped but ungranted SID cannot authorize cache creation') + } finally { await shutdown(ctx) } + }) +}) +report('result-references') From 1a9b2c664735467361c85738e1496e13fa6de476 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:27:19 +0800 Subject: [PATCH 016/118] feat(kernel): bind result lifetime and bound shared worker calls --- docs/CHANGELOG.md | 7 + docs/result-budgets.md | 29 +++++ ...23\346\236\234\351\242\204\347\256\227.md" | 21 +++ src/file/runtime.js | 13 +- src/file/worker.js | 24 +++- src/index.js | 2 + src/kernel/call-budget.ts | 62 +++++++++ src/kernel/errors.ts | 2 + src/kernel/result-lifecycle.ts | 87 +++++++++++++ src/kernel/result-storage.ts | 122 ++++++++++++++++++ src/ptc/index.js | 2 + src/ptc/worker.js | 34 ++++- ...{worker-protocol.js => worker-protocol.ts} | 12 +- test/call-budget.test.mjs | 74 +++++++++++ test/kernel-errors.test.mjs | 4 +- test/result-lifecycle.test.mjs | 63 +++++++++ test/result-storage-host.test.mjs | 81 ++++++++++++ test/worker-protocol.test.mjs | 6 +- 18 files changed, 624 insertions(+), 21 deletions(-) create mode 100644 docs/result-budgets.md create mode 100644 "docs/\347\273\223\346\236\234\351\242\204\347\256\227.md" create mode 100644 src/kernel/call-budget.ts create mode 100644 src/kernel/result-lifecycle.ts create mode 100644 src/kernel/result-storage.ts rename src/{worker-protocol.js => worker-protocol.ts} (53%) create mode 100644 test/call-budget.test.mjs create mode 100644 test/result-lifecycle.test.mjs create mode 100644 test/result-storage-host.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 987706b6..2e70dff2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,13 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Bound shared calls and expire session references (#261) + +- Share two active/eight waiting worker slots across Native/PTC, recheck authority after admission, + and cap worker execution at 30 seconds. Bind private result storage to the live host session and + current permissions; cancel and settle calls before authority-respecting cache cleanup. +- Native/PTC 共用有界调用队列与 30 秒上限;引用绑定活动会话与当前权限,会话结束后按权限清理缓存并保留审计数据。 + ### Read multiple file ranges and apply atomic multi-edits (#261) - Add read_many with 1-32 independently reported UTF-8 ranges in one worker call, current-session diff --git a/docs/result-budgets.md b/docs/result-budgets.md new file mode 100644 index 00000000..ded0988d --- /dev/null +++ b/docs/result-budgets.md @@ -0,0 +1,29 @@ +# Result budgets and session lifetime + +OPS worker calls have a 30-second execution ceiling. Native and PTC share a FIFO admission lane +per host session: two active calls and eight waiting calls. Overflow returns CALL_LIMIT with no +effects. A cancelled queued call never starts. Authority is resolved again after admission. +Native model text remains capped at 64 KiB after escaping; worker transport has a separate +128 KiB collection limit. Canonical result storage has its own 10 MiB/result and 100 MiB/session +limits. Transport, model text and stored data are separate budgets. + +Reference storage uses a private temporary cache selected by trusted host metadata. Windows uses +the actual sandbox provider's session temp directory and private-temp write capability. PTC still +runs through host confinement; Native fixed file operations keep their policy-governed executor. +Full-access cache preparation voluntarily uses a narrower workspace-write policy. If a cache +cannot be prepared, file operations retain their bounded, explicit incomplete result; reference +creation reports unavailable storage. There is no unconfined PTC fallback. + +Each reference binds the live session, current authority, credential fence, operation root and +producer tool. Only currently published/executable producers and the admitted tool are allowed. +Changing the permission mode invalidates the previous authority binding, including narrowing to +read-only. Read-only never creates a cache or updates LRU state. Existing direct legacy audit +references retain their separate contract. + +Session disposal immediately expires references, cancels queued/running work, awaits settlement, +then purges validated snapshot/staging blobs through the current host authority. The stable lock +and unknown names remain; audit data is never purged. Read-only mode or unavailable host services +can defer physical cleanup, with a diagnostic. A fresh in-memory session nonce prevents reopening +disk leftovers as valid references after a restart. Component owners share this lifetime; unloading +the last Native/PTC owner expires its remaining sessions. Cleanup is an internal operation and +cannot be requested through model tool arguments. diff --git "a/docs/\347\273\223\346\236\234\351\242\204\347\256\227.md" "b/docs/\347\273\223\346\236\234\351\242\204\347\256\227.md" new file mode 100644 index 00000000..3947f4a0 --- /dev/null +++ "b/docs/\347\273\223\346\236\234\351\242\204\347\256\227.md" @@ -0,0 +1,21 @@ +# 结果预算与会话生命周期 + +OPS 工作进程单次执行上限为 30 秒。同一宿主会话的 Native 与 PTC 共用先进先出的调用队列: +最多同时执行两次、等待八次。队列溢出返回 CALL_LIMIT,且无副作用。取消的等待调用不会启动; +获得执行资格后重新检查权限。Native 模型文本按转义后的字节计算,上限为 64 KiB;工作进程 +传输收集上限另设为 128 KiB。规范数据缓存限额为每结果 10 MiB、每会话 100 MiB。三者分别计量。 + +引用缓存位置来自可信宿主元数据。Windows 使用真实沙箱提供者的会话临时目录及其专用写入能力。 +PTC 仍通过宿主沙箱执行,Native 固定文件操作保持原有权限执行路径。完全访问模式准备缓存时 +主动收窄为工作区写入权限。无法准备缓存时,文件操作继续返回有界且明确标注不完整的结果; +创建引用则报告缓存不可用。PTC 不会回退为无沙箱执行。 + +引用绑定当前活动会话、权限、凭据保护规则、操作根目录和生产工具。只接受当前可见且可执行的 +生产工具及已获准执行的工具。权限模式变化会使原有绑定失效,包括收窄为只读。只读调用不会 +创建缓存或更新 LRU。已有旧式审计引用保持独立的约定。 + +会话销毁立即使引用过期,取消等待及执行中的调用,等待执行结束,再按当前宿主权限清理经过 +验证的快照和暂存文件。稳定锁及未知文件保留,审计数据不清理。只读模式或宿主服务不可用时, +物理清理可能延期并记录诊断;内存中的新会话随机标识保证重启后不能重新使用磁盘残留引用。 +各组件共享生命周期;最后一个 Native/PTC 所有者卸载时清理剩余会话。清理为内部操作,模型 +不能通过工具参数发起。 diff --git a/src/file/runtime.js b/src/file/runtime.js index f8964c2b..0bbbb952 100644 --- a/src/file/runtime.js +++ b/src/file/runtime.js @@ -10,7 +10,7 @@ export class NativeFileTools { this.config = config this.report = report ?? (() => {}) this.file = undefined - this.active = new Set() + this.active = new Map() this.disposers = [] this.stopped = false } @@ -33,10 +33,11 @@ export class NativeFileTools { const abort = () => controller.abort(exec?.signal?.reason) if (exec?.signal?.aborted) abort() else exec?.signal?.addEventListener?.('abort', abort, { once: true }) - const timer = setTimeout(() => controller.abort(new Error('Native file call timed out')), this.config.toolCallTimeoutMs ?? WORKER_TIMEOUT_MS) + const timer = setTimeout(() => controller.abort(new Error('Native file call timed out')), Math.min(this.config.toolCallTimeoutMs ?? WORKER_TIMEOUT_MS, WORKER_TIMEOUT_MS)) timer.unref?.() - this.active.add(controller) - try { return await invoke(this.ctx, this.file, tool, args, exec, controller.signal, this.config) } + const work = invoke(this.ctx, this.file, tool, args, exec, controller.signal, this.config) + this.active.set(controller, work) + try { return await work } finally { clearTimeout(timer) this.active.delete(controller) @@ -46,8 +47,8 @@ export class NativeFileTools { async stop() { this.stopped = true - for (const controller of this.active) controller.abort(new Error('Native file component unloaded')) - await Promise.allSettled([...this.active]) + for (const controller of this.active.keys()) controller.abort(new Error('Native file component unloaded')) + await Promise.allSettled(this.active.values()) // Isolate each teardown: one throwing worker registration must not strand the rest. safeDisposeAll(this.disposers.splice(0), (error, where) => { this.report(`dsh-ops: ${where} teardown failed: ${error instanceof Error ? error.message : String(error)}`, 'warn') diff --git a/src/file/worker.js b/src/file/worker.js index 700f563d..24754674 100644 --- a/src/file/worker.js +++ b/src/file/worker.js @@ -9,6 +9,9 @@ import { resolveWorker } from '../ptc/runtime.js' import { FILE_MUTATIONS } from '../policy.js' import { normalizeArguments } from './contract.js' import { resolveReadMany } from './read-many.js' +import { withCallBudget, CallBudgetError } from '../kernel/call-budget.js' +import { withResultSession } from '../kernel/result-lifecycle.js' +import { resultStorage } from '../kernel/result-storage.js' export { resolveWorker } @@ -62,8 +65,21 @@ async function observeReplaced(ctx, fsService, value, cwd, exec, signal) { } export async function invoke(ctx, file, tool, args, exec, signal, config) { - try { return await invokeTrusted(ctx, file, tool, args, exec, signal, config) } - catch { return failure('EXECUTOR_UNAVAILABLE', 'DSH file authority or preparation is unavailable; no unrestricted fallback was attempted.') } + try { + const session = exec?.agent?.session + if (!session?.id) return failure('EXECUTOR_UNAVAILABLE', 'An authoritative session identity is required.') + return await withResultSession(ctx, session, signal, currentSignal => + withCallBudget(ctx.root ?? ctx, String(session.id), currentSignal, + () => invokeTrusted(ctx, file, tool, args, exec, currentSignal, config))) + } + catch (error) { + if (error instanceof CallBudgetError) { + const value = failure(error.code, error.message) + value.error.nextAction = error.message + return value + } + return failure('EXECUTOR_UNAVAILABLE', 'DSH file authority or preparation is unavailable; no unrestricted fallback was attempted.') + } } async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { const session = exec?.agent?.session @@ -139,7 +155,9 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { // Traversals skip credential directories by identity, so a glob or grep cannot even name a private // key that sits below one. const denyRoots = (config?.denyCredentialReads ?? true) !== false ? credentialRoots() : [] - const envelope = workerEnvelope('file', { root: trustedPath(cwd), scope: String(session.id), tool, args: workerArgs, read_only: policy.mode === 'read-only', deny_roots: denyRoots, deny_credential_names: (config?.denyCredentialReads ?? true) !== false, ...(policy.mode === 'workspace-write' ? { write_roots: writeRoots } : {}) }, config?.toolCallTimeoutMs) + const storage = await resultStorage(ctx, session, exec.agent, file, tool, policy, denyRoots, + (config?.denyCredentialReads ?? true) !== false, signal).catch(() => undefined) + const envelope = workerEnvelope('file', { root: trustedPath(cwd), scope: String(session.id), tool, args: workerArgs, read_only: policy.mode === 'read-only', deny_roots: denyRoots, deny_credential_names: (config?.denyCredentialReads ?? true) !== false, ...(policy.mode === 'workspace-write' ? { write_roots: writeRoots } : {}) }, config?.toolCallTimeoutMs, storage) const input = JSON.stringify(envelope) if (Buffer.byteLength(input) > 2 * 1024 * 1024) return failure('OUTPUT_LIMIT', 'Request exceeds the 2 MiB byte budget.') let spawned = false diff --git a/src/index.js b/src/index.js index 7350661f..c7e4f0d5 100644 --- a/src/index.js +++ b/src/index.js @@ -10,6 +10,7 @@ import { NativeFileTools } from './file/runtime.js' import { resolveWorker } from './ptc/runtime.js' import { mountWorkspacePreparation } from './workspace-preparation.js' import { safeDispose, safeDisposeAll } from './teardown.js' +import { mountResultLifecycle } from './kernel/result-lifecycle.js' export const name = 'dsh-ops' function report(ctx, message, level = 'error') { @@ -63,6 +64,7 @@ function installHostShellDenial(ctx, config) { } async function mountNativeFile(ctx, config, file) { + mountResultLifecycle(ctx) const surface = new NativeFileTools({ ctx, config: { ...config, workerPath: file }, report: (message, level) => report(ctx, message, level) }) // `safeDispose` isolates synchronous teardown callbacks; a *rejecting promise* needs its own diff --git a/src/kernel/call-budget.ts b/src/kernel/call-budget.ts new file mode 100644 index 00000000..8c8817b1 --- /dev/null +++ b/src/kernel/call-budget.ts @@ -0,0 +1,62 @@ +/** Bound this plugin's workers without changing host admission or tool registries. */ +export const MAX_ACTIVE_CALLS = 2 +export const MAX_WAITING_CALLS = 8 + +export class CallBudgetError extends Error { + constructor(readonly code: 'CALL_LIMIT' | 'CANCELLED', message: string) { super(message) } +} +type Ticket = { resolve: (release: () => void) => void, reject: (reason: unknown) => void, + signal?: AbortSignal, abort: () => void } +type Lane = { active: number, queue: Ticket[] } +const owners = new WeakMap>() + +function cancelled(): CallBudgetError { return new CallBudgetError('CANCELLED', 'OPS call cancelled before worker admission; no effects.') } + +/** FIFO permits are scoped to one host and session, shared by Native and PTC bridges. */ +async function acquire(owner: object, sessionId: string, signal?: AbortSignal): Promise<() => void> { + if (signal?.aborted) throw cancelled() + let sessions = owners.get(owner) + if (!sessions) { sessions = new Map(); owners.set(owner, sessions) } + let lane = sessions.get(sessionId) + if (!lane) { lane = { active: 0, queue: [] }; sessions.set(sessionId, lane) } + const current = lane + const cleanup = () => { if (current.active === 0 && current.queue.length === 0) sessions.delete(sessionId) } + const releaseOnce = (): (() => void) => { + let released = false + return () => { + if (released) return + released = true + current.active-- + const next = current.queue.shift() + if (next) { + next.signal?.removeEventListener('abort', next.abort) + current.active++ + next.resolve(releaseOnce()) + } + cleanup() + } + } + if (current.active < MAX_ACTIVE_CALLS) { current.active++; return releaseOnce() } + if (current.queue.length >= MAX_WAITING_CALLS) { + throw new CallBudgetError('CALL_LIMIT', 'OPS call queue is full. Wait for current calls to finish before submitting more work; no effects.') + } + return new Promise((resolve, reject) => { + const ticket: Ticket = { resolve, reject, ...(signal ? { signal } : {}), abort: () => { + const index = current.queue.indexOf(ticket) + if (index < 0) return + current.queue.splice(index, 1) + reject(cancelled()); cleanup() + } } + current.queue.push(ticket) + signal?.addEventListener('abort', ticket.abort, { once: true }) + if (signal?.aborted) ticket.abort() + }) +} + +export async function withCallBudget(owner: object, sessionId: string, signal: AbortSignal | undefined, run: () => Promise): Promise { + const release = await acquire(owner, sessionId, signal) + try { + if (signal?.aborted) throw cancelled() + return await run() + } finally { release() } +} diff --git a/src/kernel/errors.ts b/src/kernel/errors.ts index f3499210..aa9ea6ba 100644 --- a/src/kernel/errors.ts +++ b/src/kernel/errors.ts @@ -27,6 +27,8 @@ export const ERROR_POLICIES: Readonly> = Object.free ACCESS_DENIED: changed, EXECUTOR_UNAVAILABLE: changed, JOB_BUSY: policy('error', 'bounded-transient'), + CALL_LIMIT: policy('error', 'after-state-change'), + CANCELLED: settled, METADATA_UNSUPPORTED: settled, IO_ERROR: settled, SYNC_FAILED: settled, diff --git a/src/kernel/result-lifecycle.ts b/src/kernel/result-lifecycle.ts new file mode 100644 index 00000000..1646f70f --- /dev/null +++ b/src/kernel/result-lifecycle.ts @@ -0,0 +1,87 @@ +import { randomUUID } from 'node:crypto' + +export interface ResultSession { id: string; header?: { cwd?: string } } +export interface LifecycleHost { + root?: object + on(name: 'session/disposed', callback: (session: ResultSession) => void): unknown + effect(callback: () => (() => Promise), label: string): unknown + logger?: { warn?: (message: string) => void } +} +interface LiveSession { + nonce: string + stopped: boolean + work: Map> + cleanup: Map Promise> + closing?: Promise +} +interface Owner { mounts: number; sessions: Map; expired: WeakSet } +const owners = new WeakMap() +function owner(ctx: Pick): Owner { + const key = ctx.root ?? ctx + let state = owners.get(key) + if (!state) { state = { mounts: 0, sessions: new Map(), expired: new WeakSet() }; owners.set(key, state) } + return state +} +function live(ctx: Pick, session: ResultSession): LiveSession { + const state = owner(ctx) + if (state.expired.has(session)) throw new Error('Result session has expired') + if ([...state.sessions].some(([other, entry]) => other !== session && other.id === session.id && !entry.stopped)) { + throw new Error('Another live session owns this identity') + } + let entry = state.sessions.get(session) + if (!entry) { + entry = { nonce: randomUUID(), stopped: false, work: new Map(), cleanup: new Map() } + state.sessions.set(session, entry) + } + if (entry.stopped) throw new Error('Result session is closing') + return entry +} +/** A reopened persisted session receives a fresh authority binding, even with the same id. */ +export function resultSessionNonce(ctx: Pick, session: ResultSession): string { + return live(ctx, session).nonce +} +export function registerResultCleanup(ctx: Pick, session: ResultSession, key: string, cleanup: () => Promise): void { + live(ctx, session).cleanup.set(key, cleanup) +} +export async function withResultSession(ctx: Pick, session: ResultSession, + signal: AbortSignal | undefined, run: (signal: AbortSignal) => Promise): Promise { + const entry = live(ctx, session), controller = new AbortController() + const abort = () => controller.abort(signal?.reason) + if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true }) + // Defer invocation so teardown always sees the promise before any worker can start. + const preceding = [...owner(ctx).sessions].filter(([other]) => other !== session && other.id === session.id) + .map(([, entry]) => entry.closing) + const work = Promise.resolve().then(async () => { + await Promise.allSettled(preceding) + controller.signal.throwIfAborted(); return run(controller.signal) + }) + entry.work.set(controller, work) + try { return await work } + finally { entry.work.delete(controller); signal?.removeEventListener('abort', abort) } +} +export function expireResultSession(ctx: Pick, session: ResultSession): Promise { + const state = owner(ctx), entry = state.sessions.get(session) + state.expired.add(session) + if (!entry) return Promise.resolve() + if (entry.closing) return entry.closing + entry.stopped = true + for (const controller of entry.work.keys()) controller.abort(new Error('Result session disposed')) + entry.closing = (async () => { + await Promise.allSettled(entry.work.values()) + for (const cleanup of entry.cleanup.values()) { + try { await cleanup() } + catch { ctx.logger?.warn?.('dsh-ops: session result references expired; physical cache cleanup deferred under current host authority.') } + } + entry.cleanup.clear(); state.sessions.delete(session) + })() + return entry.closing +} +/** Each component owns its listeners. Last-owner teardown expires remaining sessions. */ +export function mountResultLifecycle(ctx: LifecycleHost): void { + const state = owner(ctx); state.mounts++ + ctx.on('session/disposed', session => { void expireResultSession(ctx, session) }) + ctx.effect(() => async () => { + if (--state.mounts !== 0) return + await Promise.allSettled([...state.sessions.keys()].map(session => expireResultSession(ctx, session))) + }, 'dsh-ops: session result lifecycle') +} diff --git a/src/kernel/result-storage.ts b/src/kernel/result-storage.ts new file mode 100644 index 00000000..99f0acc9 --- /dev/null +++ b/src/kernel/result-storage.ts @@ -0,0 +1,122 @@ +/** Trusted cache metadata comes from the host, never tool arguments or selected operation roots. */ +import { createHash } from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { tmpdir } from 'node:os' +import type { SandboxExecutionPolicy, ConfinedArgv } from '@deepseek-ai/dsh-sandbox' +import type { WorkerStorage } from '../worker-protocol.js' +import { workerEnvelope } from '../worker-protocol.js' +import { CAPABILITIES } from '../capabilities/registry.js' +import { publicToolName } from '../policy.js' +import { prepareWorkspace } from '../workspace-preparation.js' +import { resultSessionNonce, registerResultCleanup } from './result-lifecycle.js' +import type { ResultSession } from './result-lifecycle.js' + +interface Subprocess { + spawn(spec: { argv: string[]; cwd: string; stdio: { stdin: { data: string }; stdout: { maxBytes: number }; stderr: { maxBytes: number } }; graceMs: number; signal: AbortSignal }): { + done: Promise<{ exitCode: number | null; signal?: unknown }> + collected: { stdout: { readFrom(offset: number): { text: string; lossy?: boolean } } } + } +} +export interface StorageHost { + root?: object + get(name: 'sandbox'): { confine(argv: string[], policy: SandboxExecutionPolicy & { mode: 'read-only' | 'workspace-write' }, signal?: AbortSignal): Promise } | undefined + get(name: 'sandboxPolicy'): { resolve(input: { session: ResultSession }): SandboxExecutionPolicy } | undefined + get(name: 'tools'): { schemas(scope: unknown): { name: string }[]; get(name: string, scope: unknown): unknown } | undefined + get(name: 'subprocess'): Subprocess | undefined +} +type Location = { root: string; windowsWriteSid?: string } +const locations = new WeakMap>() +const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex') + +/** Parse only the runner's pre-separator contract, not the wrapped program's arguments. */ +export function windowsCacheLocation(argv: readonly string[]): Location | undefined { + const separator = argv.indexOf('--') + if (separator < 1) return undefined + const flags = argv.slice(1, separator) + const one = (flag: string): string | undefined => { + const index = flags.indexOf(flag) + return index >= 0 && flags.lastIndexOf(flag) === index ? flags[index + 1] : undefined + } + const root = one('--temp'), sid = one('--temp-write-sid') + const match = /^S-1-4-([1-9][0-9]*)-([1-9][0-9]*)-1$/.exec(sid ?? '') + if (!root || !path.win32.isAbsolute(root) || root.includes('\0') || !match + || match.slice(1).some(part => Number(part) > 1073741823)) return undefined + return { root, windowsWriteSid: sid } +} +function posixCacheLocation(key: string, create: boolean): Location | undefined { + const parent = fs.realpathSync(tmpdir()), root = path.join(parent, `dsh-ops-results-${key}`) + // Only one fresh leaf under the canonical temp root. No recursive mkdir through caller paths. + if (create) { + try { fs.mkdirSync(root, { mode: 0o700 }) } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error } + } + if (!fs.existsSync(root)) return undefined + const info = fs.lstatSync(root) + if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 + || (process.getuid && info.uid !== process.getuid())) throw new Error('Result cache parent must be private and owned') + return { root } +} +function currentTools(ctx: StorageHost, scope: unknown, tool: string): string[] { + const tools = ctx.get('tools'), visible = new Set(tools?.schemas(scope).map(item => item.name) ?? []) + return CAPABILITIES.filter(item => item.rawName === tool + || (visible.has(publicToolName(item.rawName)) && tools?.get(publicToolName(item.rawName), scope))) + .map(item => item.rawName) +} +async function purge(ctx: StorageHost, session: ResultSession, file: string, storage: WorkerStorage, + original: SandboxExecutionPolicy): Promise { + const policyService = ctx.get('sandboxPolicy'), subprocess = ctx.get('subprocess') + const policy = policyService?.resolve({ session }) + if (!policy || !subprocess || policy.mode === 'read-only' || policy.workspaceRoot !== original.workspaceRoot) throw new Error('Cleanup authority unavailable') + const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 5000) + timer.unref?.() + try { + let argv = [file] + if (policy.mode !== 'danger-full-access') { + const confined = await ctx.get('sandbox')?.confine(argv, { ...policy, mode: 'workspace-write' }, controller.signal) + if (!confined || !['full', 'partial'].includes(confined.enforcement) || !confined.argv.length) throw new Error('Cleanup confinement unavailable') + if (process.platform === 'win32' && windowsCacheLocation(confined.argv)?.root !== storage.root) throw new Error('Cleanup cache grant changed') + argv = confined.argv + } + const latest = policyService?.resolve({ session }) + if (latest?.mode !== policy.mode || latest?.workspaceRoot !== policy.workspaceRoot) throw new Error('Cleanup authority changed') + const envelope = workerEnvelope('ptc', { root: policy.workspaceRoot, scope: session.id, tool: 'result', args: { action: 'purge' }, read_only: false }, 5000, storage) + const handle = subprocess.spawn({ argv, cwd: policy.workspaceRoot, stdio: { stdin: { data: JSON.stringify(envelope) }, stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 } }, graceMs: 1000, signal: controller.signal }) + const outcome = await handle.done, output = handle.collected.stdout.readFrom(0) + const value: unknown = JSON.parse(output.text) + if (outcome.exitCode !== 0 || outcome.signal || output.lossy || !value || typeof value !== 'object' + || !('status' in value) || value.status !== 'completed') throw new Error('Cache purge did not complete') + } finally { clearTimeout(timer) } +} +export async function resultStorage(ctx: StorageHost, session: ResultSession, scope: unknown, file: string, + tool: string, policy: SandboxExecutionPolicy, denyRoots: string[], denyNames: boolean, + signal?: AbortSignal, confined?: ConfinedArgv): Promise { + const nonce = resultSessionNonce(ctx, session), key = digest([session.id, policy.workspaceRoot, nonce]) + const owner = ctx.root ?? ctx + let cache = locations.get(owner) + if (!cache) { cache = new Map(); locations.set(owner, cache) } + let location = cache.get(key) + if (process.platform === 'win32') { + if (!location && policy.mode !== 'read-only') { + if (policy.sessionId !== session.id) return undefined + // Full access is voluntarily narrowed for cache preparation; file execution keeps its own path. + const cachePolicy = { ...policy, mode: 'workspace-write' as const } + if (!confined) { + const sandbox = ctx.get('sandbox') + if (!sandbox) return undefined + await prepareWorkspace(cachePolicy) + confined = await sandbox.confine([file], cachePolicy, signal) + } + if (!['full', 'partial'].includes(confined.enforcement)) return undefined + location = windowsCacheLocation(confined.argv) + } + } else location ??= posixCacheLocation(key, policy.mode !== 'read-only') + if (!location) return undefined + cache.set(key, location) + const storage: WorkerStorage = { ...location, authorityId: digest([nonce, policy.mode, policy.workspaceRoot, denyRoots, denyNames]), allowedTools: currentTools(ctx, scope, tool) } + registerResultCleanup(ctx, session, key, async () => { + try { await purge(ctx, session, file, storage, policy) } + finally { cache.delete(key) } + }) + return storage +} diff --git a/src/ptc/index.js b/src/ptc/index.js index d9f2152d..7381cd17 100644 --- a/src/ptc/index.js +++ b/src/ptc/index.js @@ -6,6 +6,7 @@ import { PTC_TOOLS, PTC_SECTION, definition } from './contract.js' import { publicToolName } from '../policy.js' import { invoke, resolveWorker, failure } from './worker.js' import { mountWorkspacePreparation } from '../workspace-preparation.js' +import { mountResultLifecycle } from '../kernel/result-lifecycle.js' export const name = 'dsh-ops-ptc' export function resolvePtcConfig(raw = {}) { if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('PTC config must be an object') @@ -30,6 +31,7 @@ export function apply(root, rawConfig) { root.logger?.warn?.(`dsh-ops: ${e.message}`); return } root.inject(['tools', 'systemPrompt', 'sandboxPolicy', 'subprocess'], ctx => { + mountResultLifecycle(ctx) const entries = new Map(), active = new Map() ctx.effect(() => ctx.get('systemPrompt').section({ name: PTC_SECTION, order: 3252, interpolate: false, diff --git a/src/ptc/worker.js b/src/ptc/worker.js index c0dbaf79..14011f21 100644 --- a/src/ptc/worker.js +++ b/src/ptc/worker.js @@ -1,5 +1,8 @@ /** Native worker bridge. Every execution uses DSH policy, confinement and process governance. */ import { workerEnvelope } from '../worker-protocol.js' +import { withCallBudget, CallBudgetError } from '../kernel/call-budget.js' +import { withResultSession } from '../kernel/result-lifecycle.js' +import { resultStorage } from '../kernel/result-storage.js' import { randomUUID } from 'node:crypto' import path from 'node:path' import fs from 'node:fs' @@ -14,8 +17,21 @@ export function failure(code, message, effectState = 'none') { nextAction: effectState === 'none' ? 'Correct the request or restore host authority.' : 'Inspect the persistent job manifest; never blindly rerun an interrupted batch.', message } } } export async function invoke(ctx, file, tool, args, exec, signal, config) { - try { return await invokeTrusted(ctx, file, tool, args, exec, signal, config) } - catch { return failure('EXECUTOR_UNAVAILABLE', 'DSH authority or execution services are unavailable; no unrestricted fallback was attempted.') } + try { + const session = exec?.agent?.session + if (!session?.id) return failure('EXECUTOR_UNAVAILABLE', 'An authoritative session identity is required.') + return await withResultSession(ctx, session, signal, currentSignal => + withCallBudget(ctx.root ?? ctx, String(session.id), currentSignal, + () => invokeTrusted(ctx, file, tool, args, exec, currentSignal, config))) + } + catch (error) { + if (error instanceof CallBudgetError) { + const value = failure(error.code, error.message) + value.error.nextAction = error.message + return value + } + return failure('EXECUTOR_UNAVAILABLE', 'DSH authority or execution services are unavailable; no unrestricted fallback was attempted.') + } } async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { const session = exec?.agent?.session @@ -34,6 +50,7 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { if (args?.root !== undefined && (typeof args.root !== 'string' || !path.isAbsolute(args.root))) return failure('INVALID_ARGUMENT', 'root must be an absolute path.') const operationRoot = args?.root ?? policy.workspaceRoot const { root: _selectedRoot, ...workerArgs } = args ?? {} + if (tool === 'result' && args?.action === 'purge') return failure('INVALID_ARGUMENT', 'purge is reserved for trusted session cleanup.') const readOnly = policy.mode === 'read-only' if (readOnly && !(tool === 'result' && args?.action === 'page')) return denied(policy, `${tool} ${args?.action ?? ''}`, 'workspace-write', 'The user selected read-only. Only existing result pages are permitted; no audit or business data was written.') // A batch root can reach a credential file through a copy, a move or a result snapshot, so the @@ -48,9 +65,8 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { if (policy.mode === 'workspace-write' && requiredMode(policy) === 'danger-full-access') { return denied(policy, `${tool} outside the workspace and session temporary directory`, 'danger-full-access', 'The selected root is outside the user-authorized workspace.') } - const envelope = workerEnvelope('ptc', { root: operationRoot, scope: String(session.id), tool, args: workerArgs, read_only: readOnly, deny_roots: (config?.denyCredentialReads ?? true) !== false ? credentialRoots() : [], deny_credential_names: (config?.denyCredentialReads ?? true) !== false, ...(jobId ? { job_id: jobId } : {}) }) - const input = JSON.stringify(envelope) - if (Buffer.byteLength(input) > 2 * 1024 * 1024) return failure('OUTPUT_LIMIT', 'Request exceeds the 2 MiB byte budget.') + const denyRoots = (config?.denyCredentialReads ?? true) !== false ? credentialRoots() : [] + if (Buffer.byteLength(JSON.stringify(workerArgs)) > 2 * 1024 * 1024) return failure('OUTPUT_LIMIT', 'Request exceeds the 2 MiB byte budget.') let argv = [file] if (policy.mode !== 'danger-full-access') { const sandbox = ctx.get('sandbox') @@ -62,6 +78,14 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { } argv = confined.argv } + const storage = await resultStorage(ctx, session, exec.agent, file, tool, policy, denyRoots, + (config?.denyCredentialReads ?? true) !== false, signal, confined).catch(() => undefined) + if (tool === 'result' && (args?.action === 'put' || String(args?.id ?? '').startsWith('ref://')) && !storage) { + return failure('EXECUTOR_UNAVAILABLE', 'Session result storage is unavailable under current host authority.') + } + const envelope = workerEnvelope('ptc', { root: operationRoot, scope: String(session.id), tool, args: workerArgs, read_only: readOnly, deny_roots: denyRoots, deny_credential_names: (config?.denyCredentialReads ?? true) !== false, ...(jobId ? { job_id: jobId } : {}) }, undefined, storage) + const input = JSON.stringify(envelope) + if (Buffer.byteLength(input) > 2 * 1024 * 1024) return failure('OUTPUT_LIMIT', 'Request exceeds the 2 MiB byte budget.') signal?.throwIfAborted?.() // Authority may change during asynchronous sandbox preparation. const current = policyService.resolve({ session }) diff --git a/src/worker-protocol.js b/src/worker-protocol.ts similarity index 53% rename from src/worker-protocol.js rename to src/worker-protocol.ts index be9fa3fa..ddc59d63 100644 --- a/src/worker-protocol.js +++ b/src/worker-protocol.ts @@ -1,15 +1,21 @@ /** One-shot worker transport. Only trusted bridge code supplies envelope metadata and budgets. */ import { randomUUID } from 'node:crypto' -export const WORKER_TIMEOUT_MS = 300000 +export const WORKER_TIMEOUT_MS = 30000 export const WORKER_OUTPUT_BYTES = 128 * 1024 -export function workerEnvelope(component, request, timeoutMs = WORKER_TIMEOUT_MS) { +export interface WorkerStorage { + root: string + authorityId: string + allowedTools: string[] + windowsWriteSid?: string +} +export function workerEnvelope(component: 'file' | 'ptc', request: Record, timeoutMs: number | null = WORKER_TIMEOUT_MS, storage?: WorkerStorage) { timeoutMs ??= WORKER_TIMEOUT_MS const timeout = Math.max(1, Math.trunc(timeoutMs)) if (!['file', 'ptc'].includes(component) || !Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeout > 2147483647) { throw new TypeError('Invalid internal worker component or timeout') } return { version: 1, requestId: randomUUID(), component, - budget: { timeoutMs: timeout, maxBytes: WORKER_OUTPUT_BYTES }, request } + budget: { timeoutMs: Math.min(timeout, WORKER_TIMEOUT_MS), maxBytes: WORKER_OUTPUT_BYTES }, request, ...(storage ? { storage } : {}) } } diff --git a/test/call-budget.test.mjs b/test/call-budget.test.mjs new file mode 100644 index 00000000..c2aad22b --- /dev/null +++ b/test/call-budget.test.mjs @@ -0,0 +1,74 @@ +import { setImmediate } from 'node:timers/promises' +import { withCallBudget, MAX_ACTIVE_CALLS, MAX_WAITING_CALLS } from '../lib/kernel/call-budget.js' +import { WORKER_TIMEOUT_MS } from '../lib/worker-protocol.js' +import { invoke as fileCall } from '../lib/file/worker.js' +import { invoke as ptcCall } from '../lib/ptc/worker.js' +import { bootHost, shutdown } from './lib/host.mjs' +import { assert, test, report, withTempDir } from './lib/harness.mjs' + +const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done }); return { promise, resolve } } + +await test('one host/session has bounded FIFO admission and an explicit overflow result', async () => { + const owner = {}, gate = deferred(), entered = [] + const jobs = Array.from({ length: MAX_ACTIVE_CALLS + MAX_WAITING_CALLS }, (_, index) => + withCallBudget(owner, 'same', undefined, async () => { entered.push(index); await gate.promise; return index })) + await setImmediate() + assert.deepEqual(entered, [0, 1]) + await assert.rejects(withCallBudget(owner, 'same', undefined, async () => assert.fail('overflow must not run')), + error => error.code === 'CALL_LIMIT' && /no effects/.test(error.message)) + gate.resolve() + assert.deepEqual(await Promise.all(jobs), Array.from({ length: 10 }, (_, index) => index)) + assert.deepEqual(entered, Array.from({ length: 10 }, (_, index) => index)) + assert.equal(await withCallBudget(owner, 'same', undefined, async () => 'released'), 'released') +}) + +await test('queued cancellation never runs; thrown work releases its permit; sessions and hosts stay independent', async () => { + const owner = {}, gate = deferred() + const running = Array.from({ length: MAX_ACTIVE_CALLS }, () => withCallBudget(owner, 'one', undefined, () => gate.promise)) + await setImmediate() + const controller = new AbortController() + const waiting = withCallBudget(owner, 'one', controller.signal, async () => assert.fail('cancelled ticket ran')) + controller.abort() + await assert.rejects(waiting, error => error.code === 'CANCELLED') + assert.equal(await withCallBudget(owner, 'two', undefined, async () => 'other session'), 'other session') + assert.equal(await withCallBudget({}, 'one', undefined, async () => 'other host'), 'other host') + await assert.rejects(withCallBudget(owner, 'two', undefined, async () => { throw new Error('work failed') }), /work failed/) + assert.equal(await withCallBudget(owner, 'two', undefined, async () => 'after error'), 'after error') + gate.resolve(); await Promise.all(running) +}) + +await test('Native and PTC bridges share admission and recheck authority after waiting', async () => { + await withTempDir('call-budget', async root => { + const { ctx } = await bootHost() + const gate = deferred(); let active = 0, peak = 0, mode = 'danger-full-access' + const policies = [] + const exec = { agent: { session: { id: 'bounded', header: { cwd: root } } } } + try { + await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve() { policies.push(mode); return { mode, workspaceRoot: root } } })) + await ctx.plugin(inner => inner.provide('subprocess', { spawn() { + active++; peak = Math.max(peak, active) + return { done: gate.promise.then(() => { active--; return { exitCode: 0 } }), collected: { + stdout: { readFrom: () => ({ text: '{"status":"completed","items":[]}', lossy: false }) }, + stderr: { readFrom: () => ({ text: '', lossy: false }) }, + } } + } })) + const jobs = [fileCall(ctx, 'unused', 'stat', { path: 'a' }, exec), ptcCall(ctx, 'unused', 'batch', { action: 'plan', operations: [] }, exec)] + await setImmediate() + assert.equal(active, 2) + const queuedWrite = fileCall(ctx, 'unused', 'edit_many', { path: 'a' }, exec) + const queuedRead = fileCall(ctx, 'unused', 'stat', { path: 'b' }, exec) + await setImmediate(); assert.equal(active, 2) + mode = 'read-only'; gate.resolve() + const [write, read] = await Promise.all([queuedWrite, queuedRead]) + await Promise.all(jobs) + assert.equal(peak, 2) + assert.equal(write.error.code, 'POLICY_DENIED') + assert.equal(write.error.effectState, 'none') + assert.equal(read.status, 'completed') + assert.equal(read.sandbox.mode, 'read-only') + assert.ok(policies.includes('read-only')) + } finally { gate.resolve(); await shutdown(ctx) } + }) + assert.equal(WORKER_TIMEOUT_MS, 30000) +}) +report('call-budget') diff --git a/test/kernel-errors.test.mjs b/test/kernel-errors.test.mjs index d55b3212..eafe7033 100644 --- a/test/kernel-errors.test.mjs +++ b/test/kernel-errors.test.mjs @@ -7,8 +7,8 @@ await test('permission, input, revision and transient failures have distinct adv assert.deepEqual(errorPolicy('REVISION_CONFLICT'), { status: 'conflict', retry: 'after-state-change' }) for (const code of ['INVALID_ARGUMENT', 'EDIT_CONFLICT', 'OUTPUT_LIMIT', 'ERR_INVALID_ENCODING', 'SEARCH_STALE']) assert.deepEqual(errorPolicy(code), { status: 'error', retry: 'after-input-change' }) assert.deepEqual(errorPolicy('JOB_BUSY'), { status: 'error', retry: 'bounded-transient' }) - for (const code of ['ACCESS_DENIED', 'AUTHORITY_CHANGED', 'EXECUTOR_UNAVAILABLE']) assert.deepEqual(errorPolicy(code), { status: 'error', retry: 'after-state-change' }) - for (const code of ['METADATA_UNSUPPORTED', 'IO_ERROR', 'NEW_CODE', 'toString', '__proto__']) assert.deepEqual(errorPolicy(code), { status: 'error', retry: 'never' }) + for (const code of ['ACCESS_DENIED', 'AUTHORITY_CHANGED', 'EXECUTOR_UNAVAILABLE', 'CALL_LIMIT']) assert.deepEqual(errorPolicy(code), { status: 'error', retry: 'after-state-change' }) + for (const code of ['METADATA_UNSUPPORTED', 'IO_ERROR', 'NEW_CODE', 'toString', '__proto__', 'CANCELLED']) assert.deepEqual(errorPolicy(code), { status: 'error', retry: 'never' }) }) await test('unknown or already-applied effects override every ordinary retry policy', () => { diff --git a/test/result-lifecycle.test.mjs b/test/result-lifecycle.test.mjs new file mode 100644 index 00000000..2c2d820d --- /dev/null +++ b/test/result-lifecycle.test.mjs @@ -0,0 +1,63 @@ +import { setImmediate } from 'node:timers/promises' +import { assert, test, report } from './lib/harness.mjs' +import { bootHost, shutdown } from './lib/host.mjs' +import { resultSessionNonce, registerResultCleanup, withResultSession, expireResultSession, mountResultLifecycle } from '../lib/kernel/result-lifecycle.js' + +const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done }); return { promise, resolve } } + +await test('disposal cancels owned work, waits for settlement, then purges exactly once', async () => { + const ctx = {}, session = { id: 'one' }, settled = deferred(), events = [] + const nonce = resultSessionNonce(ctx, session) + assert.equal(resultSessionNonce(ctx, session), nonce) + assert.notEqual(resultSessionNonce({}, session), nonce) + registerResultCleanup(ctx, session, 'cache', async () => { events.push('purged') }) + const work = withResultSession(ctx, session, undefined, async signal => { + signal.addEventListener('abort', () => events.push('aborted'), { once: true }) + await settled.promise; events.push('settled') + }) + await setImmediate() + const closing = expireResultSession(ctx, session) + assert.equal(expireResultSession(ctx, session), closing) + assert.deepEqual(events, ['aborted']) + await assert.rejects(withResultSession(ctx, session, undefined, async () => assert.fail('disposed work ran')), /expired/) + const reopened = { id: 'one' } + const next = withResultSession(ctx, reopened, undefined, async () => events.push('reopened')) + await setImmediate(); assert.deepEqual(events, ['aborted']) + settled.resolve(); await Promise.all([work, closing, next]) + assert.deepEqual(events, ['aborted', 'settled', 'purged', 'reopened']) + assert.notEqual(resultSessionNonce(ctx, reopened), nonce) + await expireResultSession(ctx, reopened) +}) + +await test('two component owners share the live session; last unload expires it', async () => { + const { ctx } = await bootHost(), session = { id: 'components' } + let purged = 0 + try { + const first = await ctx.plugin(inner => mountResultLifecycle(inner)) + const second = await ctx.plugin(inner => mountResultLifecycle(inner)) + registerResultCleanup(ctx, session, 'cache', async () => { purged++ }) + const nonce = resultSessionNonce(ctx, session) + await first.dispose() + assert.equal(purged, 0); assert.equal(resultSessionNonce(ctx, session), nonce) + await second.dispose() + assert.equal(purged, 1) + assert.throws(() => resultSessionNonce(ctx, session), /expired/) + } finally { await shutdown(ctx) } +}) + +await test('real host session-disposed event expires references and contains cleanup failure', async () => { + const { ctx } = await bootHost(), session = { id: 'event' }, done = deferred() + let warnings = 0 + try { + const fiber = await ctx.plugin(inner => mountResultLifecycle(inner)) + registerResultCleanup(ctx, session, 'cache', async () => { done.resolve(); throw new Error('read-only') }) + ctx.emit('session/disposed', session) + await done.promise; await setImmediate() + assert.throws(() => resultSessionNonce(ctx, session), /expired/) + const direct = { logger: { warn() { warnings++ } } }, other = { id: 'deferred' } + registerResultCleanup(direct, other, 'cache', async () => { throw new Error('read-only') }) + await expireResultSession(direct, other); assert.equal(warnings, 1) + await fiber.dispose() + } finally { await shutdown(ctx) } +}) +report('result-lifecycle') diff --git a/test/result-storage-host.test.mjs b/test/result-storage-host.test.mjs new file mode 100644 index 00000000..904c8392 --- /dev/null +++ b/test/result-storage-host.test.mjs @@ -0,0 +1,81 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import Sandbox from '@deepseek-ai/dsh-sandbox-local' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { bootHost, shutdown } from './lib/host.mjs' +import { WORKER, requireWorker } from './lib/boundary.mjs' +import { publicToolName } from '../lib/policy.js' +import { windowsCacheLocation, resultStorage } from '../lib/kernel/result-storage.js' +import { mountResultLifecycle, expireResultSession } from '../lib/kernel/result-lifecycle.js' +import { invoke } from '../lib/ptc/worker.js' + +requireWorker('result-storage-host') +await test('cache flags accept only the trusted runner section and private-temp capability family', () => { + const root = 'C:\\private\\cache', sid = 'S-1-4-123-456-1' + assert.deepEqual(windowsCacheLocation(['runner', '--temp', root, '--temp-write-sid', sid, '--', 'worker']), { root, windowsWriteSid: sid }) + for (const bad of ['S-1-1-0', 'S-1-5-5-1-2', 'S-1-4-0-1-1', 'S-1-4-01-2-1', 'S-1-4-1073741824-2-1', 'S-1-4-1-2-2']) { + assert.equal(windowsCacheLocation(['runner', '--temp', root, '--temp-write-sid', bad, '--', 'worker']), undefined) + } + assert.equal(windowsCacheLocation(['runner', '--', 'worker', '--temp', root, '--temp-write-sid', sid]), undefined) + assert.equal(windowsCacheLocation(['runner', '--temp', root, '--temp', root, '--temp-write-sid', sid, '--', 'worker']), undefined) + assert.equal(windowsCacheLocation(['runner', '--temp', 'relative', '--temp-write-sid', sid, '--', 'worker']), undefined) +}) + +await test('real host references bind live session, current authority and producer visibility; disposal purges only cache blobs', async () => { + await withTempDir('storage-host', async root => { + const { ctx } = await bootHost(), session = { id: 'host-reference-owner', header: { cwd: root } }, scope = { session } + let mode = 'workspace-write', calls = 0 + try { + await ctx.plugin(Subprocess); await ctx.plugin(Sandbox) + await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: () => ({ mode, workspaceRoot: root, sessionId: session.id }) })) + await ctx.plugin(inner => mountResultLifecycle(inner)) + const registration = await ctx.plugin(inner => inner.tools.register({ name: publicToolName('grep'), description: 'visible producer', parameters: { type: 'object', properties: {} }, execute: async () => ({}) })) + const policy = () => ctx.get('sandboxPolicy').resolve({ session }) + const metadata = await resultStorage(ctx, session, undefined, WORKER, 'result', policy(), [], false) + assert.ok(metadata, 'real host must provide private storage') + assert.deepEqual(metadata.allowedTools, ['grep', 'result']) + await registration.dispose() + const hidden = await resultStorage(ctx, session, undefined, WORKER, 'result', policy(), [], false) + assert.deepEqual(hidden.allowedTools, ['result']); assert.equal(hidden.authorityId, metadata.authorityId) + const deniedRoots = await resultStorage(ctx, session, undefined, WORKER, 'result', policy(), [path.join(root, 'private')], false) + assert.notEqual(deniedRoots.authorityId, metadata.authorityId) + const call = args => { calls++; return invoke(ctx, WORKER, 'result', args, { agent: scope }, undefined, { denyCredentialReads: false }) } + const stored = await call({ action: 'put', items: [{ value: 'snapshot' }] }) + assert.equal(stored.status, 'completed', JSON.stringify(stored)); assert.match(stored.id, /^ref:\/\//) + assert.deepEqual((await call({ action: 'page', id: stored.id })).items, [{ value: 'snapshot' }]) + const cache = path.join(metadata.root, `s-${createHash('sha256').update(session.id).digest('hex')}`) + const blob = path.join(cache, stored.id.split('/').at(-1) + '.json') + assert.equal(fs.existsSync(blob), true) + const original = fs.readFileSync(blob), modified = fs.statSync(blob).mtimeMs + mode = 'read-only' + const narrowed = await call({ action: 'page', id: stored.id }) + assert.equal(narrowed.error.code, 'POLICY_DENIED', JSON.stringify(narrowed)) + assert.deepEqual(fs.readFileSync(blob), original); assert.equal(fs.statSync(blob).mtimeMs, modified) + mode = 'workspace-write' + assert.equal((await call({ action: 'page', id: stored.id })).complete, true) + assert.equal((await call({ action: 'purge' })).error.code, 'INVALID_ARGUMENT') + assert.equal(fs.existsSync(blob), true) + fs.mkdirSync(path.join(root, '.dsh-ops')); fs.writeFileSync(path.join(root, '.dsh-ops', 'audit'), 'durable') + await expireResultSession(ctx, session) + assert.equal(fs.existsSync(blob), false) + assert.equal(fs.readFileSync(path.join(root, '.dsh-ops', 'audit'), 'utf8'), 'durable') + assert.equal(fs.existsSync(path.join(cache, 'lock')), true) + assert.equal(calls, 5) + } finally { await shutdown(ctx) } + }) +}) + +await test('read-only first use cannot create result storage or invoke a cache-preparation provider', async () => { + await withTempDir('storage-readonly', async root => { + const { ctx } = await bootHost(), session = { id: 'read-only-first', header: { cwd: root } } + try { + await ctx.plugin(inner => inner.provide('sandbox', { confine() { assert.fail('read-only must not prepare a writable cache') } })) + const value = await resultStorage(ctx, session, undefined, WORKER, 'result', { mode: 'read-only', workspaceRoot: root, sessionId: session.id }, [], false) + assert.equal(value, undefined); assert.deepEqual(fs.readdirSync(root), []) + await expireResultSession(ctx, session) + } finally { await shutdown(ctx) } + }) +}) +report('result-storage-host') diff --git a/test/worker-protocol.test.mjs b/test/worker-protocol.test.mjs index dfe23d9b..42ea9903 100644 --- a/test/worker-protocol.test.mjs +++ b/test/worker-protocol.test.mjs @@ -18,6 +18,7 @@ await test('bridge budgets keep existing null and fractional timer semantics', ( assert.equal(workerEnvelope('file', {}, null).budget.timeoutMs, WORKER_TIMEOUT_MS) assert.equal(workerEnvelope('file', {}, 0.5).budget.timeoutMs, 1) assert.equal(workerEnvelope('ptc', {}, 12.9).budget.timeoutMs, 12) + assert.equal(workerEnvelope('file', {}, 60000).budget.timeoutMs, 30000) for (const timeout of [0, -1, NaN, Infinity, 2147483648, '100']) { assert.throws(() => workerEnvelope('file', {}, timeout), TypeError) } @@ -90,11 +91,12 @@ await test('each bridge emits fresh trusted metadata and collects the advertised stdout: { readFrom: () => ({ text: '{"status":"completed"}', lossy: false }) }, stderr: { readFrom: () => ({ text: '', lossy: false }) }, } } } })) + const exec = execution(root) for (const [call, tool, args, component, timeout] of [ [fileCall, 'grep', { path: root, pattern: 'x', requestId: 'model-value' }, 'file', 1234], - [ptcCall, 'result', { action: 'put', items: [] }, 'ptc', WORKER_TIMEOUT_MS], + [ptcCall, 'batch', { action: 'plan', operations: [] }, 'ptc', WORKER_TIMEOUT_MS], ]) { - const value = await call(ctx, 'worker', tool, args, execution(root), signal(), { toolCallTimeoutMs: timeout }) + const value = await call(ctx, 'worker', tool, args, exec, signal(), { toolCallTimeoutMs: timeout }) assert.equal(value.status, 'completed', JSON.stringify(value)) const spec = calls.at(-1), envelope = JSON.parse(spec.stdio.stdin.data) assert.equal(envelope.version, 1); assert.equal(envelope.component, component) From ffa3cbf4a84a9968bacaccfc19a8388ba1a151f1 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:39:37 +0800 Subject: [PATCH 017/118] fix(results): separate cache and business write authority --- docs/native-kernel.md | 3 ++- ...6\237\347\224\237\345\206\205\346\240\270.md" | 3 ++- native/crates/ops-results/src/cache.rs | 8 ++------ test/result-references.test.mjs | 16 ++++++++++++++++ 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 7e28a926..0eab229d 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -54,7 +54,8 @@ existing absolute cache root, a 64-character lowercase SHA-256 authority identit canonical producer-tool allowlist. Its authority identity must bind the live session nonce and current policy/credential boundaries; knowing a reference never grants authority. Each access also rechecks scope, selected canonical root and directory identity, producer and current credential -fences. The cache root remains independent of operation-root selections. +fences. The trusted cache root is a cache-only grant independent of operation-root selections and business +write_roots. It is never added to the business mutation allowlist. With this context, result put returns `ref:////` and `reference` metadata: refId, createdAt (epoch milliseconds), ttlMs, schemaVersion, producerTool, totalItems, diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index 0021a5da..a499f60b 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -46,7 +46,8 @@ worker 接受可选的可信信封元数据 `storage: { root, authorityId, allow 工具参数和内层 request 均不能注入这些字段。宿主提供已存在的绝对缓存根目录、64 位小写 SHA-256 权限身份,以及当前规范化生产工具白名单。权限身份必须绑定本次会话随机标识、当前策略和凭据 边界;知道引用不等于获得权限。每次访问仍核实会话、选定的规范化根路径及目录身份、生产工具和 -当前凭据边界。缓存位置不随操作根目录选择而改变。 +当前凭据边界。可信缓存根目录是独立的缓存专用授权,不随操作根目录选择而改变,也不受业务 write_roots 限制; +绝不将缓存位置加入业务修改白名单。 带此上下文的 result put 返回 `ref://<编码会话>/<工具>/`,并提供 `reference` 元数据: refId、createdAt(Unix 毫秒)、ttlMs、schemaVersion、producerTool、totalItems、totalBytes diff --git a/native/crates/ops-results/src/cache.rs b/native/crates/ops-results/src/cache.rs index 23ced186..00d9ce3f 100644 --- a/native/crates/ops-results/src/cache.rs +++ b/native/crates/ops-results/src/cache.rs @@ -23,12 +23,8 @@ impl Cache { request.authorize_path(std::path::Path::new(&context.root))?; let checked = file_path::checked(&context.root)?; let root = checked.parent.open_dir_nofollow(&checked.name)?; - if !request.read_only && let Some(roots) = &request.write_roots { - let actual = std::fs::canonicalize(&context.root)?; - if !roots.iter().any(|root| std::fs::canonicalize(root).is_ok_and(|root| actual.starts_with(root))) { - return Err(Failure::policy("write-roots", "Cache root is outside authorized write roots")); - } - } + // Trusted storage.root grants cache-only access independently of business write roots. + // Never widen request.write_roots: business publication still checks its own authority. let name = format!("s-{:x}", Sha256::digest(request.scope.as_bytes())); let dir = cache_io::directory(&root, &name, create && !request.read_only, context.windows_write_sid.as_deref())?; let lock = if create { cache_io::lock_file(&dir, !request.read_only, context.windows_write_sid.as_deref())? } else { cache_io::open_file(&dir, "lock", !request.read_only, context.windows_write_sid.as_deref())? }.into_std(); diff --git a/test/result-references.test.mjs b/test/result-references.test.mjs index db8acb4d..3c012cdf 100644 --- a/test/result-references.test.mjs +++ b/test/result-references.test.mjs @@ -68,6 +68,22 @@ await test('references recheck session, root, authority, capability and credenti }) }) +await test('trusted cache authorization is independent of unchanged business write roots', async () => { + await fixture(async (root, cache) => { + const business = path.join(root, 'workspace'); fs.mkdirSync(business) + const stored = await call(business, cache, { action: 'put', items: [{ private: true }] }, { writeRoots: [business] }) + assert.equal(stored.status, 'completed', JSON.stringify(stored)) + assert.deepEqual((await call(business, cache, { action: 'page', id: stored.id }, { writeRoots: [business] })).items, [{ private: true }]) + const outside = path.join(cache, 'not-a-cache-object') + const write = input(business, cache, { path: outside, content: 'forbidden', expectedRevision: 'absent' }, { writeRoots: [business] }) + write.request.tool = 'write_file' + const denied = await send(write) + assert.equal(denied.error.code, 'POLICY_DENIED'); assert.equal(denied.error.effectState, 'none') + assert.equal(fs.existsSync(outside), false) + assert.equal((await call(business, cache, { action: 'purge' }, { writeRoots: [business] })).removed, 1) + }) +}) + await test('read-only pages do not touch LRU or create storage and cannot put or release', async () => { await fixture(async (root, cache) => { const stored = await call(root, cache, { action: 'put', items: [{ value: 1 }] }) From 7a0042ea253f55c345791f4c194e7f27d35f0d63 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:47:08 +0800 Subject: [PATCH 018/118] fix(results): preserve session lifetime across reload and cancellation --- docs/result-budgets.md | 3 +- ...23\346\236\234\351\242\204\347\256\227.md" | 3 +- src/index.js | 2 +- src/kernel/result-lifecycle.ts | 27 +++++++--- src/ptc/index.js | 1 - test/authority.test.mjs | 7 ++- test/mount.test.mjs | 3 +- test/ptc.test.mjs | 21 +++++--- test/result-lifecycle.test.mjs | 51 ++++++++++++++++++- 9 files changed, 96 insertions(+), 22 deletions(-) diff --git a/docs/result-budgets.md b/docs/result-budgets.md index ded0988d..c2d687ff 100644 --- a/docs/result-budgets.md +++ b/docs/result-budgets.md @@ -23,7 +23,8 @@ references retain their separate contract. Session disposal immediately expires references, cancels queued/running work, awaits settlement, then purges validated snapshot/staging blobs through the current host authority. The stable lock and unknown names remain; audit data is never purged. Read-only mode or unavailable host services -can defer physical cleanup, with a diagnostic. A fresh in-memory session nonce prevents reopening +can prevent physical cleanup, with a diagnostic. No automatic retry is scheduled; up to the session +quota can remain for host temporary-directory cleanup or operator removal. A fresh in-memory session nonce prevents reopening disk leftovers as valid references after a restart. Component owners share this lifetime; unloading the last Native/PTC owner expires its remaining sessions. Cleanup is an internal operation and cannot be requested through model tool arguments. diff --git "a/docs/\347\273\223\346\236\234\351\242\204\347\256\227.md" "b/docs/\347\273\223\346\236\234\351\242\204\347\256\227.md" index 3947f4a0..17f91633 100644 --- "a/docs/\347\273\223\346\236\234\351\242\204\347\256\227.md" +++ "b/docs/\347\273\223\346\236\234\351\242\204\347\256\227.md" @@ -16,6 +16,7 @@ PTC 仍通过宿主沙箱执行,Native 固定文件操作保持原有权限执 会话销毁立即使引用过期,取消等待及执行中的调用,等待执行结束,再按当前宿主权限清理经过 验证的快照和暂存文件。稳定锁及未知文件保留,审计数据不清理。只读模式或宿主服务不可用时, -物理清理可能延期并记录诊断;内存中的新会话随机标识保证重启后不能重新使用磁盘残留引用。 +物理清理可能无法完成并记录诊断;不安排自动重试,最多一个会话配额的残留文件需由宿主临时目录 +清理或操作者删除。内存中的新会话随机标识保证重启后不能重新使用磁盘残留引用。 各组件共享生命周期;最后一个 Native/PTC 所有者卸载时清理剩余会话。清理为内部操作,模型 不能通过工具参数发起。 diff --git a/src/index.js b/src/index.js index c7e4f0d5..ce4c41c3 100644 --- a/src/index.js +++ b/src/index.js @@ -70,7 +70,7 @@ async function mountNativeFile(ctx, config, file) { // `safeDispose` isolates synchronous teardown callbacks; a *rejecting promise* needs its own // catch, or the rejection is dropped and the worker's shutdown failure stays invisible (#30). ctx.effect(() => () => { - void surface.stop().catch(error => report(ctx, `dsh-ops: native file worker shutdown failed: ${error?.message ?? error}`, 'warn')) + return surface.stop().catch(error => report(ctx, `dsh-ops: native file worker shutdown failed: ${error?.message ?? error}`, 'warn')) }, 'dsh-ops: native file worker') const tools = await surface.start() if (tools.length) report(ctx, `dsh-ops: native Rust file worker ready; ${tools.length} tools.`, 'info') diff --git a/src/kernel/result-lifecycle.ts b/src/kernel/result-lifecycle.ts index 1646f70f..325c531f 100644 --- a/src/kernel/result-lifecycle.ts +++ b/src/kernel/result-lifecycle.ts @@ -1,9 +1,11 @@ import { randomUUID } from 'node:crypto' +import { CallBudgetError } from './call-budget.js' export interface ResultSession { id: string; header?: { cwd?: string } } export interface LifecycleHost { root?: object on(name: 'session/disposed', callback: (session: ResultSession) => void): unknown + on(name: 'session/event', callback: (session: ResultSession, event: { type: string }) => void): unknown effect(callback: () => (() => Promise), label: string): unknown logger?: { warn?: (message: string) => void } } @@ -25,6 +27,7 @@ function owner(ctx: Pick): Owner { function live(ctx: Pick, session: ResultSession): LiveSession { const state = owner(ctx) if (state.expired.has(session)) throw new Error('Result session has expired') + // The pinned SessionStore emits its exact stored Session object throughout one lifetime. if ([...state.sessions].some(([other, entry]) => other !== session && other.id === session.id && !entry.stopped)) { throw new Error('Another live session owns this identity') } @@ -53,35 +56,43 @@ export async function withResultSession(ctx: Pick, ses .map(([, entry]) => entry.closing) const work = Promise.resolve().then(async () => { await Promise.allSettled(preceding) - controller.signal.throwIfAborted(); return run(controller.signal) + if (controller.signal.aborted) throw new CallBudgetError('CANCELLED', 'OPS call cancelled before worker admission; no effects.') + return run(controller.signal) }) entry.work.set(controller, work) try { return await work } finally { entry.work.delete(controller); signal?.removeEventListener('abort', abort) } } -export function expireResultSession(ctx: Pick, session: ResultSession): Promise { +export function expireResultSession(ctx: Pick, session: ResultSession, disposed = true): Promise { const state = owner(ctx), entry = state.sessions.get(session) - state.expired.add(session) + if (disposed) state.expired.add(session) if (!entry) return Promise.resolve() if (entry.closing) return entry.closing entry.stopped = true - for (const controller of entry.work.keys()) controller.abort(new Error('Result session disposed')) - entry.closing = (async () => { + // Publish the barrier before abort listeners can synchronously reopen the same id. + entry.closing = Promise.resolve().then(async () => { await Promise.allSettled(entry.work.values()) for (const cleanup of entry.cleanup.values()) { try { await cleanup() } - catch { ctx.logger?.warn?.('dsh-ops: session result references expired; physical cache cleanup deferred under current host authority.') } + catch { ctx.logger?.warn?.('dsh-ops: session result references expired; physical cache cleanup did not complete under current host authority. Temporary files remain for host or operator cleanup; no automatic retry is scheduled.') } } entry.cleanup.clear(); state.sessions.delete(session) - })() + }) + for (const controller of entry.work.keys()) controller.abort(new Error('Result session disposed')) return entry.closing } /** Each component owns its listeners. Last-owner teardown expires remaining sessions. */ export function mountResultLifecycle(ctx: LifecycleHost): void { const state = owner(ctx); state.mounts++ ctx.on('session/disposed', session => { void expireResultSession(ctx, session) }) + ctx.on('session/event', (session, event) => { + if (event.type !== 'sandbox/mode') return + for (const [other, entry] of state.sessions) if (other === session) { + for (const controller of entry.work.keys()) controller.abort(new Error('Sandbox authority changed')) + } + }) ctx.effect(() => async () => { if (--state.mounts !== 0) return - await Promise.allSettled([...state.sessions.keys()].map(session => expireResultSession(ctx, session))) + await Promise.allSettled([...state.sessions.keys()].map(session => expireResultSession(ctx, session, false))) }, 'dsh-ops: session result lifecycle') } diff --git a/src/ptc/index.js b/src/ptc/index.js index 7381cd17..58aa895b 100644 --- a/src/ptc/index.js +++ b/src/ptc/index.js @@ -116,7 +116,6 @@ export function apply(root, rawConfig) { ctx.on('tools/change', reconcile) ctx.on('session/event', (_session, event) => { if (event.type === 'sandbox/mode') { - for (const controller of active.keys()) controller.abort(new Error('Sandbox authority changed')) reconcile() } }) diff --git a/test/authority.test.mjs b/test/authority.test.mjs index 372810ff..c294382c 100644 --- a/test/authority.test.mjs +++ b/test/authority.test.mjs @@ -16,7 +16,12 @@ import { anchoredPath } from '../lib/authority-paths.js' import { sessionTempDir, ensureSessionTemp } from '../lib/session-temp.js' const signal = () => new AbortController().signal -const execution = root => ({ agent: { session: { id: 'authority-test', header: { cwd: root } } }, signal: signal() }) +// SessionStore keeps one exact Session object throughout a live session, including mode changes. +const sessions = new Map() +const execution = root => { + if (!sessions.has(root)) sessions.set(root, { id: 'authority-test', header: { cwd: root } }) + return { agent: { session: sessions.get(root) }, signal: signal() } +} const writeArgs = file => ({ path: file, content: 'new', expectedRevision: 'absent' }) await test('native normalization requires a recognized stable reason and preserves effect facts', () => { diff --git a/test/mount.test.mjs b/test/mount.test.mjs index 80242f9a..35adcead 100644 --- a/test/mount.test.mjs +++ b/test/mount.test.mjs @@ -169,7 +169,8 @@ await test('native file paths resolve from session cwd and enforce workspace-wri try { const read = await callTool(ctx, publicToolName('inspect_local_file'), { path: outside }, undefined, workspace) assert.equal(read.value.status, 'completed') - const readonly = await agentForCall(ctx, dir, 'read-only') + workspace.session.mode = 'read-only' + const readonly = workspace const denied = await callTool(ctx, publicToolName('replace'), { path: outside, pattern: 'outside', replacement: 'changed' }, undefined, readonly) assert.equal(denied.value.error.code, 'POLICY_DENIED') assert.match(denied.value.error.message, /User authorization/) diff --git a/test/ptc.test.mjs b/test/ptc.test.mjs index 78a54c69..b80ffaf8 100644 --- a/test/ptc.test.mjs +++ b/test/ptc.test.mjs @@ -183,8 +183,9 @@ try { await bridge.plugin(inner => { inner.provide('sandboxPolicy', { resolve: () => ({ mode: 'read-only', workspaceRoot: dir, sessionId: 'alias' }) }) }) try { const aliasRoot = fs.realpathSync.native(dir) !== dir + const aliasSession = { id: 'alias', mode: 'read-only', header: { cwd: dir } } const fileCall = (tool, args) => invokeFile(bridge, file, tool, args, - { agent: { session: { id: 'alias', mode: 'read-only', header: { cwd: dir } } } }, new AbortController().signal) + { agent: { session: aliasSession } }, new AbortController().signal) const observed = await fileCall('inspect_local_file', { path: path.join(dir, 'main/keep') }) assert.equal(observed.status, 'completed', JSON.stringify(observed)) assert.equal(observed.items[0]?.text, 'protected', JSON.stringify(observed)) @@ -317,17 +318,22 @@ try { const monitored = await def.execute({action:'plan',operations:[{op:'remove',path:'host-dest'}]}, {agent:pure,signal:new AbortController().signal}) assert.equal(monitored.status,'planned'); assert.equal(hostJobs.size,0); assert.ok(jobNumber >= 1) const aborted = new AbortController(); aborted.abort() - assert.equal((await invoke(ctx,file,'batch',{action:'run',operations:[{op:'remove',path:'host-dest'}]}, {agent:pure},aborted.signal)).error.effectState,'none') + const cancelled = await invoke(ctx,file,'batch',{action:'run',operations:[{op:'remove',path:'host-dest'}]}, {agent:pure},aborted.signal) + assert.equal(cancelled.error.effectState,'none'); assert.equal(cancelled.error.code,'CANCELLED') assert.equal(calls.length, before+2) const abortDuringWork = new AbortController() + let enteredWorker + const workerStarted = new Promise(resolve => { enteredWorker = resolve }) const interruptedCtx = { get(name) { if (name === 'sandboxPolicy') return { resolve: () => ({mode:'danger-full-access',workspaceRoot:dir}) } if (name === 'subprocess') return { spawn() { + enteredWorker() return { collected: { stdout: {readFrom:()=>({text:'',lossy:false})},stderr:{readFrom:()=>({text:'',lossy:false})} }, done: new Promise(resolve => abortDuringWork.signal.addEventListener('abort', () => resolve({exitCode:null,signal:'SIGTERM'}), {once:true})) } } } } } const interruption = invoke(interruptedCtx,file,'batch',{action:'run',operations:[{op:'remove',path:'host-dest'}]}, {agent:pure},abortDuringWork.signal) + await workerStarted abortDuringWork.abort() const unsettled = await interruption assert.equal(unsettled.error.code,'RESULT_UNKNOWN'); assert.match(unsettled.detailsRef,/^j-/) @@ -419,12 +425,13 @@ try { } const sdkResult = await sdk(` const stored = await tools.ops_result({action:'put',items:Array.from({length:120},(_,n)=>({n,kind:n%2?'odd':'even',payload:'x'.repeat(80)}))}); - const selected = []; let offset = 0; let stable = true; let bounded = true; + const selected = []; let offset = 0; let stable = true; let bounded = true; let contentHash; while (true) { - const page = await tools.ops_result({action:'page',id:stored.id,equals:{kind:'even'},fields:['n'],offset,limit:7,maxBytes:512}); + const page = await tools.ops_result({action:'page',id:stored.id,equals:{kind:'even'},fields:['n'],offset,limit:7,maxBytes:2048}); if (page.error) return page; - stable = stable && page.contentHash === stored.contentHash && page.sourceTotal === 120 && page.total === 60; - bounded = bounded && JSON.stringify(page).length <= 512; + contentHash ??= page.contentHash; + stable = stable && page.contentHash === contentHash && page.sourceTotal === 120 && page.total === 60 && stored.reference.totalItems === 120; + bounded = bounded && JSON.stringify(page).length <= 2048; selected.push(...page.items.map(row=>row.n)); if (page.complete) break; if (page.nextOffset <= offset) return {error:'pagination did not advance'}; @@ -440,7 +447,7 @@ try { return {stable,bounded,count:selected.length,last:selected[selected.length-1],invalid:invalid.error.code,denied:denied.error, audit:audit.items[0],resumed:resumed.status,replay:replay.done,detailsRef:plan.detailsRef}; `) - assert.equal(sdkResult.stable, true); assert.equal(sdkResult.bounded, true) + assert.equal(sdkResult.stable, true, JSON.stringify(sdkResult)); assert.equal(sdkResult.bounded, true) assert.equal(sdkResult.count, 60); assert.equal(sdkResult.last, 118) assert.equal(sdkResult.invalid, 'INVALID_ARGUMENT'); assert.equal(sdkResult.audit.state, 'pending') assert.equal(sdkResult.denied.code, 'POLICY_DENIED'); assert.equal(sdkResult.denied.permission.current, 'workspace-write') diff --git a/test/result-lifecycle.test.mjs b/test/result-lifecycle.test.mjs index 2c2d820d..74139421 100644 --- a/test/result-lifecycle.test.mjs +++ b/test/result-lifecycle.test.mjs @@ -2,6 +2,8 @@ import { setImmediate } from 'node:timers/promises' import { assert, test, report } from './lib/harness.mjs' import { bootHost, shutdown } from './lib/host.mjs' import { resultSessionNonce, registerResultCleanup, withResultSession, expireResultSession, mountResultLifecycle } from '../lib/kernel/result-lifecycle.js' +import { invoke as fileCall } from '../lib/file/worker.js' +import { withTempDir } from './lib/harness.mjs' const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done }); return { promise, resolve } } @@ -41,7 +43,10 @@ await test('two component owners share the live session; last unload expires it' assert.equal(purged, 0); assert.equal(resultSessionNonce(ctx, session), nonce) await second.dispose() assert.equal(purged, 1) - assert.throws(() => resultSessionNonce(ctx, session), /expired/) + const reopened = await ctx.plugin(inner => mountResultLifecycle(inner)) + assert.notEqual(resultSessionNonce(ctx, session), nonce) + assert.equal(await withResultSession(ctx, session, undefined, async () => 'resumed'), 'resumed') + await reopened.dispose() } finally { await shutdown(ctx) } }) @@ -60,4 +65,48 @@ await test('real host session-disposed event expires references and contains cle await fiber.dispose() } finally { await shutdown(ctx) } }) +await test('synchronous abort listeners cannot start a reopened session before the old purge', async () => { + const ctx = {}, old = { id: 'reentrant' }, next = { id: 'reentrant' }, settled = deferred(), events = [] + let reopened + registerResultCleanup(ctx, old, 'cache', async () => { events.push('purged') }) + const work = withResultSession(ctx, old, undefined, async signal => { + signal.addEventListener('abort', () => { + events.push('aborted') + reopened = withResultSession(ctx, next, undefined, async () => { events.push('reopened') }) + }, { once: true }) + await settled.promise; events.push('settled') + }) + await setImmediate() + const closing = expireResultSession(ctx, old) + await setImmediate(); assert.deepEqual(events, ['aborted']) + settled.resolve(); await Promise.all([work, closing, reopened]) + assert.deepEqual(events, ['aborted', 'settled', 'purged', 'reopened']) + await expireResultSession(ctx, next) +}) + +await test('permission changes cancel the owning Native session while sibling work continues', async () => { + await withTempDir('lifecycle-mode', async root => { + const { ctx } = await bootHost(), siblingGate = deferred(), entered = deferred() + const one = { id: 'one', header: { cwd: root } }, two = { id: 'two', header: { cwd: root } } + let mode = 'workspace-write', spawned = 0 + try { + await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: ({ session }) => ({ mode: session.id === 'one' ? mode : 'workspace-write', workspaceRoot: root }) })) + await ctx.plugin(inner => inner.provide('subprocess', { spawn(spec) { + const sessionId = JSON.parse(spec.stdio.stdin.data).request.scope + const done = sessionId === 'one' ? new Promise(resolve => spec.signal.addEventListener('abort', () => resolve({ exitCode: 0 }), { once: true })) : siblingGate.promise + if (++spawned === 2) entered.resolve() + return { done, collected: { stdout: { readFrom: () => ({ text: '{"status":"completed"}' }) }, stderr: { readFrom: () => ({ text: '' }) } } } + } })) + await ctx.plugin(inner => mountResultLifecycle(inner)) + const work = fileCall(ctx, 'unused', 'edit_many', { path: 'file' }, { agent: { session: one } }) + const sibling = fileCall(ctx, 'unused', 'stat', { path: 'file' }, { agent: { session: two } }) + await entered.promise + mode = 'read-only'; ctx.emit('session/event', one, { type: 'sandbox/mode' }) + const stopped = await work + assert.equal(stopped.error.code, 'RESULT_UNKNOWN'); assert.equal(stopped.error.effectState, 'unknown') + siblingGate.resolve({ exitCode: 0 }); assert.equal((await sibling).status, 'completed') + assert.throws(() => resultSessionNonce(ctx, { ...one }), /Another live session/) + } finally { siblingGate.resolve({ exitCode: 0 }); await shutdown(ctx) } + }) +}) report('result-lifecycle') From c49634ce3a99f7e2b5ae4f6d65f885450a1ad620 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:56:50 +0800 Subject: [PATCH 019/118] feat(results): spill bounded output into session snapshots --- docs/CHANGELOG.md | 11 + docs/native-kernel.md | 47 ++++- ...37\347\224\237\345\206\205\346\240\270.md" | 37 +++- native/crates/ops-files/src/file.rs | 12 +- native/crates/ops-files/src/file_many.rs | 34 ++- native/crates/ops-files/src/file_output.rs | 17 +- native/crates/ops-files/src/file_single.rs | 2 +- native/crates/ops-protocol/src/capture.rs | 83 ++++++++ native/crates/ops-protocol/src/lib.rs | 5 + native/crates/ops-results/src/lib.rs | 1 + native/crates/ops-results/src/references.rs | 19 +- native/crates/ops-results/src/spill.rs | 139 +++++++++++++ native/crates/ops-search/src/lib.rs | 2 +- native/crates/ops-search/src/paging.rs | 30 ++- native/crates/ops-search/src/scan.rs | 2 +- native/crates/ops-search/src/search.rs | 4 +- native/src/lib.rs | 16 +- native/src/main.rs | 3 +- test/result-spill.test.mjs | 195 ++++++++++++++++++ 19 files changed, 619 insertions(+), 40 deletions(-) create mode 100644 native/crates/ops-protocol/src/capture.rs create mode 100644 native/crates/ops-results/src/spill.rs create mode 100644 test/result-spill.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 503bf7c0..450e85c2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,17 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Spill oversized canonical output into bounded session snapshots (#261, 6b/16) + +- Preserve a useful head and original operation root, execution facts and mutation effects while + storing requested file/search rows as bounded, losslessly reconstructable tagged JSON records. +- Keep original scan coverage separate from stored-page and capture completeness; quota rollback + never leaves partial logical JSON. Cache errors remain separate from business effects. +- Authorize trusted cache roots independently of business write roots, preserving no-follow, + credential and readonly boundaries; reference pages never create nested references. +- 大结果保留有界开头与原始根目录,并以可无损重建的分块引用缓存请求范围;区分扫描、捕获和 + 分页完整性,缓存失败不覆盖业务副作用,缓存专用授权不扩大业务写入范围。 + ### Add private session snapshot references (#261, 6a/16) - Add trusted-envelope `ref://session/tool/uuid` storage with 10 MiB snapshot and 100 MiB session diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 0eab229d..6c8cc603 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -31,20 +31,22 @@ The benchmark preflight checks all workspace inputs while ignoring Cargo target New bridges send `{ version: 1, requestId, component, budget, request }`. `request` retains the strict existing capability and host authority fields; `component` is `file` or `ptc` and never grants authority. The bridge generates a fresh UUID, supplies the authoritative session/root and -policy, then rechecks authority immediately before spawning as before. Success JSON is unchanged. +policy, then rechecks authority immediately before spawning as before. Ordinary success fields +retain their semantics; optional reference fields are described below. `budget.timeoutMs` is 1..2147483647 milliseconds, using the existing 300000ms default and existing host cancellation. A process-local watchdog also enforces it for direct callers; interruption exits 124 and bridges report unknown effects. `budget.maxBytes` is 1024..131072, defaults to 131072, and counts serialized JSON plus its newline. The minimum leaves room for a structured error. Oversized -read output returns `OUTPUT_LIMIT` with no effects; a lost mutation receipt reports unknown effects +read output without trusted storage returns `OUTPUT_LIMIT` with no effects; a lost mutation +receipt reports unknown effects unless an existing failure already carries a more precise effect state. No automatic retry follows. The complete request remains capped at 2 MiB. Explicit legacy unversioned requests remain accepted for 0.3.x benchmark comparisons, with the same authority checks and bounded default transport budget. Any envelope marker selects strict v1 decoding: partial envelopes, unknown fields, invalid IDs, unsupported components/versions and unusable budgets are rejected before filesystem effects. -Request IDs do not change result hashes. Scan and automatic output-spill integration are separate work. +Request IDs do not change result hashes. Automatic output spill is described below. ## Session snapshot references (6a/16) @@ -88,5 +90,40 @@ accepts no other arguments. It removes only validated snapshot/stage names for t the lock inode, directories and unknown/audit names. It returns `removed`, `retained`, `complete` and `effectState`; partial cleanup failures identify prior effects. Readonly purge is refused. The host must first invalidate references logically and await active calls; a denied or interrupted -purge is deferred cleanup, never a reason to rerun business operations. This worker slice does not -yet attach host lifecycle hooks or spill ordinary tool output automatically. +purge is deferred cleanup, never a reason to rerun business operations. Host lifecycle hooks are +attached by the host integration slice. + +## Automatic bounded output spill (6b/16) + +With trusted storage, ordinary file/search/batch output that exceeds the serialized inline budget +keeps a bounded head and adds `reference` and `referenceRoot` (the canonical operation root). +Pass that original root when paging after cwd changes. Display `complete` is false; original +status, mutation counts/effects and scan coverage remain intact. Small outputs create no cache. +The worker captures the requested range/page, never silently broadens a query to the whole tree. + +Reference pages include `source`: the original non-item execution facts, `root`, +`itemEncoding: "tagged-json-v1"`, `capturedItems` (logical items) and `captureComplete`. +Original scanComplete/matchedTotal, revision/range facts and continuation semantics remain there. +`page.complete` means that stored records have been displayed; it proves neither a complete scan +nor complete capture. For read_many, one logical item is the whole per-file receipt, including its +lines, revision, bounds and any failure. Per-file failures remain values. + +Small records are `{ kind: "item", index, value }`. Large items become bounded +`{ kind: "item-json-chunk", index, chunk, byteOffset, text, complete }` records. `index` and +`chunk` start at zero; byteOffset counts UTF-8 bytes of the serialized logical JSON. Concatenate +text in chunk order and JSON.parse only after the final complete=true chunk. Verify contiguous +indices/offsets and final markers. StorageReference.totalItems counts physical records, while +source.capturedItems counts reconstructed logical items. Chunking occurs before display clipping, +so an inspected line's Unicode/control characters and suffix remain recoverable. + +Capture is bounded below the 10 MiB physical snapshot quota. If one logical item cannot fit, all +of its chunks are discarded and only preceding whole items are stored; source.captureComplete is +false and storageError reports QUOTA_EXCEEDED. Narrow the query for the missing range. Capture +completion is independent of original scan completion. Reference pages never spill another handle. + +A cache failure adds bounded `storageError: { code, message, effectState }`; its effectState refers +only to cache effects, separately from business status/changed/effectState. Readonly observation +cannot create a reference. Cache refusal retains an incomplete bounded head with range/hex recovery; +completed mutations must not be repeated. Reference metadata is budget-checked before publication, +so an unusably small head budget does not create an unreachable blob. Calls without trusted storage +retain the legacy transport contract; the host supplies recovery guidance when storage is unavailable. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index a499f60b..80c563d6 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -27,18 +27,18 @@ English: [native-kernel.md](native-kernel.md). 新的宿主桥接发送 `{ version: 1, requestId, component, budget, request }`。`request` 保留严格的 能力参数和宿主权限字段;`component` 为 `file` 或 `ptc`,不会授予权限。桥接生成新的 UUID, -填入真实会话、根路径和策略,并在启动进程前再次核实权限。成功结果 JSON 保持不变。 +填入真实会话、根路径和策略,并在启动进程前再次核实权限。普通成功字段保留原有语义;可选引用字段见下文。 `budget.timeoutMs` 范围为 1..2147483647 毫秒,沿用现有 300000 毫秒默认值及宿主取消机制。 进程内的看门狗也会约束直接调用;超时退出码为 124,桥接将副作用报告为 unknown。 `budget.maxBytes` 范围为 1024..131072,默认 131072,计算序列化 JSON 与末尾换行的总字节数。 -下限保证结构化错误有足够空间。读取输出超限返回 `OUTPUT_LIMIT` 且无副作用;修改回执丢失则 +下限保证结构化错误有足够空间。没有可信存储的读取输出超限返回 `OUTPUT_LIMIT` 且无副作用;修改回执丢失则 报告 unknown,除非已有错误携带更精确的副作用状态。不会自动重试。 完整请求仍以 2 MiB 为上限。为兼容 0.3.x 基准对比,显式保留旧版无版本请求,采用相同权限检查和 有界默认传输预算。只要出现任一信封字段,就严格按 v1 解析;缺字段、未知字段、非法 ID、 不支持的组件或版本、无法使用的预算,均在文件系统副作用前被拒绝。请求 ID 不改变结果哈希。 -扫描及自动输出溢出集成由其他切片实现。 +自动输出溢出见下文。 ## 会话快照引用(6a/16) @@ -73,4 +73,33 @@ Windows 宿主提供受限调用实际的私有 `--temp` 和 `--temp-write-sid` 该会话范围内已验证的快照/暂存名,保留锁 inode、目录以及未知/审计名称。结果包含 removed、 retained、complete、effectState;部分清理失败会明确已有副作用。只读模式拒绝 purge。宿主必须 先在逻辑上使引用失效,再等待活动调用结束;清理被拒绝或中断时延后清理,绝不能重跑业务操作。 -本 worker 切片尚未接入宿主生命周期,也不会自动缓存普通工具的过大输出。 +宿主生命周期钩子由宿主集成切片接入。 + +## 过大输出的自动有界缓存(6b/16) + +提供可信存储时,普通文件、搜索和批处理输出超出序列化内联预算后,保留有界开头并返回 +reference 与 referenceRoot(原始操作的规范根目录)。cwd 改变后分页仍须传入该原始根目录。 +显示 complete 为 false;原始状态、修改数量/副作用和扫描覆盖事实保持不变。小结果不创建缓存。 +worker 仅捕获请求的范围或页,不会把查询静默扩大成整棵目录树。 + +引用页包含 source:原始非 items 执行事实,以及 root、itemEncoding: "tagged-json-v1"、 +capturedItems(逻辑条目数量)和 captureComplete。原始 scanComplete/matchedTotal、版本、范围 +及 continuation 语义保留其中。page.complete 仅表示存储记录已显示完毕,不证明扫描或捕获完整。 +read_many 的一个逻辑条目是完整逐文件回执,包括行、版本、范围及错误;逐文件失败仍为结果值。 + +小条目编码为 `{ kind: "item", index, value }`;大条目编码为一串有界的 +`{ kind: "item-json-chunk", index, chunk, byteOffset, text, complete }`。index 和 chunk 从零开始; +byteOffset 计算逻辑 JSON 序列化后的 UTF-8 字节。按 chunk 顺序连接 text,仅在最后一块的 +complete=true 后 JSON.parse;须验证连续索引、偏移及结束标记。StorageReference.totalItems +计算物理记录数,source.capturedItems 计算重建后的逻辑条目数。分块发生在显示裁切前,因此 +被检查行的 Unicode、控制字符与行尾内容均可恢复。 + +捕获预算小于 10 MiB 实际快照配额。若某个逻辑条目放不下,其全部块会回滚,仅存储此前完整 +条目;source.captureComplete 为 false,storageError 报告 QUOTA_EXCEEDED。缩小查询以获取 +缺失范围。捕获完整性独立于原始扫描完整性;引用页绝不会再次溢出为引用。 + +缓存失败附加有界的 `storageError: { code, message, effectState }`,其中 effectState 仅描述 +缓存副作用,与业务 status/changed/effectState 分开。只读观察不能创建引用。缓存拒绝时保留 +不完整的有界开头,并提示范围/十六进制恢复;已完成的修改不能重跑。发布前先验证引用元数据 +能否放进头部预算,避免因预算过小而留下无法取得的快照。没有可信存储时保留旧传输契约, +由宿主在存储不可用时补充恢复指导。 diff --git a/native/crates/ops-files/src/file.rs b/native/crates/ops-files/src/file.rs index f7be283e..c030349a 100644 --- a/native/crates/ops-files/src/file.rs +++ b/native/crates/ops-files/src/file.rs @@ -73,15 +73,15 @@ mod output; mod many; /// Reserve space for metadata added by search callers after this shared builder. -pub fn result(items: Vec, total: usize, offset: usize, next_base: usize, matched: usize, truncated: bool) -> Value { - output::bounded(items, total, offset, next_base, matched, truncated, json!({}), MAX_OUTPUT_BYTES - ENVELOPE_OVERHEAD) +pub fn result(request: &Request, items: Vec, total: usize, offset: usize, next_base: usize, matched: usize, truncated: bool) -> Value { + output::bounded(items, total, offset, next_base, matched, truncated, json!({}), MAX_OUTPUT_BYTES - ENVELOPE_OVERHEAD, request.output_sink.as_deref().map(|sink| (sink, false))) } fn inspect(request: &Request) -> Result { let args: InspectArgs = serde_json::from_value(request.args.clone())?; - inspect_args(args, MAX_OUTPUT_BYTES, false) + inspect_args(args, MAX_OUTPUT_BYTES, false, request.output_sink.as_deref().map(|sink| (sink, false))) } -fn inspect_args(args: InspectArgs, budget: usize, include_path: bool) -> Result { +fn inspect_args(args: InspectArgs, budget: usize, include_path: bool, capture: Option<(&ops_protocol::capture::OutputSink, bool)>) -> Result { if args.limit == 0 || args.limit > 10_000 { return Err(Failure::new("INVALID_ARGUMENT", "inspect limit is 1-10000")); } if !args.encoding.eq_ignore_ascii_case("utf-8") { return Err(Failure::new("ERR_INVALID_ENCODING", "Text inspection supports UTF-8 only")); } let path = target(&args.path, false)?; @@ -107,8 +107,8 @@ fn inspect_args(args: InspectArgs, budget: usize, include_path: bool) -> Result< // str::lines splits physical LF/CRLF, never bare CR or Unicode separators. // Empty files have zero lines; a final LF does not create a phantom line. let lines: Vec<_> = text.lines().enumerate().skip(args.offset.saturating_sub(1)).take(args.limit).map(|(i, line)| json!({"path":path,"line":i+1,"text":line})).collect(); - let value = output::bounded(lines, text.lines().count(), args.offset.saturating_sub(1), args.offset, text.lines().count(), false, facts, budget); - if output::size(&value) + 1 > budget { + let value = output::bounded(lines, text.lines().count(), args.offset.saturating_sub(1), args.offset, text.lines().count(), false, facts, budget, capture); + if output::size(&value) + 1 > budget && capture.is_none() { return Err(Failure::new("OUTPUT_LIMIT", "File metadata exceeds the remaining output budget")); } Ok(value) diff --git a/native/crates/ops-files/src/file_many.rs b/native/crates/ops-files/src/file_many.rs index 18c7c853..b2aa7044 100644 --- a/native/crates/ops-files/src/file_many.rs +++ b/native/crates/ops-files/src/file_many.rs @@ -30,24 +30,48 @@ pub(super) fn read_many(request: &Request) -> Result { let mut failed = 0; let mut complete = true; let mut truncated = false; + let sink = request.output_sink.as_deref(); + let (mut observed_count, mut source_failed, mut source_complete) = (0, 0, true); + let mut inline_closed = false; for file in args.files { - // Stop before touching another file when no meaningful receipt can fit. - if room < 1024 { break; } + // Once inline output fills, only a bounded capture may observe further files. + if sink.is_some_and(|sink| sink.exhausted()) { break; } + if room < 1024 { if sink.is_none() { break; } inline_closed = true; } let observed = request.authorize_path(Path::new(&file.path)).and_then(|()| { - inspect_args(InspectArgs { path: file.path.clone(), offset: file.offset, limit: file.limit, encoding: utf8(), view: text() }, room.saturating_sub(1), true) + inspect_args(InspectArgs { path: file.path.clone(), offset: file.offset, limit: file.limit, encoding: utf8(), view: text() }, room.saturating_sub(1), true, sink.map(|sink| (sink, true))) }); let item = match observed { Ok(item) => item, - Err(error) => json!({"path":file.path,"status":"error","complete":false,"items":[],"error":error}), + Err(error) => { + let item = json!({"path":file.path,"status":"error","complete":false,"items":[],"error":error}); + if let Some(sink) = sink { sink.push(&item); sink.source(item.clone()); } + item + }, }; + observed_count += 1; + let facts = sink.map_or_else(|| item.clone(), |sink| sink.last_source()); + source_failed += usize::from(facts.get("error").is_some()); source_complete &= facts["complete"] == true; let size = output::size(&item) + 1; - if size > room { break; } // this input remains pending; never discard a receipt silently + if inline_closed || size > room { + if let Some(sink) = sink { sink.clipped(); inline_closed = true; continue; } + break; + } room -= size; if item.get("error").is_some() { failed += 1; } complete &= item["complete"] == true; truncated |= item["truncated"] == true; items.push(item); } + if let Some(sink) = sink { + let full = source_complete && observed_count == total; + let mut source = value.clone(); + source["status"] = json!(if full { "completed" } else { "partial" }); + source["complete"] = json!(full); source["truncated"] = json!(!full); + source["matched"] = json!(observed_count - source_failed); source["failed"] = json!(source_failed); + source["nextIndex"] = json!(observed_count); source["pending"] = json!(total - observed_count); + if full { source.as_object_mut().unwrap().remove("nextAction"); } + sink.source(source); + } let returned = items.len(); complete &= returned == total; value["status"] = json!(if complete { "completed" } else { "partial" }); diff --git a/native/crates/ops-files/src/file_output.rs b/native/crates/ops-files/src/file_output.rs index d5820753..ee4d8e06 100644 --- a/native/crates/ops-files/src/file_output.rs +++ b/native/crates/ops-files/src/file_output.rs @@ -18,16 +18,28 @@ fn prefix(text: &str, budget: usize) -> &str { text } pub(super) fn size(value: &Value) -> usize { - serde_json::to_vec(value).expect("JSON value is serializable").len() + ops_protocol::capture::json_size(value) } /// `offset` counts skipped units; `next_base` is the caller's resumption base. /// The envelope reserves its longest possible flags, counters, and recovery advice. /// Each candidate includes its actual escaped path and truncation metadata before /// text receives any room. No fixed estimate for a path/header can overflow this cap. #[allow(clippy::too_many_arguments)] -pub(super) fn bounded(items: Vec, total: usize, offset: usize, next_base: usize, matched: usize, truncated: bool, facts: Value, budget: usize) -> Value { +pub(super) fn bounded(items: Vec, total: usize, offset: usize, next_base: usize, matched: usize, truncated: bool, facts: Value, budget: usize, capture: Option<(&ops_protocol::capture::OutputSink, bool)>) -> Value { let mut value = json!({"status":"completed","phase":"verify","changed":0,"matched":matched,"skipped":offset,"failed":0,"complete":false,"truncated":false,"omitted":total,"items":[],"total":total,"nextOffset":next_base.saturating_add(items.len()),"nextAction":HEX_RECOVERY}); value.as_object_mut().expect("result object").extend(facts.as_object().expect("facts object").clone()); + let mut items = items; + if let Some((sink, whole_file)) = capture { + let mut source = value.clone(); + source["complete"] = json!(!truncated && offset.saturating_add(items.len()) >= total); + source["truncated"] = json!(truncated); source["omitted"] = json!(total.saturating_sub(offset.saturating_add(items.len()))); + source.as_object_mut().unwrap().remove("nextAction"); + if whole_file { + source["items"] = Value::Array(items); sink.push(&source); + items = match source.as_object_mut().unwrap().remove("items").unwrap() { Value::Array(items) => items, _ => unreachable!() }; + } else { for item in &items { sink.push(item); } } + sink.source(source); + } let mut room = budget.saturating_sub(size(&value) + 1); // trailing worker newline let mut kept = Vec::new(); let mut stopped = false; @@ -53,6 +65,7 @@ pub(super) fn bounded(items: Vec, total: usize, offset: usize, next_base: break; } let consumed = kept.len().saturating_sub(usize::from(incomplete_line)); + if stopped && let Some((sink, _)) = capture { sink.clipped(); } let cut = truncated || stopped; value["complete"] = json!(!cut && offset.saturating_add(consumed) >= total); value["truncated"] = json!(cut); diff --git a/native/crates/ops-files/src/file_single.rs b/native/crates/ops-files/src/file_single.rs index 5b8fb197..6632da97 100644 --- a/native/crates/ops-files/src/file_single.rs +++ b/native/crates/ops-files/src/file_single.rs @@ -284,7 +284,7 @@ fn diff_file(request: &Request) -> Result { .skip(args.offset) .take(args.limit) .collect(); - let mut output = result(items, total, args.offset, args.offset, total, false); + let mut output = result(request, items, total, args.offset, args.offset, total, false); output["before"] = content_facts(&bytes); output["after"] = content_facts(args.content.as_bytes()); output["hunk"] = json!({"startLine":prefix+1,"removed":removed.len(),"added":added.len()}); diff --git a/native/crates/ops-protocol/src/capture.rs b/native/crates/ops-protocol/src/capture.rs new file mode 100644 index 00000000..1ee164c4 --- /dev/null +++ b/native/crates/ops-protocol/src/capture.rs @@ -0,0 +1,83 @@ +//! Bounded pre-display capture shared by independent kernel crates. No filesystem authority lives here. +use crate::Failure; +use serde_json::{Value, json}; +use std::{cell::RefCell, io::{self, Write}}; +const CAPTURE_BYTES: usize = 10 * 1024 * 1024 - 32768; +const CHUNK_BYTES: usize = 2048; +#[derive(Default)] +pub struct Captured { + pub records: Vec, pub source: Value, pub logical_items: usize, + pub observed_items: usize, pub clipped: bool, pub error: Option, bytes: usize, +} +#[derive(Default)] +pub struct OutputSink(RefCell); +impl OutputSink { + pub fn push(&self, item: &Value) { + let mut state = self.0.borrow_mut(); state.observed_items += 1; + if state.error.is_some() { return; } + let (start, bytes, index) = (state.records.len(), state.bytes, state.logical_items); + let saved = if json_size(item) <= 4096 { + state.record(json!({"kind":"item","index":index,"value":item})) + } else { + let mut writer = ChunkWriter { state: &mut state, index, chunk: 0, offset: 0, pending: Vec::new() }; + serde_json::to_writer(&mut writer, item).map_err(io::Error::other).and_then(|()| writer.finish()) + }; + if saved.is_err() { + state.records.truncate(start); state.bytes = bytes; + state.error = Some(Failure::new("QUOTA_EXCEEDED", "Output capture reached the 10 MiB snapshot budget; only preceding whole logical items were retained")); + } else { state.logical_items += 1; } + } + pub fn source(&self, mut source: Value) { + if let Some(object) = source.as_object_mut() { object.remove("items"); } + self.0.borrow_mut().source = source; + } + pub fn last_source(&self) -> Value { self.0.borrow().source.clone() } + pub fn clipped(&self) { self.0.borrow_mut().clipped = true; } + pub fn exhausted(&self) -> bool { self.0.borrow().error.is_some() } + pub fn take(&self) -> Captured { std::mem::take(&mut *self.0.borrow_mut()) } +} +impl Captured { + fn record(&mut self, value: Value) -> io::Result<()> { + let cost = json_size(&value) + 1; + if self.bytes.saturating_add(cost) > CAPTURE_BYTES { return Err(io::Error::other("capture quota")); } + self.bytes += cost; self.records.push(value); Ok(()) + } +} +struct ChunkWriter<'a> { state: &'a mut Captured, index: usize, chunk: usize, offset: usize, pending: Vec } +impl ChunkWriter<'_> { + fn emit(&mut self) -> io::Result<()> { + let end = match std::str::from_utf8(&self.pending) { Ok(_) => self.pending.len(), Err(error) => error.valid_up_to() }; + if end == 0 { return Ok(()); } + let text = std::str::from_utf8(&self.pending[..end]).map_err(io::Error::other)?.to_owned(); + self.state.record(json!({"kind":"item-json-chunk","index":self.index,"chunk":self.chunk,"byteOffset":self.offset,"text":text,"complete":false}))?; + self.offset += end; self.chunk += 1; self.pending.drain(..end); Ok(()) + } + fn finish(&mut self) -> io::Result<()> { + self.emit()?; + if !self.pending.is_empty() || self.chunk == 0 { return Err(io::Error::other("incomplete JSON encoding")); } + self.state.records.last_mut().unwrap()["complete"] = json!(true); + self.state.bytes -= 1; // true is one serialized byte shorter than false. + Ok(()) + } +} +impl Write for ChunkWriter<'_> { + fn write(&mut self, mut bytes: &[u8]) -> io::Result { + let total = bytes.len(); + while !bytes.is_empty() { + let count = (CHUNK_BYTES - self.pending.len()).min(bytes.len()); + self.pending.extend_from_slice(&bytes[..count]); bytes = &bytes[count..]; + if self.pending.len() == CHUNK_BYTES { self.emit()?; } + } + Ok(total) + } + fn flush(&mut self) -> io::Result<()> { Ok(()) } +} +/// Count escaped JSON bytes without allocating a potentially huge encoded string. +pub fn json_size(value: &Value) -> usize { + struct Counter(usize); + impl Write for Counter { + fn write(&mut self, bytes: &[u8]) -> io::Result { self.0 = self.0.saturating_add(bytes.len()); Ok(bytes.len()) } + fn flush(&mut self) -> io::Result<()> { Ok(()) } + } + let mut count = Counter(0); serde_json::to_writer(&mut count, value).expect("JSON value serializes"); count.0 +} diff --git a/native/crates/ops-protocol/src/lib.rs b/native/crates/ops-protocol/src/lib.rs index cd4ca042..33798a8a 100644 --- a/native/crates/ops-protocol/src/lib.rs +++ b/native/crates/ops-protocol/src/lib.rs @@ -1,4 +1,5 @@ #![forbid(unsafe_code)] +pub mod capture; pub mod envelope; pub mod search; pub mod storage; @@ -152,6 +153,10 @@ pub type Result = std::result::Result; #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub struct Request { + #[serde(skip)] + pub output_sink: Option>, + #[serde(skip)] + pub output_budget: usize, /// Trusted envelope metadata; never accepted from the tool argument/request data plane. #[serde(skip)] pub storage: Option, diff --git a/native/crates/ops-results/src/lib.rs b/native/crates/ops-results/src/lib.rs index 06a793d7..4a3a48ae 100644 --- a/native/crates/ops-results/src/lib.rs +++ b/native/crates/ops-results/src/lib.rs @@ -6,5 +6,6 @@ use ops_files::sync_directory; pub mod store; pub mod cache; pub mod references; +pub mod spill; mod results; pub use results::{run, referenced_items}; diff --git a/native/crates/ops-results/src/references.rs b/native/crates/ops-results/src/references.rs index e269f5ad..6f98c315 100644 --- a/native/crates/ops-results/src/references.rs +++ b/native/crates/ops-results/src/references.rs @@ -11,6 +11,8 @@ struct Record { stored_bytes: usize, version: u32, scope: String, root: String, root_identity: String, authority_id: String, producer_tool: String, created_at: u64, ttl_ms: u64, items: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + source: Option, } fn now_ms() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64 } fn segment(text: &str) -> String { @@ -93,7 +95,7 @@ pub fn run(request: &Request) -> Result { if request.read_only { return Err(Failure::policy("read-only", "Read-only calls cannot create references")); } let id = uuid::Uuid::new_v4().to_string(); let record = Record { stored_bytes: 0, version: 1, scope: request.scope.clone(), root: request.root.clone(), root_identity: request.root_identity.clone(), - authority_id: context.authority_id.clone(), producer_tool: request.tool.clone(), created_at: now_ms(), ttl_ms, items }; + authority_id: context.authority_id.clone(), producer_tool: request.tool.clone(), created_at: now_ms(), ttl_ms, items, source: None }; let reference = record.reference(&id)?; Cache::open(request)?.save(&id, &record)?; return Ok(json!({"status":"completed","id":reference.ref_id,"reference":reference,"total":record.items.len(),"complete":false,"omitted":record.items.len(),"nextAction":"Page this reference; it grants no authority. Reference pages never spill into another reference."})); @@ -124,6 +126,7 @@ fn page(record: &Record, args: &ResultArgs, id: &str) -> Result { let rows: Vec<_> = record.items.iter().filter(|item| args.equals.as_ref().is_none_or(|filter| filter.iter().all(|(key, value)| item.get(key) == Some(value)))).collect(); let total = rows.len(); let start = args.offset.min(total); let mut value = json!({"id":reference.ref_id,"reference":reference,"items":[],"sourceTotal":record.items.len(),"contentHash":format!("sha256:{:x}", Sha256::digest(serde_json::to_vec(&record.items)?)),"total":total,"nextOffset":total,"complete":false,"omitted":total}); + if let Some(source) = &record.source { value["source"] = source.clone(); } let header = serde_json::to_vec(&value)?.len() + 1; if header > budget { return Err(Failure::new("OUTPUT_LIMIT", "Reference metadata exceeds page budget; increase maxBytes. A reference page never creates another reference.")); } let mut room = budget - header; let mut items = Vec::new(); @@ -143,3 +146,17 @@ fn page(record: &Record, args: &ResultArgs, id: &str) -> Result { value["omitted"] = json!(total - next); value["complete"] = json!(next == total); Ok(value) } + +/// Store already bounded canonical capture. Only the trusted request supplies cache authority. +pub fn store_snapshot(request: &Request, items: Vec, source: Value) -> Result { + if request.read_only { return Err(Failure::policy("read-only", "Read-only calls cannot create references")); } + let context = request.storage.as_ref().ok_or_else(|| Failure::new("POLICY_DENIED", "Trusted storage context is required"))?; + context.authorize(&request.tool)?; + if items.iter().any(nested_reference) { return Err(Failure::new("INVALID_ARGUMENT", "Reference handles cannot be captured inside another reference")); } + let id = uuid::Uuid::new_v4().to_string(); + let record = Record { stored_bytes: 0, version: 1, scope: request.scope.clone(), root: request.root.clone(), root_identity: request.root_identity.clone(), + authority_id: context.authority_id.clone(), producer_tool: request.tool.clone(), created_at: now_ms(), ttl_ms: 0, items, source: Some(source) }; + let reference = record.reference(&id)?; + Cache::open(request)?.save(&id, &record)?; + Ok(reference) +} diff --git a/native/crates/ops-results/src/spill.rs b/native/crates/ops-results/src/spill.rs new file mode 100644 index 00000000..0f5b9553 --- /dev/null +++ b/native/crates/ops-results/src/spill.rs @@ -0,0 +1,139 @@ +//! Preserve operation facts independently of cache effects and bounded inline presentation. +use crate::{contract::*, references}; +use ops_protocol::capture::{OutputSink, json_size}; +use serde_json::{Value, json}; +const RECOVERY: &str = "Page reference.refId with root=referenceRoot. Reconstruct tagged-json-v1 records in index/chunk order; page.complete describes stored records only. Check source.captureComplete and original coverage; narrow the query if capture is incomplete."; +fn clipped(value: &Value) -> bool { + value.get("truncatedInLine") == Some(&json!(true)) || value.get("truncatedBy") == Some(&json!("output-budget")) + || value.get("items").and_then(Value::as_array).is_some_and(|items| items.iter().any(clipped)) +} +fn storage_error(error: &Failure) -> Value { + json!({"code":error.code,"message":error.message.chars().take(256).collect::(),"effectState":error.effect_state}) +} +fn mark_head(request: &Request, value: &mut Value) { + value["complete"] = json!(false); value["truncated"] = json!(true); + if value.get("pageComplete").is_some() { + value["pageComplete"] = json!(false); + if value["scanComplete"] == true { value["truncatedBy"] = json!("output-budget"); } + } + let skipped = if request.tool == "replace" { 0 } else { value["skipped"].as_u64().unwrap_or(0) }; + if let Some(total) = value["total"].as_u64() { value["omitted"] = json!(total.saturating_sub(skipped)); } + if value.get("nextOffset").is_some() { + value["nextOffset"] = json!(if request.tool == "inspect_local_file" { + if request.args["view"] == "hex" { skipped / 16 + 1 } else { skipped + 1 } + } else { skipped }); + } + if value.get("nextIndex").is_some() { value["nextIndex"] = json!(0); value["pending"] = value["total"].clone(); } + value.as_object_mut().unwrap().remove("continuation"); +} +/// Reduce a useful first item only. Its suffix remains recoverable from the snapshot. +fn shrink(value: &mut Value) -> bool { + if let Some(text) = value.get("text").and_then(Value::as_str) { + if text.is_empty() { return false; } + let original = value.get("bytesTotal").and_then(Value::as_u64).unwrap_or(text.len() as u64); + let mut end = text.len() / 2; while !text.is_char_boundary(end) { end -= 1; } + let prefix = text[..end].to_owned(); + value["text"] = json!(prefix); value["truncatedInLine"] = json!(true); + value["bytesTotal"] = json!(original); value["bytesOmitted"] = json!(original - end as u64); + return true; + } + if let Some(items) = value.get_mut("items").and_then(Value::as_array_mut) { + if items.len() > 1 { items.pop(); } + else if !items.is_empty() && shrink(&mut items[0]) {} else { return false; } + value["complete"] = json!(false); value["truncated"] = json!(true); + let start = value["skipped"].as_u64().unwrap_or(0) + 1; + if value.get("nextOffset").is_some() { value["nextOffset"] = json!(start); } + return true; + } + false +} +fn fit(value: &mut Value, budget: usize) { + let candidates = value.get_mut("items").and_then(Value::as_array_mut).map(std::mem::take).unwrap_or_default(); + let mut kept = Vec::new(); + for mut item in candidates { + value["items"] = json!(&kept); + let room = budget.saturating_sub(json_size(value) + 2); + let mut partial = false; + while kept.is_empty() && json_size(&item) > room && shrink(&mut item) { partial = true; } + if json_size(&item) > room { break; } + kept.push(item); + if partial { break; } + } + value["items"] = json!(kept); +} +pub fn finish(request: &Request, mut value: Value) -> Value { + // A reference page/handle never recursively becomes another reference. Legacy callers retain their transport. + if request.tool == "result" || request.storage.is_none() || value.get("reference").is_some() { return value; } + let local = OutputSink::default(); let sink = request.output_sink.as_deref().unwrap_or(&local); + let mut capture = sink.take(); + let fallback_clipped = capture.observed_items == 0 && clipped(&value); + if capture.observed_items == 0 && !request.read_only { + if let Some(items) = value.get("items").and_then(Value::as_array) { for item in items { sink.push(item); } } + capture = sink.take(); + } + let budget = if request.output_budget == 0 { ops_protocol::envelope::MAX_OUTPUT_BYTES } else { request.output_budget }; + let needs_spill = capture.clipped || capture.error.is_some() || clipped(&value) || json_size(&value) + 1 > budget + || (request.tool == "read_many" && value["pending"].as_u64().unwrap_or(0) > 0); + if !needs_spill { return value; } + let mut source = value.clone(); source.as_object_mut().unwrap().remove("items"); + // Only presentation fields are restored; post-execution mutation receipts remain authoritative. + for key in ["complete", "truncated", "nextOffset", "nextIndex", "pending", "omitted", "total", "matched", "failed", "pageComplete", "truncatedBy", "continuation", "nextAction"] { + if let Some(field) = capture.source.get(key) { source[key] = field.clone(); } + else if matches!(key, "continuation" | "nextAction") && capture.source.is_object() { source.as_object_mut().unwrap().remove(key); } + } + if request.tool == "read_many" && capture.source.is_object() { + source["status"] = capture.source["status"].clone(); + for key in ["status", "matched", "failed"] { value[key] = source[key].clone(); } + } + source["root"] = json!(request.root); source["itemEncoding"] = json!("tagged-json-v1"); + source["capturedItems"] = json!(capture.logical_items); source["captureComplete"] = json!(capture.error.is_none() && !request.read_only && !fallback_clipped); + mark_head(request, &mut value); + value["nextAction"] = json!(RECOVERY); + let mut error = capture.error; + // Bound the initial handle before creating any persistent blob, including long root/session metadata. + let mut probe = value.clone(); probe["items"] = json!([]); + probe["referenceRoot"] = json!(request.root); + probe["reference"] = json!({"refId":format!("ref://{}/{}/00000000-0000-0000-0000-000000000000", request.scope.as_bytes().iter().map(|b| format!("%{b:02X}")).collect::(), request.tool),"createdAt":u64::MAX,"ttlMs":0,"schemaVersion":"1","producerTool":request.tool,"totalItems":usize::MAX,"totalBytes":10485760,"preview":""}); + if let Some(error) = &error { probe["storageError"] = storage_error(error); } + if request.read_only { error = Some(Failure::policy("read-only", "Read-only observation cannot spill into reference storage")); } + else if json_size(&probe) + 1 > budget { error = Some(Failure::new("OUTPUT_LIMIT", "Reference metadata exceeds maxBytes; narrow the query or increase the output budget")); } + else if !capture.records.is_empty() { + match references::store_snapshot(request, capture.records, source) { + Ok(mut reference) => { + reference.preview.clear(); // The bounded head already supplies the preview. + value["reference"] = json!(reference); value["referenceRoot"] = json!(request.root); + if let Some(error) = &mut error { error.effect_state = "applied".into(); } + }, + Err(failure) => error = Some(failure), + } + } + if value.get("reference").is_none() && error.is_none() { + error = Some(Failure::new("OUTPUT_LIMIT", "No complete logical output item could be captured")); + } + if let Some(error) = error { value["storageError"] = storage_error(&error); } + if value.get("reference").is_none() { + value["nextAction"] = json!("Output is incomplete and has no stored continuation. Narrow the requested range or use bounded hex inspection for a long line; do not repeat mutations; inspect postconditions. storageError describes cache effects only."); + } + fit(&mut value, budget); + value +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn fallback_capture_never_promotes_an_already_clipped_item_to_complete() { + let root = std::env::temp_dir().join(format!("dsh-ops-spill-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&root).unwrap(); + let mut request: Request = serde_json::from_value(json!({"root":root,"scope":"fallback-test","tool":"future_tool","args":{}})).unwrap(); + request.storage = Some(serde_json::from_value(json!({"root":root,"authorityId":"a".repeat(64),"allowedTools":["future_tool","result"]})).unwrap()); + let head = finish(&request, json!({"status":"completed","complete":false,"items":[{"text":"prefix","truncatedInLine":true}]})); + let id = head["reference"]["refId"].as_str().expect("bounded fallback reference"); + request.tool = "result".into(); request.args = json!({"action":"page","id":id}); + let page = references::run(&request).unwrap(); + assert_eq!(page["source"]["captureComplete"], false); + assert_eq!(page["items"][0]["value"]["text"], "prefix"); + assert_eq!(page["source"]["complete"], false); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/native/crates/ops-search/src/lib.rs b/native/crates/ops-search/src/lib.rs index 5b77a0b4..395a9d9a 100644 --- a/native/crates/ops-search/src/lib.rs +++ b/native/crates/ops-search/src/lib.rs @@ -207,7 +207,7 @@ fn replace(request: &Request) -> Result { } changed += item["replacements"].as_u64().unwrap_or(0) as usize; } let count = items.len(); - let mut output = result(items, count, 0, 0, total, walked.truncated); + let mut output = result(request, items, count, 0, 0, total, walked.truncated); output["changed"] = json!(changed); output["skipped"] = json!(skipped); // Do not let optional skipped path names defeat the serialized receipt budget. output["skippedPaths"] = if serde_json::to_vec(&output)?.len() < MAX_OUTPUT_BYTES / 2 { json!(skipped_paths) } else { json!([]) }; diff --git a/native/crates/ops-search/src/paging.rs b/native/crates/ops-search/src/paging.rs index acb0dfe5..f0d243fe 100644 --- a/native/crates/ops-search/src/paging.rs +++ b/native/crates/ops-search/src/paging.rs @@ -40,21 +40,24 @@ fn stale() -> Failure { } pub struct Page { pub items: Vec, pub offset: usize, limit: usize, bytes: usize, - pub output_cut: bool, partial_item: bool, + pub output_cut: bool, partial_item: bool, capture: Option>, } impl Page { - pub fn new(offset: usize, limit: usize) -> Self { - Self { items: Vec::new(), offset, limit, bytes: 0, output_cut: false, partial_item: false } + pub fn new(offset: usize, limit: usize, capture: Option>) -> Self { + Self { items: Vec::new(), offset, limit, bytes: 0, output_cut: false, partial_item: false, capture } } pub fn wants(&self, ordinal: usize) -> bool { - !self.output_cut && ordinal >= self.offset && ordinal < self.offset.saturating_add(self.limit) + (!self.output_cut || self.capture.as_ref().is_some_and(|sink| !sink.exhausted())) && ordinal >= self.offset && ordinal < self.offset.saturating_add(self.limit) } pub fn push(&mut self, mut item: Value, ordinal: usize) -> Result<()> { if !self.wants(ordinal) { return Ok(()); } + if let Some(sink) = &self.capture { sink.push(&item); } + if self.output_cut { return Ok(()); } let remaining = MAX_OUTPUT_BYTES.saturating_sub(8192 + self.bytes); - let size = serde_json::to_vec(&item)?.len() + 1; + let size = ops_protocol::capture::json_size(&item) + 1; if size <= remaining { self.items.push(item); self.bytes += size; return Ok(()); } self.output_cut = true; + if let Some(sink) = &self.capture { sink.clipped(); } // Preserve a useful first prefix without pretending the remaining line was delivered. if let Some(text) = item.get("text").and_then(Value::as_str).map(str::to_owned) { let length = text.len(); let mut end = length.min(remaining); @@ -62,7 +65,7 @@ impl Page { while !text.is_char_boundary(end) { end -= 1; } item["text"] = json!(&text[..end]); item["truncatedInLine"] = json!(true); item["bytesTotal"] = json!(length); item["bytesOmitted"] = json!(length - end); - if serde_json::to_vec(&item)?.len() + 1 <= remaining { + if ops_protocol::capture::json_size(&item) + 1 <= remaining { self.items.push(item); self.partial_item = true; break; } end /= 2; @@ -92,5 +95,20 @@ pub fn finish(mut scan: Scan, cursor: Cursor, page: Page, observed: usize, item_ else if page.partial_item { output["nextAction"] = json!("A matching line is only partially displayed. Inspect that file with bounded text/hex ranges; nextOffset does not skip the undisplayed suffix."); } output.as_object_mut().unwrap().extend(serde_json::to_value(report)?.as_object().unwrap().clone()); if let Some(extra) = extra.as_object() { output.as_object_mut().unwrap().extend(extra.clone()); } + if let Some(sink) = page.capture { + let mut source = output.clone(); source.as_object_mut().unwrap().remove("items"); + let source_next = page.offset.saturating_add(page.limit).min(item_total).max(page.offset); + let more = source_next < item_total; + source["nextOffset"] = json!(source_next); source["pageComplete"] = json!(true); + source["complete"] = json!(complete && !more); source["truncated"] = json!(!complete || more); + source["truncatedBy"] = serde_json::to_value(scan.stopped.unwrap_or(if more { TruncatedBy::RowLimit } else { TruncatedBy::None }))?; + source.as_object_mut().unwrap().remove("continuation"); + if complete { + source["omitted"] = json!(item_total.saturating_sub(source_next)); + source.as_object_mut().unwrap().remove("nextAction"); + if more && source_next > page.offset { source["continuation"] = json!(cursor.token(&snapshot, source_next)); } + } + sink.source(source); + } Ok(output) } diff --git a/native/crates/ops-search/src/scan.rs b/native/crates/ops-search/src/scan.rs index bde0e9fb..87ee58bf 100644 --- a/native/crates/ops-search/src/scan.rs +++ b/native/crates/ops-search/src/scan.rs @@ -148,7 +148,7 @@ mod tests { assert_eq!(paths.len(), 2); assert_eq!(scan.scanned, 2); assert!(!scan.complete()); assert_eq!(scan.stopped, Some(TruncatedBy::ScanBudget)); let report = super::super::paging::finish(scan, super::super::paging::Cursor::new(&request, 0, None).unwrap(), - super::super::paging::Page::new(0, 1), paths.len(), paths.len(), json!({})).unwrap(); + super::super::paging::Page::new(0, 1, None), paths.len(), paths.len(), json!({})).unwrap(); assert_eq!(report["scanComplete"], false); assert!(report.get("matchedTotal").is_none()); assert_eq!(report["truncatedBy"], "scan-budget"); assert!(report.get("continuation").is_none()); assert!(report["nextAction"].as_str().unwrap().contains("Narrow")); diff --git a/native/crates/ops-search/src/search.rs b/native/crates/ops-search/src/search.rs index 5e6a0610..5792061d 100644 --- a/native/crates/ops-search/src/search.rs +++ b/native/crates/ops-search/src/search.rs @@ -43,7 +43,7 @@ pub fn glob(request: &Request) -> Result { files.push((file.to_path_buf(), modified, metadata.len())); Ok(()) })?; if args.sort == "modified" { files.sort_by_key(|(_, modified, _)| std::cmp::Reverse(*modified)); } - let total = files.len(); let mut page = Page::new(cursor.offset, args.limit); + let total = files.len(); let mut page = Page::new(cursor.offset, args.limit, request.output_sink.clone()); for (ordinal, (file, modified, bytes)) in files.into_iter().enumerate() { if page.wants(ordinal) { page.push(if args.output_mode == "details" { json!({"path":file,"bytes":bytes,"modified":modified}) } else { json!(file) }, ordinal)?; } } @@ -72,7 +72,7 @@ pub fn grep(request: &Request) -> Result { let regex = RegexBuilder::new(&args.pattern).case_insensitive(args.case_insensitive).multi_line(args.multiline).build().map_err(|error| Failure::new("INVALID_ARGUMENT", error))?; let root = target(&args.path, false)?; let cursor = Cursor::new(request, args.offset, args.continuation.as_deref())?; - let mut scan = Scan::new(); let mut page = Page::new(cursor.offset, args.limit); + let mut scan = Scan::new(); let mut page = Page::new(cursor.offset, args.limit, request.output_sink.clone()); let mut file_count = 0; let mut match_count = 0; let content = matches!(args.output_mode.as_str(), "" | "content"); scan.walk(&root, &args.glob, request, |file, _, scan| { diff --git a/native/src/lib.rs b/native/src/lib.rs index aec212de..f5939863 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -22,21 +22,27 @@ pub fn dispatch(request: Request) -> Result { .into(), ..request }; + let dir = Dir::open_ambient_dir(&root, ambient_authority())?; + use cap_fs_ext::MetadataExt; + let metadata = dir.dir_metadata()?; + request.root_identity = format!("{}:{}", metadata.dev(), metadata.ino()); + if request.tool != "result" && request.storage.is_some() && !request.read_only { + request.output_sink = Some(std::rc::Rc::new(contract::capture::OutputSink::default())); + } + let value = execute(&dir, &request)?; + Ok(ops_results::spill::finish(&request, value)) +} +fn execute(dir: &Dir, request: &Request) -> Result { // File reads/searches do not create or consult the durable audit store. // This is important for read-only sessions and keeps observation separate // from mutation audit records. if matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "glob" | "grep" | "replace" | "write_file" | "edit_file" | "edit_many" | "diff" | "stat" | "copy" | "move" | "remove") { - request.root_identity = String::new(); return if matches!(request.tool.as_str(), "glob" | "grep" | "replace") { ops_search::dispatch(&request) } else { ops_files::dispatch(&request) }; } - let dir = Dir::open_ambient_dir(root, ambient_authority())?; - use cap_fs_ext::MetadataExt; - let metadata = dir.dir_metadata()?; - request.root_identity = format!("{}:{}", metadata.dev(), metadata.ino()); if request.tool == "result" && ((request.storage.is_some() && request.args.get("action").and_then(Value::as_str) == Some("put")) || request.args.get("action").and_then(Value::as_str) == Some("purge") || request.args.get("id").and_then(Value::as_str).is_some_and(|id| id.starts_with("ref://"))) { diff --git a/native/src/main.rs b/native/src/main.rs index ea400a86..f4c28f3b 100644 --- a/native/src/main.rs +++ b/native/src/main.rs @@ -18,8 +18,9 @@ fn main() { let mut bytes = Vec::new(); std::io::stdin().take((envelope::MAX_REQUEST_BYTES + 1) as u64) .read_to_end(&mut bytes).map_err(Failure::from)?; - let decoded = envelope::decode(&bytes)?; + let mut decoded = envelope::decode(&bytes)?; budget = decoded.budget; + decoded.request.output_budget = budget.max_bytes; mutating = may_mutate(&decoded.request); // The host also owns cancellation. This process-local deadline covers direct callers; // interruption exits nonzero so the bridge reports unknown effects, never completion. diff --git a/test/result-spill.test.mjs b/test/result-spill.test.mjs new file mode 100644 index 00000000..1ffc7d2b --- /dev/null +++ b/test/result-spill.test.mjs @@ -0,0 +1,195 @@ +/** Real-worker spill boundaries: exact recovery, coverage, effects, and independent cache authority. */ +import fs from 'node:fs' +import path from 'node:path' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { callWorker, request, envelopeOf, requireWorker } from './lib/boundary.mjs' +requireWorker('result-spill') +const tools = ['inspect_local_file', 'read_many', 'grep', 'glob', 'diff', 'replace', 'result'] +const fixture = body => withTempDir('spill', async root => { + const cache = path.join(root, 'cache'); const work = path.join(root, 'work') + fs.mkdirSync(cache, { mode: 0o700 }); fs.mkdirSync(work); await body(work, cache) +}) +const input = (root, cache, tool, args, options = {}) => ({ version: 1, requestId: 'spill-test', component: 'file', + budget: { timeoutMs: 30000, maxBytes: options.maxBytes ?? 4096 }, + storage: { root: cache, authorityId: 'a'.repeat(64), allowedTools: tools }, + request: { ...request(tool, args, { root, scope: 'spill-session', writeRoots: [root], readOnly: options.readOnly }), ...options.authority } }) +async function send(value) { + const raw = await callWorker(JSON.stringify(value)); const output = envelopeOf(raw, assert) + assert.ok(Buffer.byteLength(raw.stdout) <= value.budget.maxBytes, raw.stdout.slice(0, 256)); return output +} +const call = (root, cache, tool, args, options) => send(input(root, cache, tool, args, options)) +function reconstruct(records) { + const items = []; let chunks = [], bytes = 0 + for (const row of records) { + assert.equal(row.index, items.length, 'logical index is contiguous') + if (row.kind === 'item') { assert.equal(chunks.length, 0); items.push(row.value); continue } + assert.equal(row.kind, 'item-json-chunk'); assert.equal(row.chunk, chunks.length) + assert.equal(row.byteOffset, bytes); chunks.push(row.text); bytes += Buffer.byteLength(row.text) + if (row.complete) { items.push(JSON.parse(chunks.join(''))); chunks = []; bytes = 0 } + } + assert.equal(chunks.length, 0, 'no unfinished logical JSON item can be stored'); return items +} +async function collect(root, cache, handle, options = {}) { + assert.match(handle.reference?.refId ?? '', /^ref:\/\/spill-session\//) + assert.equal(fs.realpathSync.native(handle.referenceRoot), fs.realpathSync.native(root)) + const records = []; let offset = 0, source + do { + const page = await call(handle.referenceRoot, cache, 'result', { action: 'page', id: handle.reference.refId, offset, limit: 512, maxBytes: 65536 }, { maxBytes: 131072, ...options }) + assert.equal(page.storageError, undefined); assert.ok(page.items?.length > 0, JSON.stringify(page)) + assert.equal(page.source.itemEncoding, 'tagged-json-v1'); assert.equal(page.reference.refId, handle.reference.refId) + assert.equal(page.nextOffset, offset + page.items.length); records.push(...page.items); offset = page.nextOffset + if (source) assert.deepEqual(page.source, source); source = page.source + if (page.complete) break + assert.ok(offset < handle.reference.totalItems) + } while (true) + assert.equal(records.length, handle.reference.totalItems) + const items = reconstruct(records); assert.equal(items.length, source.capturedItems) + return { items, source, records } +} +await test('oversized escaped UTF-8 lines recover every byte after inline clipping, including readonly paging', async () => { + await fixture(async (root, cache) => { + const text = '中😀\0"\\'.repeat(12000) + 'LAST-SUFFIX' + const file = path.join(root, 'large.txt'); fs.writeFileSync(file, text) + const head = await call(root, cache, 'inspect_local_file', { path: file }) + assert.equal(head.status, 'completed'); assert.equal(head.complete, false); assert.equal(head.truncated, true) + assert.ok(head.items[0].text.length > 0); assert.equal(head.items[0].truncatedInLine, true) + assert.equal(head.nextOffset, 1); assert.equal(head.storageError, undefined) + const page = await collect(root, cache, head, { readOnly: true }) + assert.equal(page.source.complete, true); assert.equal(page.source.captureComplete, true) + assert.equal(page.items.length, 1); assert.equal(page.items[0].text, text) + assert.ok(page.records.length > 1); assert.equal(page.items[0].truncatedInLine, undefined) + const other = path.join(root, 'other'); fs.mkdirSync(other) + const wrong = await call(other, cache, 'result', { action: 'page', id: head.reference.refId }) + assert.equal(wrong.error.code, 'POLICY_DENIED') + const narrowed = input(root, cache, 'result', { action: 'page', id: head.reference.refId }); narrowed.storage.allowedTools = ['result'] + assert.equal((await send(narrowed)).error.code, 'POLICY_DENIED') + }) +}) +await test('read_many captures all canonical files and failures beyond the inline prefix', async () => { + await fixture(async (root, cache) => { + const contents = ['huge'.repeat(40000), 'one\r\ntwo\nthree', '', null] + const files = contents.map((text, index) => { const file = path.join(root, `${index}.txt`); if (text !== null) fs.writeFileSync(file, text); return { path: file, ...(index === 1 ? { offset: 2, limit: 1 } : {}) } }) + const head = await call(root, cache, 'read_many', { files }); const page = await collect(root, cache, head) + assert.equal(head.failed, 1); assert.equal(head.matched, 3) + assert.equal(page.items.length, 4); assert.equal(page.source.failed, 1); assert.equal(page.source.pending, 0) + assert.equal(page.source.status, 'partial'); assert.equal(page.source.complete, false); assert.equal(page.source.captureComplete, true) + assert.equal(page.items[0].items[0].text, contents[0]); assert.equal(page.items[0].complete, true) + assert.equal(page.items[1].items[0].text, 'two'); assert.equal(page.items[1].items[0].line, 2) + assert.equal(page.items[1].nextOffset, 3); assert.equal(page.items[1].complete, false) + assert.equal(page.items[2].lineCount, 0); assert.equal(page.items[2].complete, true) + assert.equal(page.items[3].error.code, 'NOT_FOUND'); assert.equal(page.items[3].complete, false) + }) +}) +await test('search snapshots preserve incomplete traversal facts and stay frozen after file changes', async () => { + await fixture(async (root, cache) => { + const text = 'needle '.repeat(20000); const file = path.join(root, 'a.txt'); fs.writeFileSync(file, text) + fs.writeFileSync(path.join(root, 'bad.bin'), Buffer.from([0xff])) + const head = await call(root, cache, 'grep', { path: root, pattern: 'needle', limit: 10 }) + assert.equal(head.scanComplete, false); assert.equal(head.matchedTotal, undefined) + fs.writeFileSync(file, 'changed after observation') + const page = await collect(root, cache, head) + assert.equal(page.source.scanComplete, false); assert.equal(page.source.matchedTotal, undefined) + assert.equal(page.source.skipReasons['invalid-encoding'], 1); assert.equal(page.source.captureComplete, true) + assert.equal(page.items[0].text, text); assert.match(page.source.nextAction, /Narrow/) + }) +}) +await test('a complete search spills only its requested page and retains honest row-limit continuation', async () => { + await fixture(async (root, cache) => { + fs.writeFileSync(path.join(root, 'many.txt'), Array.from({ length: 8 }, (_, i) => `needle${i} ${'x'.repeat(2500)}`).join('\n')) + const head = await call(root, cache, 'grep', { path: root, pattern: 'needle', offset: 2, limit: 3 }) + const page = await collect(root, cache, head) + assert.equal(head.truncatedBy, 'output-budget'); assert.equal(head.pageComplete, false) + assert.equal(page.source.scanComplete, true); assert.equal(page.source.matchedTotal, 8) + assert.equal(page.source.complete, false); assert.equal(page.source.pageComplete, true) + assert.equal(page.source.nextOffset, 5); assert.equal(page.source.truncatedBy, 'row-limit') + assert.ok(page.source.continuation); assert.deepEqual(page.items.map(item => item.line), [3, 4, 5]) + }) +}) +await test('10 MiB capture quota keeps preceding complete items and never stores a partial JSON item', async () => { + await fixture(async (root, cache) => { + fs.writeFileSync(path.join(root, 'huge.txt'), `prefix\n${'q'.repeat(11 * 1024 * 1024)}\nafter`) + const head = await call(root, cache, 'inspect_local_file', { path: path.join(root, 'huge.txt') }) + assert.equal(head.storageError.code, 'QUOTA_EXCEEDED'); assert.equal(head.storageError.effectState, 'applied') + assert.ok(head.reference.totalBytes < 10 * 1024 * 1024) + const page = await collect(root, cache, head) + assert.equal(page.source.captureComplete, false); assert.equal(page.source.complete, true) + assert.equal(page.items.length, 1); assert.equal(page.items[0].text, 'prefix') + assert.equal(page.records.length, 1) + }) +}) +await test('readonly and credential-refused cache spills return bounded heads without creating artifacts', async () => { + await fixture(async (root, cache) => { + const file = path.join(root, 'large.txt'); fs.writeFileSync(file, 'data'.repeat(40000)) + for (const options of [{ readOnly: true }, { authority: { deny_roots: [cache] } }]) { + const head = await call(root, cache, 'inspect_local_file', { path: file }, options) + assert.equal(head.status, 'completed'); assert.equal(head.complete, false); assert.equal(head.reference, undefined) + assert.ok(['POLICY_DENIED', 'CREDENTIAL_DENIED'].includes(head.storageError.code)) + assert.equal(head.storageError.effectState, 'none'); assert.ok(head.items[0].text.length > 0) + assert.deepEqual(fs.readdirSync(cache), []) + } + }) +}) +await test('a metadata-starved budget creates no unreachable reference and reports incomplete output', async () => { + await fixture(async (root, cache) => { + const file = path.join(root, 'long.txt'); fs.writeFileSync(file, 'z'.repeat(30000)) + const payload = input(root, cache, 'inspect_local_file', { path: file }, { maxBytes: 1024 }); payload.request.scope = 'long-session-'.repeat(19) + const head = await send(payload) + assert.equal(head.status, 'completed'); assert.equal(head.complete, false); assert.equal(head.reference, undefined) + assert.equal(head.storageError.code, 'OUTPUT_LIMIT'); assert.deepEqual(fs.readdirSync(cache), []) + }) +}) +await test('result pages never spill a second handle and unchanged small observations create no cache', async () => { + await fixture(async (root, cache) => { + const file = path.join(root, 'small.txt'); fs.writeFileSync(file, 'one\ntwo') + const small = await call(root, cache, 'inspect_local_file', { path: file }) + assert.equal(small.complete, true); assert.equal(small.reference, undefined); assert.deepEqual(fs.readdirSync(cache), []) + const put = await call(root, cache, 'result', { action: 'put', items: [{ text: 'x'.repeat(80000) }] }, { maxBytes: 131072 }) + const page = await call(root, cache, 'result', { action: 'page', id: put.id, maxBytes: 65536 }, { maxBytes: 131072 }) + assert.equal(page.error.code, 'OUTPUT_LIMIT'); assert.equal(page.reference, undefined) + assert.equal(fs.readdirSync(cache, { recursive: true }).filter(name => name.endsWith('.json')).length, 1) + }) +}) +await test('cache failure after replacement preserves successful mutation receipts and never advises repeating the write', async () => { + await fixture(async (root, cache) => { + const files = Array.from({ length: 20 }, (_, i) => path.join(root, `target-${i}.txt`)) + for (const file of files) fs.writeFileSync(file, 'replace this marker') + const head = await call(root, cache, 'replace', { path: root, pattern: 'marker', replacement: 'changed' }, { authority: { deny_roots: [cache] } }) + assert.equal(head.status, 'completed'); assert.equal(head.changed, 20); assert.equal(head.matched, 20) + assert.equal(head.complete, false); assert.equal(head.storageError.code, 'CREDENTIAL_DENIED') + assert.equal(head.storageError.effectState, 'none'); assert.equal(head.reference, undefined) + assert.match(head.nextAction, /do not repeat mutations/) + for (const file of files) assert.equal(fs.readFileSync(file, 'utf8'), 'replace this changed') + assert.deepEqual(fs.readdirSync(cache), []) + }) +}) +await test('spilled mutation source retains final changed counts and per-file postconditions', async () => { + await fixture(async (root, cache) => { + for (let i = 0; i < 20; i++) fs.writeFileSync(path.join(root, `target-${i}.txt`), 'marker') + const head = await call(root, cache, 'replace', { path: root, pattern: 'marker', replacement: 'published' }) + const page = await collect(root, cache, head) + assert.equal(head.changed, 20); assert.equal(page.source.changed, 20); assert.equal(page.source.complete, true) + assert.equal(page.items.length, 20); assert.ok(page.items.every(item => item.replacements === 1 && /^[a-f0-9]{64}$/.test(item.revisionAfter))) + assert.ok(page.items.every(item => fs.readFileSync(item.path, 'utf8') === 'published')) + }) +}) +await test('hex spill retains 1-based row resumption and exact requested bytes', async () => { + await fixture(async (root, cache) => { + const file = path.join(root, 'binary.bin'); const bytes = Buffer.alloc(32000, 0xaa); fs.writeFileSync(file, bytes) + const head = await call(root, cache, 'inspect_local_file', { path: file, view: 'hex', offset: 3, limit: 1000 }) + assert.equal(head.nextOffset, 3); assert.equal(head.complete, false) + const page = await collect(root, cache, head) + assert.equal(page.source.nextOffset, 1003); assert.equal(page.source.complete, false) + assert.equal(page.items[0].offset, 32); assert.equal(page.items[0].text.replaceAll(' ', ''), bytes.subarray(32, 16032).toString('hex')) + }) +}) +await test('glob string rows and display omitted counts survive a smaller transport budget', async () => { + await fixture(async (root, cache) => { + for (let i = 0; i < 40; i++) fs.writeFileSync(path.join(root, `${String(i).padStart(2, '0')}-${'longname'.repeat(8)}.txt`), '') + const head = await call(root, cache, 'glob', { path: root, pattern: ['*.txt'] }) + assert.equal(head.omitted, 40); assert.equal(head.nextOffset, 0) + const page = await collect(root, cache, head) + assert.equal(page.source.complete, true); assert.equal(page.source.omitted, 0) + assert.equal(page.items.length, 40); assert.ok(page.items.every(item => typeof item === 'string')) + }) +}) +report("result-spill") From 462125021e876cfbab36ce23a4b239b2d77c6294 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:01:23 +0800 Subject: [PATCH 020/118] docs(results): clarify the host execution deadline --- "docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index 80c563d6..a0d8e41f 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -29,7 +29,7 @@ English: [native-kernel.md](native-kernel.md). 能力参数和宿主权限字段;`component` 为 `file` 或 `ptc`,不会授予权限。桥接生成新的 UUID, 填入真实会话、根路径和策略,并在启动进程前再次核实权限。普通成功字段保留原有语义;可选引用字段见下文。 -`budget.timeoutMs` 范围为 1..2147483647 毫秒,沿用现有 300000 毫秒默认值及宿主取消机制。 +`budget.timeoutMs` 为兼容信封仍接受 1..2147483647 毫秒;宿主默认值和硬上限均为 30000 毫秒,并保留宿主取消机制。 进程内的看门狗也会约束直接调用;超时退出码为 124,桥接将副作用报告为 unknown。 `budget.maxBytes` 范围为 1024..131072,默认 131072,计算序列化 JSON 与末尾换行的总字节数。 下限保证结构化错误有足够空间。没有可信存储的读取输出超限返回 `OUTPUT_LIMIT` 且无副作用;修改回执丢失则 From 0915244d596117d16d5904aa8c8c9c8eae2ada75 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:05:18 +0800 Subject: [PATCH 021/118] test(results): canonicalize the private cache fixture --- native/crates/ops-results/src/spill.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/native/crates/ops-results/src/spill.rs b/native/crates/ops-results/src/spill.rs index 0f5b9553..cb779a5e 100644 --- a/native/crates/ops-results/src/spill.rs +++ b/native/crates/ops-results/src/spill.rs @@ -125,10 +125,12 @@ mod tests { fn fallback_capture_never_promotes_an_already_clipped_item_to_complete() { let root = std::env::temp_dir().join(format!("dsh-ops-spill-{}", uuid::Uuid::new_v4())); std::fs::create_dir(&root).unwrap(); + // macOS temporary paths can contain /var -> /private/var; cache authority requires canonical roots. + let root = std::fs::canonicalize(root).unwrap(); let mut request: Request = serde_json::from_value(json!({"root":root,"scope":"fallback-test","tool":"future_tool","args":{}})).unwrap(); request.storage = Some(serde_json::from_value(json!({"root":root,"authorityId":"a".repeat(64),"allowedTools":["future_tool","result"]})).unwrap()); let head = finish(&request, json!({"status":"completed","complete":false,"items":[{"text":"prefix","truncatedInLine":true}]})); - let id = head["reference"]["refId"].as_str().expect("bounded fallback reference"); + let id = head["reference"]["refId"].as_str().unwrap_or_else(|| panic!("bounded fallback reference: {head}")); request.tool = "result".into(); request.args = json!({"action":"page","id":id}); let page = references::run(&request).unwrap(); assert_eq!(page["source"]["captureComplete"], false); From 38ac1f9be6931f2bc1012b4570c66632d594ebd9 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:08:54 +0800 Subject: [PATCH 022/118] feat(result): expose scoped Native reference paging --- docs/CHANGELOG.md | 9 + docs/component-reference.md | 7 +- docs/native-ptc.md | 2 +- docs/result-budgets.md | 28 +++ ...04\344\273\266\345\217\202\350\200\203.md" | 7 +- ...23\346\236\234\351\242\204\347\256\227.md" | 21 ++ dsh-plugin.json | 3 +- scripts/validate-manifest.mjs | 6 +- src/capabilities/file.ts | 3 + src/capabilities/ptc.ts | 18 +- src/capabilities/registry.ts | 5 +- src/capabilities/result.ts | 27 +++ src/capabilities/types.ts | 1 + src/components/file/locale/en.json | 2 +- src/components/file/locale/zh.json | 2 +- src/file/result-provider.ts | 49 ++++ src/file/result.ts | 34 +++ src/file/runtime.js | 23 +- src/index.js | 2 +- src/modes/native/render.ts | 6 + src/modes/native/result.ts | 33 +++ src/policy.ts | 11 +- src/ptc/index.js | 56 +++-- test/capabilities.test.mjs | 19 +- test/mount.test.mjs | 5 +- test/native-result.test.mjs | 228 ++++++++++++++++++ test/ptc-cost.test.mjs | 2 +- 27 files changed, 548 insertions(+), 61 deletions(-) create mode 100644 src/capabilities/result.ts create mode 100644 src/file/result-provider.ts create mode 100644 src/file/result.ts create mode 100644 src/modes/native/result.ts create mode 100644 test/native-result.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2e3112d7..4cb36ee9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,15 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Page stored references from Native file tools (#261, 6d/16) + +- Add a Native page/release projection of the shared ops_result capability, independently owned by + the file row. PTC retains put and typed data; Native descendants of PTC agents keep page-only schemas. +- Bind paging to current session cwd (or explicit original root), authority and visible producer. + Preserve source/capture facts and tagged JSON chunks; bounded display never advances past omitted rows. +- 新增文件组件独立提供的 Native 引用分页/释放能力;PTC 保留 put。分别报告页面、原始操作与捕获完整性, + 保持会话、权限与生产工具可见性检查,显示截断时要求原偏移重试。 + ### Bound shared calls and expire session references (#261) - Share two active/eight waiting worker slots across Native/PTC, recheck authority after admission, diff --git a/docs/component-reference.md b/docs/component-reference.md index 678f2f0a..52d85abf 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -7,7 +7,7 @@ What each of the six components publishes, with parameters and permission requir | Row `name` | Description | Default | Published tools | | --- | --- | --- | --- | | `dsh-ops/shell` | Prefer bash as the terminal, with PowerShell 7 for operations bash cannot cover | On | `ops_bash`; host `pwsh` uses bundled PowerShell 7 | -| `dsh-ops/file` | Faster, higher-performance Rust file retrieval with more concise, token-efficient output | On | Four file tools | +| `dsh-ops/file` | Faster, higher-performance Rust file retrieval with more concise, token-efficient output | On | Native file tools and ops_result page/release | | `dsh-ops/ptc` | Independent Rust safe batches and bounded result references, only in pure PTC sessions | Off | `ops_batch`, `ops_result` | | `dsh-ops/panel` | Data capture, off at the switch: new conversations write `/.dsh-ops/panel/.json` plus the viewer `panel.html` | Row on, `capture: false` | None; see the [configuration reference](configuration-reference.md) | | `dsh-ops/tui` | Read-only current-session OPS sidebar | Off | None; see [sidebar guide](tui-panel.md) | @@ -131,12 +131,13 @@ Preflight and verify copy / move / remove. ### `ops_result` -Bounded session-owned result references: put object rows, page with top-level `equals`/`fields` and quantity/byte budgets, release results. 24h expiry. Job audits are read-only. References are observations, never authority. +Bounded session-owned result references: PTC can put object rows; Native file tools expose page/release only for ref:// handles. Page uses top-level equals/fields and quantity/byte budgets. New references default to session lifetime (ttlMs=0), optionally up to seven days; only legacy r- snapshots use 24h expiry. Job audits are read-only and remain PTC-only. References are observations, never authority. Native root defaults to current session cwd; PTC defaults to the workspace root. See [paging and capture completeness](result-budgets.md#native-and-ptc-reference-pages). | Parameter | Type | Notes | | --- | --- | --- | | `action` | `put` \| `page` \| `release` (required) | Action | -| `items` | object array, ≤10000 items | Rows for `put` | +| `items` | object array, ≤10000 items | PTC put; whole request ≤2 MiB, snapshot ≤10 MiB | +| `ttlMs` | integer 0–604800000 | PTC put; default 0 means session lifetime | | `root` / `id` | string | Operation root and result id | | `offset` | integer ≥ 0 | Paging start | | `limit` | integer 1–512 | Rows per page | diff --git a/docs/native-ptc.md b/docs/native-ptc.md index c954143e..3092d7cb 100644 --- a/docs/native-ptc.md +++ b/docs/native-ptc.md @@ -63,7 +63,7 @@ The session must already use DSH's **PTC-only** presentation. The component does not force presentation, replace the registry, or create a REPL. It recognizes the host's public prompt assembly (`tools:ptc-only` section plus the reserved-only wire schema), not private `modeFor`, run_code existence alone or a guessed session key. -Standard/native and hybrid/both scopes expose neither tool nor extra prompt. +This PTC row adds no batch/put capability or extra prompt in Native/both scopes. The file row independently supplies Native ops_result page/release; see [result budgets](result-budgets.md#native-and-ptc-reference-pages). 文件组件独立提供 Native 引用分页/释放,详见[结果预算](结果预算.md#native-与-ptc-引用分页)。 The host generates SDK declarations from the same tool schemas; no duplicate guide or dynamic plan IDs are added to the system prefix. diff --git a/docs/result-budgets.md b/docs/result-budgets.md index c2d687ff..c55c9716 100644 --- a/docs/result-budgets.md +++ b/docs/result-budgets.md @@ -28,3 +28,31 @@ quota can remain for host temporary-directory cleanup or operator removal. A fre disk leftovers as valid references after a restart. Component owners share this lifetime; unloading the last Native/PTC owner expires its remaining sessions. Cleanup is an internal operation and cannot be requested through model tool arguments. + +## Native and PTC reference pages + +The file row provides Native ops_result with page/release only, independently of the PTC row. +The single result catalogue entry also supplies PTC put/page/release; PTC-only scopes retain the +richer schema, and Native/both descendants receive the file row's page-only projection. Disabling +file withdraws that projection while the PTC row remains independent. Advanced discovery owns +its later on-demand publication; enabling the pager does not change any official tool. + +Native accepts only ref:// handles, never legacy audit IDs or put/purge. Omit root to use the +current session cwd; after changing cwd, pass the original referenceRoot. PTC defaults to the +workspace root. The locator grants no authority: a different session/root, changed permissions, +or a hidden producer denies access. Release removes cached observations, never business files. + +Pages default to offset=0, limit=64 and maxBytes=16384 (bounds: 1-512 rows, 512-65536 bytes). +equals filters top-level exact values before fields projection; offsets count filtered stored rows. +New references use ttlMs=0 for session lifetime, or up to seven days when PTC put specifies ttlMs. +createdAt is Unix milliseconds. The entire put request is capped at 2 MiB, each physical snapshot +at 10 MiB. Legacy audit contracts are unchanged. + +Native renders intact stored rows under a 64 KiB escaped-content ceiling. Follow nextOffset only +when displayComplete=true; otherwise repeat the same offset with smaller limit/maxBytes or fewer +fields. pageComplete reports pagination of stored rows. source preserves original execution facts, +source.root, capturedItems and captureComplete: finishing pages never completes an incomplete scan. +With source.itemEncoding=tagged-json-v1, kind=item rows contain value. item-json-chunk rows carry +index, chunk, byteOffset, text and complete; concatenate text for one index in byteOffset order and +JSON.parse only after its final chunk. A final chunk alone is not a complete logical item. A stored +snapshot represents the requested operation/page, not an unbounded whole-query result. diff --git "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" index ae9f8220..638572e5 100644 --- "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" +++ "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" @@ -7,7 +7,7 @@ | 配置行 `name` | 介绍 | 默认 | 发布的工具 | | --- | --- | --- | --- | | `dsh-ops/shell` | 将默认终端改为 bash,以及 bash 覆盖不到时提供 PowerShell 7 | 开 | `ops_bash`;宿主 `pwsh` 使用随包 PowerShell 7 | -| `dsh-ops/file` | 更快、更高性能、输出更精简更省 token 的 Rust 文件检索 | 开 | 四个文件工具 | +| `dsh-ops/file` | 更快、更高性能、输出更精简更省 token 的 Rust 文件检索 | 开 | Native 文件工具与 ops_result 分页/释放 | | `dsh-ops/ptc` | 独立 Rust 安全批处理和有界结果引用,仅纯 PTC 会话加载 | 关 | `ops_batch`、`ops_result` | | `dsh-ops/panel` | 数据采集,开关默认关:新对话写入 `/.dsh-ops/panel/.json` 与读取它的 `panel.html` | 行开,`capture: false` | 无;见[配置参考](配置参考.md) | | `dsh-ops/tui` | 当前会话的只读 OPS 侧栏 | 关 | 无;见 [侧栏说明](tui-panel.zh.md) | @@ -131,12 +131,13 @@ ### `ops_result` -会话自有的有界结果引用:写入对象行、按顶层 `equals`/`fields` 与数量/字节预算分页、释放结果。24 小时过期。job 审计只读。引用是观察结果,从不是权限来源。 +会话自有的有界结果引用:PTC 可写入对象行;Native 文件组件仅为 ref:// 提供分页/释放。分页支持顶层 equals/fields 与数量/字节预算。新引用默认 ttlMs=0(会话生命周期),可指定最多七天;仅旧 r- 快照为 24 小时过期。job 审计只读且仅在 PTC 可用。引用是观察结果,从不是权限来源。Native root 默认当前会话 cwd,PTC 默认工作区根目录。详见[分页与捕获完整性](结果预算.md#native-与-ptc-引用分页)。 | 参数 | 类型 | 说明 | | --- | --- | --- | | `action` | `put` \| `page` \| `release`(必填) | 动作 | -| `items` | 对象数组,≤10000 项 | `put` 的数据行 | +| `items` | 对象数组,≤10000 项 | PTC put;整个请求 ≤2 MiB,快照 ≤10 MiB | +| `ttlMs` | integer 0–604800000 | PTC put;默认 0 表示会话生命周期 | | `root` / `id` | string | 作用根与结果 id | | `offset` | integer ≥ 0 | 分页起点 | | `limit` | integer 1–512 | 每页条数 | diff --git "a/docs/\347\273\223\346\236\234\351\242\204\347\256\227.md" "b/docs/\347\273\223\346\236\234\351\242\204\347\256\227.md" index 17f91633..73c76fcd 100644 --- "a/docs/\347\273\223\346\236\234\351\242\204\347\256\227.md" +++ "b/docs/\347\273\223\346\236\234\351\242\204\347\256\227.md" @@ -20,3 +20,24 @@ PTC 仍通过宿主沙箱执行,Native 固定文件操作保持原有权限执 清理或操作者删除。内存中的新会话随机标识保证重启后不能重新使用磁盘残留引用。 各组件共享生命周期;最后一个 Native/PTC 所有者卸载时清理剩余会话。清理为内部操作,模型 不能通过工具参数发起。 + +## Native 与 PTC 引用分页 + +文件组件独立提供 Native ops_result,只允许 page/release;不依赖 PTC 组件。同一个能力目录条目还提供 +PTC 的 put/page/release。纯 PTC 作用域保留完整参数,Native/both 子作用域使用文件组件的分页投影。 +关闭文件组件会撤回该投影,不影响独立 PTC 组件。高级工具发现后续负责按需发布;此能力不改变官方工具。 + +Native 仅接受 ref://,不接受旧审计 ID、put 或 purge。省略 root 时使用当前会话 cwd;cwd 改变后必须 +传入原始 referenceRoot。PTC 默认使用工作区根目录。引用不授予权限:不同会话、根目录、权限变化或 +生产工具不可见都拒绝读取。release 仅删除缓存观察结果,不删除业务文件。 + +默认 offset=0、limit=64、maxBytes=16384;范围分别为 1-512 行和 512-65536 字节。equals 先按顶层精确 +值过滤,fields 再投影,偏移按过滤后的存储行计数。新引用默认 ttlMs=0(会话生命周期);PTC put 可指定 +最多七天。createdAt 使用 Unix 毫秒。整个 put 请求最多 2 MiB,单个物理快照最多 10 MiB。旧审计协议不变。 + +Native 只显示完整存储行,转义后的内容最多 64 KiB。仅在 displayComplete=true 时使用 nextOffset;否则 +用较小 limit/maxBytes 或更少 fields 从同一偏移重试。pageComplete 仅表示存储行分页完成。source 保留 +原操作事实、root、capturedItems 和 captureComplete;读完分页不能使未完成的扫描变为完成。 +source.itemEncoding=tagged-json-v1 时,kind=item 行在 value 中携带数据;item-json-chunk 行包含 index、 +chunk、byteOffset、text、complete。按同一 index 的 byteOffset 顺序拼接 text,收到最后一片后才进行 +JSON.parse;单独的最后一片不是完整逻辑项。快照仅覆盖所请求的操作或页面,不表示无限制的整个查询。 diff --git a/dsh-plugin.json b/dsh-plugin.json index f2b5a090..ba7d770a 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -84,7 +84,8 @@ "move", "remove", "read_many", - "edit_many" + "edit_many", + "result" ], "shellPermission": "Per-call DSH sandbox policy and native subprocess" }, diff --git a/scripts/validate-manifest.mjs b/scripts/validate-manifest.mjs index c8096810..f62b679d 100644 --- a/scripts/validate-manifest.mjs +++ b/scripts/validate-manifest.mjs @@ -20,13 +20,13 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { parse as parseYaml } from 'yaml' import { - FILE_TOOLS, IN_PROCESS_TOOLS, TOOLING_SECTION, TOOL_PREFIX, publicToolName, renderToolingPolicy, } from '../lib/policy.js' +import { NATIVE_FILE_TOOLS } from '../lib/capabilities/registry.js' import { resolvePtcConfig } from '../lib/ptc/index.js' import { resolveTuiConfig } from '../lib/components/tui/index.js' import { resolvePanelConfig } from '../lib/components/panel/index.js' @@ -256,10 +256,10 @@ if (hostedNamespace === undefined) { check(false, 'contributes.x-tool-namespaces declares the hosted tool namespace') } else { const declared = [...hostedNamespace.tools] - const expected = [...FILE_TOOLS] + const expected = [...NATIVE_FILE_TOOLS] check( declared.length === expected.length && expected.every((toolName) => declared.includes(toolName)), - 'the native file namespace matches lib/file/contract.js', + 'the native file namespace matches catalogue Native file projections', `manifest ${declared.join(',')} vs contract ${expected.join(',')}`, ) check( diff --git a/src/capabilities/file.ts b/src/capabilities/file.ts index b5e21569..f2551106 100644 --- a/src/capabilities/file.ts +++ b/src/capabilities/file.ts @@ -1,5 +1,6 @@ /** File capability data; no host services or registration side effects. */ import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import { STORAGE_REFERENCE } from './result.js' import type { ParameterSchema } from './types.js' const string: JsonSchemaNode = { type: 'string' } @@ -69,6 +70,7 @@ export const PARAMETERS = Object.freeze({ export const OUTPUT: JsonSchemaNode = { type: 'object', properties: { + reference: STORAGE_REFERENCE, referenceRoot: string, root: string, source: { type: 'object' }, storageError: { type: 'object' }, nextAction: string, revision: string, bytes: { type: 'integer' }, lineCount: { oneOf: [{ type: 'integer' }, { type: 'null' }] }, status: { type: 'string' }, phase: { type: 'string' }, changed: { type: 'integer' }, matched: { type: 'integer' }, skipped: { type: 'integer' }, failed: { type: 'integer' }, @@ -82,6 +84,7 @@ export const OUTPUT: JsonSchemaNode = { export const READ_MANY_OUTPUT: JsonSchemaNode = { type: 'object', properties: { + reference: STORAGE_REFERENCE, referenceRoot: string, root: string, source: { type: 'object' }, storageError: { type: 'object' }, nextAction: string, status: string, complete: { type: 'boolean' }, nextIndex: { type: 'integer' }, pending: { type: 'integer' }, total: { type: 'integer' }, failed: { type: 'integer' }, truncated: { type: 'boolean' }, effectState: string, items: { type: 'array', items: { ...OUTPUT, properties: { ...OUTPUT.properties, path: string } } }, error: { type: 'object' }, diff --git a/src/capabilities/ptc.ts b/src/capabilities/ptc.ts index 239fd207..8dbfac9d 100644 --- a/src/capabilities/ptc.ts +++ b/src/capabilities/ptc.ts @@ -1,5 +1,6 @@ -/** Batch and result capability data; publication remains scoped to PTC-only agents. */ +/** Batch and PTC result schemas; the same result capability also owns a Native projection. */ import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import { STORAGE_REFERENCE, RESULT_PAGE_PARAMETERS } from './result.js' import type { ParameterSchema } from './types.js' const string: JsonSchemaNode = { type: 'string' } @@ -15,13 +16,9 @@ export const PARAMETERS = { id: string, }, ['action']), result: object({ - action: { type: 'string', enum: ['put', 'page', 'release'] }, root: string, id: string, - items: { type: 'array', description: 'At most 10000 object rows; 2 MiB request/store cap.', items: { type: 'object' } }, - offset: { ...integer, description: 'Nonnegative filtered-row offset; default 0.' }, - limit: { ...integer, description: '1-512 rows; default 64.' }, - maxBytes: { ...integer, description: '512-65536 serialized bytes; default 16384.' }, - fields: { type: 'array', description: 'At most 32 top-level field names, each at most 128 bytes.', items: string }, - equals: { type: 'object' }, + action: { type: 'string', enum: ['put', 'page', 'release'] }, ...RESULT_PAGE_PARAMETERS, + items: { type: 'array', description: 'At most 10000 object rows; 2 MiB whole request, 10 MiB physical reference cap.', items: { type: 'object' } }, + ttlMs: { ...integer, description: 'put only: 0 (default, session lifetime), or 1-604800000 milliseconds.' }, }, ['action']), } // Open JSON values exist only in the data plane (caller-owned rows), not in operation inputs. @@ -30,7 +27,8 @@ export const OUTPUT: JsonSchemaNode = { id: string, root: string, status: string, phase: string, done: integer, total: integer, failed: integer, unknown: integer, logicalBytes: integer, byteSemantics: string, detailsRef: string, complete: { type: 'boolean' }, omitted: integer, nextAction: string, - sourceTotal: integer, contentHash: string, + sourceTotal: integer, contentHash: string, reference: STORAGE_REFERENCE, + source: { type: 'object', description: 'Original execution facts, itemEncoding, capturedItems and captureComplete; independent of page completeness.' }, expires: { oneOf: [integer, { type: 'null' }] }, items: { type: 'array', items: { type: 'object' } }, nextOffset: integer, sandbox: object({ mode: string, enforcement: string }, ['mode']), error: object({ code: string, phase: string, retryable: { type: 'boolean' }, effectState: string, nextAction: string, message: string, @@ -40,5 +38,5 @@ export const OUTPUT: JsonSchemaNode = { } export const DESCRIPTIONS = { batch: 'Preflight and verify copy/move/remove. action=plan requires operations and creates a no-effect audit plan; execute that exact plan with action=resume plus id (do not change it to run). action=run requires operations and executes immediately. root, when supplied, must be an absolute path: a relative root is rejected at preflight with INVALID_ARGUMENT. Omit root to use the session workspace root; an absolute root follows DSH permissions (unrestricted in full access). Operations use relative / paths, no overwrite or link traversal. Page detailsRef with ops_result, passing the same root whenever the plan root differs from the session workspace root. Parameter errors return structured error objects.', - result: 'Session-owned object rows: put items (up to 10000; 2 MiB request/store cap), page, release. Pages default to offset=0, limit=64, maxBytes=16384; follow nextOffset until complete. equals filters top-level exact values before fields projection; offset/total/omitted count filtered rows. sourceTotal/contentHash identify all ordered rows before filtering/projection (SHA-256 of worker compact JSON with sorted object keys); compare hashes across pages to detect changed audits, never as authority. An oversized row returns OUTPUT_LIMIT: project fewer fields or raise maxBytes (max 65536). Result expiry is 24h, returned as Unix seconds; audit pages have expires=null and cannot be released. operationsRef in ops_batch accepts only owned unexpired result rows. Pass the same root as the batch when paging detailsRef, or expect NOT_FOUND. Failures are structured values, not thrown errors. In run_code, process pages locally and return only needed summaries.', + result: 'Session-owned object rows: put items (up to 10000; 2 MiB whole request and 10 MiB physical reference cap), page, release. Pages default to offset=0, limit=64, maxBytes=16384; follow nextOffset until complete. equals filters top-level exact values before fields projection; offset/total/omitted count filtered rows. sourceTotal/contentHash identify all ordered rows before filtering/projection (SHA-256 of worker compact JSON with sorted object keys); compare hashes across pages to detect changed audits, never as authority. An oversized row returns OUTPUT_LIMIT: project fewer fields or raise maxBytes (max 65536). New ref:// snapshots default to session lifetime (ttlMs=0); put may set ttlMs up to 604800000. reference.createdAt is Unix milliseconds. Legacy r- snapshots expire after 24h; audit pages have expires=null and cannot be released. Page complete describes stored rows only; source execution and capture completeness remain independent. Tagged-json-v1 item-json-chunk text must be joined by index and byteOffset before JSON.parse, only after complete=true. operationsRef in ops_batch accepts only owned unexpired result rows. Pass the same root as the batch when paging detailsRef, or expect NOT_FOUND. Failures are structured values, not thrown errors. In run_code, process pages locally and return only needed summaries.', } diff --git a/src/capabilities/registry.ts b/src/capabilities/registry.ts index 270d060b..9625ae5a 100644 --- a/src/capabilities/registry.ts +++ b/src/capabilities/registry.ts @@ -1,6 +1,7 @@ /** One catalogue for owned tools. This module never registers tools or imports host values. */ import { PARAMETERS as fileParameters, DESCRIPTIONS as fileDescriptions, fileOutput } from './file.js' import { PARAMETERS as ptcParameters, DESCRIPTIONS as ptcDescriptions, OUTPUT as ptcOutput } from './ptc.js' +import { NATIVE_RESULT_PARAMETERS, NATIVE_RESULT_DESCRIPTION } from './result.js' import type { CapabilityMetadata, Component, SchemaCapability } from './types.js' export type FileToolName = keyof typeof fileParameters @@ -17,7 +18,8 @@ export const FILE_CAPABILITIES = Object.freeze(fileOrder.map(rawName => Object.f parameters: fileParameters[rawName], description: fileDescriptions[rawName], output: fileOutput(rawName), } satisfies SchemaCapability))) export const PTC_CAPABILITIES = Object.freeze((Object.keys(ptcParameters) as PtcToolName[]).map(rawName => Object.freeze({ - rawName, component: 'ptc' as const, presentations: Object.freeze(['ptc'] as const), + rawName, component: 'ptc' as const, presentations: rawName === 'result' ? allPresentations : Object.freeze(['ptc'] as const), + ...(rawName === 'result' ? { nativeProjection: { component: 'file' as const, parameters: NATIVE_RESULT_PARAMETERS, description: NATIVE_RESULT_DESCRIPTION } } : {}), availability: 'resident' as const, mutates: true, concurrencySafe: false, parameters: ptcParameters[rawName], description: ptcDescriptions[rawName], output: ptcOutput, } satisfies SchemaCapability))) @@ -36,6 +38,7 @@ export function capabilitiesFor(component: Component): readonly Capability[] { return CAPABILITIES.filter(capability => capability.component === component) } export const FILE_TOOLS = Object.freeze(FILE_CAPABILITIES.map(capability => capability.rawName)) +export const NATIVE_FILE_TOOLS = Object.freeze([...FILE_TOOLS, ...PTC_CAPABILITIES.filter(entry => entry.nativeProjection?.component === 'file').map(entry => entry.rawName)]) export const FILE_MUTATIONS = Object.freeze(FILE_CAPABILITIES.filter(capability => capability.mutates).map(capability => capability.rawName)) export const PTC_TOOLS = PTC_CAPABILITIES.map(capability => capability.rawName) export const IN_PROCESS_TOOLS = Object.freeze(SHELL_CAPABILITIES.map(capability => capability.rawName)) diff --git a/src/capabilities/result.ts b/src/capabilities/result.ts new file mode 100644 index 00000000..904755f4 --- /dev/null +++ b/src/capabilities/result.ts @@ -0,0 +1,27 @@ +/** Shared stored-reference wire metadata and the Native projection of the result capability. */ +import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import type { ParameterSchema } from './types.js' +const string: JsonSchemaNode = { type: 'string' } +const integer: JsonSchemaNode = { type: 'integer' } +export const STORAGE_REFERENCE: JsonSchemaNode = { + type: 'object', properties: { + refId: { ...string, description: 'Opaque ref://session/tool/uuid; a locator, never authority.' }, + createdAt: { ...integer, description: 'Unix milliseconds.' }, + ttlMs: { ...integer, description: '0 means session lifetime; otherwise at most 604800000 milliseconds.' }, + schemaVersion: string, producerTool: string, totalItems: integer, totalBytes: integer, preview: string, + }, required: ['refId', 'createdAt', 'ttlMs', 'schemaVersion', 'producerTool', 'totalItems', 'totalBytes', 'preview'], +} +export const RESULT_PAGE_PARAMETERS = { + root: { ...string, description: 'Absolute root bound to the original reference. Native defaults to current session cwd; PTC defaults to workspace root.' }, + id: { ...string, description: 'Use the returned reference.refId unchanged.' }, + offset: { ...integer, description: 'Nonnegative filtered-row offset; default 0.' }, + limit: { ...integer, description: '1-512 rows; default 64.' }, + maxBytes: { ...integer, description: '512-65536 serialized bytes; default 16384. Reduce this if Native display is incomplete.' }, + fields: { type: 'array', description: 'At most 32 top-level field names, each at most 128 bytes.', items: string }, + equals: { type: 'object', description: 'At most 16 top-level exact-value filters, applied before projection and paging.' }, +} satisfies Record +export const NATIVE_RESULT_PARAMETERS: ParameterSchema = { + type: 'object', properties: { action: { type: 'string', enum: ['page', 'release'] }, ...RESULT_PAGE_PARAMETERS }, + required: ['action', 'id'], additionalProperties: false, +} +export const NATIVE_RESULT_DESCRIPTION = 'Page or release an owned ref:// snapshot. No put, audit IDs, or business writes. root defaults to current session cwd; pass the returned root after cwd changes. Page defaults offset=0, limit=64, maxBytes=16384. Follow nextOffset only when all page rows are displayed; page complete never proves the source operation complete. equals filters before fields projection; offsets count filtered stored rows, including chunks. Tagged item-json-chunk rows are JSON text fragments: join text in byteOffset order for one index, then parse only after complete=true. References expire with the session or ttlMs and require current authority and producer visibility. Release removes only cached observations. Failures are returned error values.' diff --git a/src/capabilities/types.ts b/src/capabilities/types.ts index e2f3320e..ae70e775 100644 --- a/src/capabilities/types.ts +++ b/src/capabilities/types.ts @@ -21,4 +21,5 @@ export interface SchemaCapability extends CapabilityMetadata { readonly parameters: ParameterSchema readonly description: string readonly output: JsonSchemaNode + readonly nativeProjection?: { readonly component: 'file'; readonly parameters: ParameterSchema; readonly description: string } } diff --git a/src/components/file/locale/en.json b/src/components/file/locale/en.json index bf67586b..eb206484 100644 --- a/src/components/file/locale/en.json +++ b/src/components/file/locale/en.json @@ -1,6 +1,6 @@ { "meta": { "title": "dsh-ops-file", - "description": "High-performance Rust file search with faster execution and compact output for token efficiency." + "description": "Bounded Native file reads, edits, search and owned result-reference paging." } } diff --git a/src/components/file/locale/zh.json b/src/components/file/locale/zh.json index 28222e82..df62e3a7 100644 --- a/src/components/file/locale/zh.json +++ b/src/components/file/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { "title": "dsh-ops-file", - "description": "更快、更高性能、输出更精简更省token的rust文件检索" + "description": "有界 Native 文件读取、编辑、搜索与会话结果引用分页。" } } diff --git a/src/file/result-provider.ts b/src/file/result-provider.ts new file mode 100644 index 00000000..f8976105 --- /dev/null +++ b/src/file/result-provider.ts @@ -0,0 +1,49 @@ +/** Only exact visible OPS registrations can supply a scope's Native pager. */ +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import type { Context } from '@deepseek-ai/cordis' +interface Host { + root?: object + get(name: 'tools'): { get(name: string, scope?: unknown): ToolDefinition | undefined } | undefined +} +interface Provider { definition: ToolDefinition; probes: ToolDefinition[]; scope: unknown; active: boolean; publish(visible: boolean): void } +interface State { providers: Set; ptc: Set } +const owners = new WeakMap() +function state(ctx: Host): State { + const key = ctx.root ?? ctx + let entry = owners.get(key) + if (!entry) { entry = { providers: new Set(), ptc: new Set() }; owners.set(key, entry) } + return entry +} +export async function registerResultProvider(ctx: Host, definition: ToolDefinition, probes: ToolDefinition[], + register: () => (() => void)): Promise<() => void> { + // Public pinned scope API loaded only during activation, never by the package's host entry. + const { scopeOf } = await import('@deepseek-ai/dsh-scope') + const owner = state(ctx), scope = scopeOf(ctx as Context) + let dispose: (() => void) | undefined + const provider: Provider = { definition, probes, scope, active: true, publish(visible) { + if (visible && provider.active && !dispose) dispose = register() + else if (!visible && dispose) { const remove = dispose; dispose = undefined; remove() } + } } + owner.providers.add(provider) + try { if (!owner.ptc.has(scope)) provider.publish(true) } + catch (error) { provider.active = false; owner.providers.delete(provider); throw error } + return () => { provider.active = false; owner.providers.delete(provider); provider.publish(false) } +} +/** Only a PTC definition in this exact registration layer may temporarily replace its Native peer. */ +export function claimPtcResult(ctx: Host, scope: unknown): () => void { + const owner = state(ctx) + owner.ptc.add(scope) + for (const provider of owner.providers) if (provider.scope === scope) provider.publish(false) + return () => { + owner.ptc.delete(scope) + for (const provider of owner.providers) if (provider.scope === scope) provider.publish(true) + } +} +export function nativeResultProvider(ctx: Host, scope: unknown): Provider | undefined { + const tools = ctx.get('tools') + const visible = [...state(ctx).providers].filter(provider => provider.active + && (tools?.get(provider.definition.name, scope) === provider.definition + || provider.probes.some(definition => tools?.get(definition.name, scope) === definition))) + // Fully masked or ambiguous file rows fail closed; never borrow a sibling's executor. + return visible.length === 1 ? visible[0] : undefined +} diff --git a/src/file/result.ts b/src/file/result.ts new file mode 100644 index 00000000..eace4e91 --- /dev/null +++ b/src/file/result.ts @@ -0,0 +1,34 @@ +/** Native page/release adapter over the same result kernel bridge used by PTC. */ +import path from 'node:path' +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import { capability } from '../capabilities/registry.js' +import { publicToolName } from '../policy.js' +import { OUTPUT } from '../capabilities/ptc.js' +import { invoke, failure } from '../ptc/worker.js' +import { renderNative } from '../modes/native/render.js' +export function resultDefinition(execute: ToolDefinition['execute']): ToolDefinition { + const entry = capability('result') + if (!entry || !('nativeProjection' in entry) || !entry.nativeProjection) throw new Error('Native result projection is missing') + return { name: publicToolName(entry.rawName), description: entry.nativeProjection.description, + parameters: entry.nativeProjection.parameters, execute, + output: { schema: OUTPUT, render: (args, value) => [{ type: 'text', text: renderNative('result', args, value) }] } } +} +type Execution = Parameters[1] +export async function invokeResult(ctx: Parameters[0], file: string, args: unknown, + exec: Execution, signal: AbortSignal, config: Parameters[6]): Promise { + if (!args || typeof args !== 'object' || Array.isArray(args)) return failure('INVALID_ARGUMENT', 'Expected page/release arguments.') + const input: Record = { ...args } + for (const [key, value] of Object.entries(input)) { + if (key !== 'id' && key !== 'action' && (value === null || value === undefined + || (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0))) delete input[key] + } + const allowed = new Set(['action', 'root', 'id', 'offset', 'limit', 'maxBytes', 'fields', 'equals']) + const unknown = Object.keys(input).find(key => !allowed.has(key)) + if (unknown) return failure('INVALID_ARGUMENT', 'Unknown Native result argument: ' + unknown) + if (!['page', 'release'].includes(String(input.action)) || typeof input.id !== 'string' || !input.id.startsWith('ref://')) { + return failure('INVALID_ARGUMENT', 'Native result accepts page/release with an owned ref:// ID only.') + } + const root = input.root ?? exec.agent?.session?.header.cwd + if (typeof root !== 'string' || !path.isAbsolute(root)) return failure('INVALID_ARGUMENT', 'An absolute reference root or authoritative session cwd is required.') + return invoke(ctx, file, 'result', { ...input, root }, exec, signal, config) +} diff --git a/src/file/runtime.js b/src/file/runtime.js index 0bbbb952..d99d7534 100644 --- a/src/file/runtime.js +++ b/src/file/runtime.js @@ -1,6 +1,9 @@ import { WORKER_TIMEOUT_MS } from '../worker-protocol.js' import { publicToolName } from '../policy.js' -import { definition, FILE_TOOLS } from './contract.js' +import { definition } from './contract.js' +import { NATIVE_FILE_TOOLS } from '../capabilities/registry.js' +import { resultDefinition, invokeResult } from './result.js' +import { registerResultProvider } from './result-provider.js' import { safeDisposeAll } from '../teardown.js' import { invoke, failure } from './worker.js' @@ -21,10 +24,17 @@ export class NativeFileTools { if (this.config.required) throw new Error('Native file worker path is unavailable.') return [] } - for (const rawName of FILE_TOOLS) { - this.disposers.push(this.ctx.tools.register(definition(rawName, (args, exec) => this.call(rawName, args, exec)))) + const probes = [] + let pager + for (const rawName of NATIVE_FILE_TOOLS) { + const execute = (args, exec) => this.call(rawName, args, exec) + const tool = rawName === 'result' ? resultDefinition(execute) : definition(rawName, execute) + if (rawName === 'result') pager = tool + else probes.push(tool) + if (rawName !== 'result') this.disposers.push(this.ctx.tools.register(tool)) } - return FILE_TOOLS.map(publicToolName) + if (pager) this.unregisterProvider = await registerResultProvider(this.ctx, pager, probes, () => this.ctx.tools.register(pager)) + return NATIVE_FILE_TOOLS.map(publicToolName) } async call(tool, args, exec) { @@ -35,7 +45,9 @@ export class NativeFileTools { else exec?.signal?.addEventListener?.('abort', abort, { once: true }) const timer = setTimeout(() => controller.abort(new Error('Native file call timed out')), Math.min(this.config.toolCallTimeoutMs ?? WORKER_TIMEOUT_MS, WORKER_TIMEOUT_MS)) timer.unref?.() - const work = invoke(this.ctx, this.file, tool, args, exec, controller.signal, this.config) + const work = tool === 'result' + ? invokeResult(this.ctx, this.file, args, exec, controller.signal, this.config) + : invoke(this.ctx, this.file, tool, args, exec, controller.signal, this.config) this.active.set(controller, work) try { return await work } finally { @@ -47,6 +59,7 @@ export class NativeFileTools { async stop() { this.stopped = true + safeDisposeAll([this.unregisterProvider].filter(Boolean), error => this.report('dsh-ops: Native pager teardown failed: ' + error, 'warn'), 'result provider') for (const controller of this.active.keys()) controller.abort(new Error('Native file component unloaded')) await Promise.allSettled(this.active.values()) // Isolate each teardown: one throwing worker registration must not strand the rest. diff --git a/src/index.js b/src/index.js index ce4c41c3..5670ed6e 100644 --- a/src/index.js +++ b/src/index.js @@ -2,7 +2,7 @@ import { resolveConfig } from './config.js' import { mountConfigUpdates, updatedConfig } from './config-updates.js' import { SECTION_ORDERS, TOOLING_SECTION, BASH_TOOL, publicToolName, hostShellRefusal, renderToolingPolicy } from './policy.js' -import { FILE_TOOLS } from './file/contract.js' +import { NATIVE_FILE_TOOLS as FILE_TOOLS } from './capabilities/registry.js' import { resolveShells } from './shells.js' import { mountSessionBash } from './session-shells.js' import { mountBundledPwsh } from './session-pwsh.js' diff --git a/src/modes/native/render.ts b/src/modes/native/render.ts index c0a7ae36..7f97434b 100644 --- a/src/modes/native/render.ts +++ b/src/modes/native/render.ts @@ -1,4 +1,5 @@ import { Buffer } from 'node:buffer' +import { renderResult } from './result.js' import { errorPolicy } from '../../kernel/errors.js' import type { EffectState } from '../../kernel/outcome.js' @@ -161,12 +162,17 @@ export function renderNative(tool: string, args: unknown, result: unknown): stri const input = record(args) ? args : {} let lines: string[] if (record(result.error) || ['error', 'rejected', 'conflict', 'unknown', 'failed'].includes(String(result.status))) lines = failure(result) + else if (tool === 'result') return capNativeText(renderResult(input, result), NATIVE_OUTPUT_BYTES, 'Repeat the same offset with smaller limit/maxBytes; do not advance past undisplayed rows.') else if (tool === 'read_many') lines = readMany(result) else if (tool === 'inspect_local_file') lines = inspect(input, result) else if (mutations.has(tool)) lines = receipts(tool, result) else if (tool === 'stat') lines = stat(input, result) else if (tool === 'diff') lines = diff(input, result) else lines = listing(tool, input, result) + // Keep the locator ahead of potentially clipped model text so its omitted suffix stays recoverable. + if (record(result.reference)) lines.unshift('[Stored reference: ' + facts(result.reference, ['refId', 'producerTool', 'totalItems', 'totalBytes']) + + '; referenceRoot=' + field(result.referenceRoot) + '. Page with ops_result; inspect source capture/coverage facts before claiming completeness.]') + if (record(result.storageError)) lines.unshift('[Storage: ' + facts(result.storageError, ['code', 'message', 'effectState']) + '. Cache effects are separate from business effects.]') return capNativeText(lines.join('\n'), NATIVE_OUTPUT_BYTES, mutations.has(tool) ? 'Inspect current state before another mutation; do not repeat the write to recover undisplayed receipts.' : undefined) diff --git a/src/modes/native/result.ts b/src/modes/native/result.ts new file mode 100644 index 00000000..17b1d8fc --- /dev/null +++ b/src/modes/native/result.ts @@ -0,0 +1,33 @@ +/** Stored rows remain distinct from source-operation completeness and item JSON chunks. */ +const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value) +const text = (value: unknown): string => JSON.stringify(value) ?? 'null' +const bytes = (value: string): number => Buffer.byteLength(JSON.stringify([{ type: 'text', text: value }])) +export function renderResult(args: Record, value: Record): string { + const id = value.id ?? (object(value.reference) ? value.reference.refId : undefined) ?? args.id + if (value.status === 'released') return 'Released cached reference ' + text(id) + '. No business files were changed.' + const reference = object(value.reference) ? value.reference : {} + const source = object(value.source) ? value.source : {} + const header = ['=== RESULT: ' + text(id) + ' ===', + '[Notice: root=' + text(source.root ?? value.root ?? args.root ?? '(current session cwd)') + '; sourceTotal=' + text(value.sourceTotal) + + '; filteredTotal=' + text(value.total) + '; contentHash=' + text(value.contentHash) + '.]', + '[Notice: producer=' + text(reference.producerTool) + '; createdAt=' + text(reference.createdAt) + + '; ttlMs=' + text(reference.ttlMs) + '; totalBytes=' + text(reference.totalBytes) + '.]'] + if (object(value.source)) header.push('[Source execution: ' + text(value.source) + ']') + if (source.itemEncoding === 'tagged-json-v1') header.push('[Notice: Stored rows encode items or item JSON chunks. Join chunks for the same index in byteOffset order before parsing; a final chunk alone is not a complete item.]') + const items: unknown[] = Array.isArray(value.items) ? value.items : [] + let body = header.join('\n'), displayed = 0 + for (const item of items) { + const next = body + '\n' + text(item) + // Reserve the final paging notice and keep every displayed stored row intact. + if (bytes(next) > 60 * 1024) break + body = next; displayed++ + } + const displayComplete = displayed === items.length + const notice = '[Notice: displayedRows=' + displayed + '; displayComplete=' + displayComplete + + '; pageComplete=' + text(value.complete) + '; omitted=' + text(value.omitted) + + (displayComplete ? '; nextOffset=' + text(value.nextOffset) : '; repeatOffset=' + text(args.offset ?? 0)) + + '. Page completeness covers stored rows only; source execution and capture completeness are independent.]' + return body + '\n' + notice + (!displayComplete + ? '\nNext: Repeat the same offset with a smaller limit/maxBytes or fewer fields. Do not advance past undisplayed rows.' + : typeof value.nextAction === 'string' ? '\nNext: ' + value.nextAction : '') +} diff --git a/src/policy.ts b/src/policy.ts index 3130f439..fd71be64 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -1,4 +1,4 @@ -import { FILE_TOOLS, IN_PROCESS_TOOLS } from './capabilities/registry.js' +import { FILE_TOOLS, NATIVE_FILE_TOOLS, IN_PROCESS_TOOLS } from './capabilities/registry.js' export { FILE_TOOLS, FILE_MUTATIONS, IN_PROCESS_TOOLS } from './capabilities/registry.js' /** Model-facing routing, kept in one section. No shared registry mutation. */ @@ -15,7 +15,7 @@ export const BASH_TOOL = publicToolName(IN_PROCESS_TOOLS[0]) export function ladderLevels({ published, pwsh = false }: { published: Iterable, pwsh?: boolean }) { const names = new Set(published) return { - files: FILE_TOOLS.some(name => names.has(publicToolName(name))), + files: NATIVE_FILE_TOOLS.some(name => names.has(publicToolName(name))), bash: names.has(BASH_TOOL), pwsh: pwsh === true && names.has('pwsh'), } @@ -33,10 +33,10 @@ interface ToolingPolicyOptions { /** A single compact table; absent tools never get advertised. */ export function renderToolingPolicy({ published, mounted, levels, pwsh7 = false, shellComponent = true, extraGuidance = '' }: ToolingPolicyOptions) { const names = new Set(published ?? (mounted || levels?.files - ? [...FILE_TOOLS, ...(levels?.bash ? IN_PROCESS_TOOLS : [])].map(publicToolName) : [])) + ? [...NATIVE_FILE_TOOLS, ...(levels?.bash ? IN_PROCESS_TOOLS : [])].map(publicToolName) : [])) if (levels?.pwsh) names.add('pwsh') if (!shellComponent) names.delete('pwsh') - const hasFiles = FILE_TOOLS.some(name => names.has(publicToolName(name))) + const hasFiles = NATIVE_FILE_TOOLS.some(name => names.has(publicToolName(name))) const hasBash = names.has(BASH_TOOL) const hasPwsh = shellComponent && names.has('pwsh') if (!hasFiles && !hasBash && !hasPwsh) return '' @@ -60,13 +60,14 @@ export function renderToolingPolicy({ published, mounted, levels, pwsh7 = false, row('copy', 'Copy one regular file, no clobber') row('move', 'Move one regular file, same filesystem') row('remove', 'Remove one regular file (CAS)') + row('result', 'Page / release owned result references') row('bash', 'General commands / builds / git / pipelines / scripts — preferred command executor') if (names.has('pwsh')) lines.push(`| Windows-native cmdlets / registry / services only | pwsh (${pwsh7 || levels?.pwsh ? 'bundled PowerShell 7 via host' : 'host PowerShell'}) |`) lines.push('', '- ops_* arguments are strict: pass only declared fields.') if (hasFiles) lines.push('- Use file tools for file operations and batch independent requests.') if (names.has(publicToolName('grep'))) lines.push('- ops_grep: summary for aggregate file/match totals; count for per-file counts; content for matching text. Follow nextOffset for remaining pages, but do not loop blindly on truncated/failed scans: they are not exhaustive.') if (hasFiles) lines.push( - '- Every file tool has one `path`, resolved relative to the current DSH session cwd. `to` on copy/move uses the same base. Use `/` separators; do not pass root, workspace, cwd, or file_path aliases.', + '- Single-file tools use `path`, resolved relative to the current DSH session cwd. `to` on copy/move uses the same base. Use `/` separators; do not pass workspace, cwd, or file_path aliases. ops_result accepts the original reference root; read_many takes files.', '- Native file tools are UTF-8 first and bounded. glob and grep do not traverse symlinks, junctions, reparse points, hidden files, or `.git`.', ) const globbing = ['glob', 'grep', 'replace'].filter(raw => names.has(publicToolName(raw))) diff --git a/src/ptc/index.js b/src/ptc/index.js index 58aa895b..f39b0b22 100644 --- a/src/ptc/index.js +++ b/src/ptc/index.js @@ -7,6 +7,7 @@ import { publicToolName } from '../policy.js' import { invoke, resolveWorker, failure } from './worker.js' import { mountWorkspacePreparation } from '../workspace-preparation.js' import { mountResultLifecycle } from '../kernel/result-lifecycle.js' +import { nativeResultProvider, claimPtcResult } from '../file/result-provider.js' export const name = 'dsh-ops-ptc' export function resolvePtcConfig(raw = {}) { if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('PTC config must be an object') @@ -71,26 +72,49 @@ export function apply(root, rawConfig) { mutating = true try { for (const [agent, entry] of ready) { - for (const tool of PTC_TOOLS) entry.disposers.push(entry.ctx.tools.register(definition(tool, async (args, exec) => { - const callEpoch = epoch - if (stopped || entries.get(agent) !== entry || exec?.agent !== agent || !await ptcOnly(ctx, agent, exec.signal).catch(() => false)) { - return failure('POLICY_DENIED', 'The calling scope must be in authoritative PTC-only presentation.') - } - if (stopped || callEpoch !== epoch || entries.get(agent) !== entry) return failure('POLICY_DENIED', 'PTC presentation, component or owning scope changed during preparation.') - const controller = new AbortController() - const abort = () => controller.abort(exec.signal?.reason) - if (exec.signal?.aborted) abort(); else exec.signal?.addEventListener('abort', abort, { once: true }) - const timer = setTimeout(() => controller.abort(new Error('Worker deadline exceeded')), WORKER_TIMEOUT_MS); timer.unref?.() - const work = monitoredInvoke(ctx, file, tool, args, exec, controller, config) - active.set(controller, { owner: agent, promise: work }) - try { return await work } - finally { clearTimeout(timer); active.delete(controller); exec.signal?.removeEventListener('abort', abort) } - }))) + for (const tool of PTC_TOOLS) { + const releaseNative = tool === 'result' ? claimPtcResult(entry.ctx, agent) : undefined + try { + const dispose = entry.ctx.tools.register(definition(tool, async (args, exec) => { + const callEpoch = epoch + if (stopped || entries.get(agent) !== entry || exec?.agent !== agent || !await ptcOnly(ctx, agent, exec.signal).catch(() => false)) { + return failure('POLICY_DENIED', 'The calling scope must be in authoritative PTC-only presentation.') + } + if (stopped || callEpoch !== epoch || entries.get(agent) !== entry) return failure('POLICY_DENIED', 'PTC presentation, component or owning scope changed during preparation.') + const controller = new AbortController() + const abort = () => controller.abort(exec.signal?.reason) + if (exec.signal?.aborted) abort(); else exec.signal?.addEventListener('abort', abort, { once: true }) + const timer = setTimeout(() => controller.abort(new Error('Worker deadline exceeded')), WORKER_TIMEOUT_MS); timer.unref?.() + const work = monitoredInvoke(ctx, file, tool, args, exec, controller, config) + active.set(controller, { owner: agent, promise: work }) + try { return await work } + finally { clearTimeout(timer); active.delete(controller); exec.signal?.removeEventListener('abort', abort) } + })) + entry.disposers.push(dispose) + if (releaseNative) entry.disposers.push(releaseNative) + } catch (error) { releaseNative?.(); throw error } + } } // Native/both child scopes must not inherit PTC-only capabilities from ancestors. for (const [agent, entry] of entries) if (entry.ctx && !ready.some(([a]) => a === agent)) { const names = ctx.get('tools').schemas(agent).map(s => s.name) - const deny = PTC_TOOLS.map(publicToolName).filter(n => names.includes(n)) + // A Native child must shadow a PTC parent's richer result definition with the + // file row's own page/release projection. The optional provider never gates PTC. + const provider = nativeResultProvider(entry.ctx, agent) + const nativeResult = provider?.active ? provider.definition : undefined + if (nativeResult && provider.scope !== agent) entry.disposers.push(entry.ctx.tools.register({ ...nativeResult, execute: async (args, exec) => { + const callEpoch = epoch + if (stopped || !provider.active || entries.get(agent) !== entry || exec?.agent !== agent + || await ptcOnly(ctx, agent, exec.signal).catch(() => true)) { + return failure('POLICY_DENIED', 'Native result projection or owning scope is unavailable.') + } + if (stopped || !provider.active || callEpoch !== epoch || entries.get(agent) !== entry) { + return failure('POLICY_DENIED', 'Native result presentation changed during preparation.') + } + return nativeResult.execute(args, exec) + } })) + const deny = PTC_TOOLS.filter(tool => tool !== 'result' || !nativeResult) + .map(publicToolName).filter(n => names.includes(n)) if (deny.length) entry.disposers.push(entry.ctx.tools.restrict({ deny })) } } finally { mutating = false } diff --git a/test/capabilities.test.mjs b/test/capabilities.test.mjs index 950ff76b..d3adfeaf 100644 --- a/test/capabilities.test.mjs +++ b/test/capabilities.test.mjs @@ -1,6 +1,6 @@ /** Registry migration preserves the complete previously published host definitions. */ import { createHash } from 'node:crypto' -import { CAPABILITIES, FILE_CAPABILITIES, PTC_CAPABILITIES, SHELL_CAPABILITIES, capability, capabilitiesFor } from '../lib/capabilities/registry.js' +import { CAPABILITIES, NATIVE_FILE_TOOLS, FILE_CAPABILITIES, PTC_CAPABILITIES, SHELL_CAPABILITIES, capability, capabilitiesFor } from '../lib/capabilities/registry.js' import { FILE_TOOLS, FILE_MUTATIONS, IN_PROCESS_TOOLS, BASH_TOOL, publicToolName } from '../lib/policy.js' import { definition as fileDefinition, PARAMETERS as fileParameters, DESCRIPTIONS as fileDescriptions } from '../lib/file/contract.js' import { definition as ptcDefinition, PTC_TOOLS, PARAMETERS as ptcParameters, DESCRIPTIONS as ptcDescriptions } from '../lib/ptc/contract.js' @@ -31,7 +31,7 @@ await test('the catalogue covers every owned file, PTC and shell tool exactly on await test('metadata preserves current mode availability and conservative concurrency', () => { for (const entry of [...FILE_CAPABILITIES, ...SHELL_CAPABILITIES]) assert.deepEqual(entry.presentations, ['native', 'ptc', 'both']) - for (const entry of PTC_CAPABILITIES) assert.deepEqual(entry.presentations, ['ptc']) + for (const entry of PTC_CAPABILITIES) assert.deepEqual(entry.presentations, entry.rawName === 'result' ? ['native', 'ptc', 'both'] : ['ptc']) for (const entry of CAPABILITIES) { assert.equal(entry.availability, 'resident') assert.equal(entry.concurrencySafe, entry.component === 'file' && !entry.mutates) @@ -40,14 +40,19 @@ await test('metadata preserves current mode availability and conservative concur const snapshot = (names, definition) => names.map(name => { const d = definition(name, async () => null) + // Reference metadata was added in6d; preserve the earlier business argument/output snapshot. + d.output.schema = structuredClone(d.output.schema) + for (const key of ['reference', 'referenceRoot', 'root', 'source', 'storageError', 'nextAction']) { + if (definition === fileDefinition || ['reference', 'source'].includes(key)) delete d.output.schema.properties[key] + } return { name: d.name, description: d.description, parameters: d.parameters, output: d.output.schema, concurrent: d.isConcurrencySafe?.({}) ?? false, timeoutMs: d.timeoutMs ?? null } }) -await test('unchanged file definitions and PTC definitions retain their original serialized contracts', () => { +await test('business file and batch definitions retain their original serialized contracts', () => { // Captured before this migration, covering every argument/output contract and published description. const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') assert.equal(hash(snapshot(FILE_TOOLS.filter(name => !['glob', 'grep', 'read_many', 'edit_many'].includes(name)), fileDefinition)), 'f32e33306b4052fdb3d19a11e0fe9d65c255a87d2d485de08a9d7b1275e958fc') - assert.equal(hash(snapshot(PTC_TOOLS, ptcDefinition)), '74e6a50db799da1aa80c745e18c67be577426cc41441f9d0f8c87673c04f3c43') + assert.equal(hash(snapshot(['batch'], ptcDefinition)), 'f979821d5fc132d365280a27b18930137c94e3208a82cb780a363b0b06659d80') }) await test('native registration consumes the catalogue while execute values stay unchanged', async () => { @@ -55,8 +60,8 @@ await test('native registration consumes the catalogue while execute values stay const runtime = new NativeFileTools({ ctx: { tools: { register(definition) { registered.push(definition); return () => disposed.push(definition.name) } } }, config: { workerPath: 'fixture-worker' } }) - assert.deepEqual(await runtime.start(), FILE_CAPABILITIES.map(item => publicToolName(item.rawName))) - assert.deepEqual(registered.map(item => item.name), FILE_TOOLS.map(publicToolName)) + assert.deepEqual(await runtime.start(), NATIVE_FILE_TOOLS.map(publicToolName)) + assert.deepEqual(registered.map(item => item.name), NATIVE_FILE_TOOLS.map(publicToolName)) const canonical = { status: 'completed', items: [{ path: '/file', line: 1, text: 'text' }], complete: false } for (const [names, definition] of [[FILE_TOOLS, fileDefinition], [PTC_TOOLS, ptcDefinition]]) { for (const name of names) { @@ -68,6 +73,6 @@ await test('native registration consumes the catalogue while execute values stay } } await runtime.stop() - assert.deepEqual(disposed, registered.map(item => item.name)) + assert.deepEqual(disposed.sort(), registered.map(item => item.name).sort()) }) report('capabilities') diff --git a/test/mount.test.mjs b/test/mount.test.mjs index 35adcead..6f09eba8 100644 --- a/test/mount.test.mjs +++ b/test/mount.test.mjs @@ -2,7 +2,8 @@ import fs from 'node:fs' import path from 'node:path' import { pathToFileURL } from 'node:url' -import { BASH_TOOL, FILE_TOOLS, TOOLING_SECTION, publicToolName } from '../lib/policy.js' +import { NATIVE_FILE_TOOLS } from '../lib/capabilities/registry.js' +import { BASH_TOOL, TOOLING_SECTION, publicToolName } from '../lib/policy.js' import { applyComponent } from '../lib/index.js' import * as ptc from '../lib/ptc/index.js' import { resolveWorker } from '../lib/ptc/runtime.js' @@ -16,7 +17,7 @@ import { officialToolHidingMountChecks } from './lib/official-tool-hiding.mjs' // Component 4 has no native runtime dependency; always exercise its real mounts. await officialToolHidingMountChecks() -const EXPECTED = FILE_TOOLS.map(publicToolName).sort() +const EXPECTED = NATIVE_FILE_TOOLS.map(publicToolName).sort() const FILE_SECTION = `${TOOLING_SECTION}:file` const SHELL_SECTION = `${TOOLING_SECTION}:shell` const BG_SECTION = `${TOOLING_SECTION}:background` diff --git a/test/native-result.test.mjs b/test/native-result.test.mjs new file mode 100644 index 00000000..6acc3f3f --- /dev/null +++ b/test/native-result.test.mjs @@ -0,0 +1,228 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import Sandbox from '@deepseek-ai/dsh-sandbox-local' +import { createScope } from '@deepseek-ai/dsh-scope' +import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools' +import { assert, test, report, withTempDir, waitFor } from './lib/harness.mjs' +import { bootHost, mountPlugin, shutdown } from './lib/host.mjs' +import { WORKER, requireWorker } from './lib/boundary.mjs' +import { invoke } from '../lib/ptc/worker.js' +import * as ptc from '../lib/ptc/index.js' +import { applyComponent } from '../lib/index.js' +import { resultStorage } from '../lib/kernel/result-storage.js' +import { resultDefinition } from '../lib/file/result.js' +import { CAPABILITIES, NATIVE_FILE_TOOLS } from '../lib/capabilities/registry.js' +import { NATIVE_RESULT_PARAMETERS, STORAGE_REFERENCE } from '../lib/capabilities/result.js' +requireWorker('native-result') +let serial = 0 +async function setup(root) { + const { ctx } = await bootHost() + await ctx.plugin(Subprocess); await ctx.plugin(Sandbox) + await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: ({ session }) => ({ mode: session.mode, workspaceRoot: root, sessionId: session.id }) })) + const file = await mountPlugin({ ctx, config: { binaryPath: WORKER, required: true }, expectTools: NATIVE_FILE_TOOLS.length }) + return { ctx, file } +} +async function agent(ctx, cwd, presentation = 'native', parent) { + const id = 'native-reference-' + ++serial + const value = { id, session: { id, mode: 'workspace-write', header: { cwd } } } + value.ctx = createScope(parent?.ctx ?? ctx, value).ctx + await value.ctx.plugin({ inject: ['tools'], apply(inner) { value.liftPresentation = inner.tools.presentAs(presentation) } }) + await ctx.serial('agent/created', { agent: value }) + return value +} +const execute = (ctx, agent, args) => ctx.get('tools').execute({ name: 'ops_result', callId: 'native-page-' + ++serial, + arguments: args, agent, signal: new AbortController().signal }) +async function page(ctx, agent, args) { + const result = await execute(ctx, agent, args) + assert.equal(result.isError, false, JSON.stringify(result)) + return { ...result, text: result.content.map(block => block.text ?? '').join('\n') } +} +const put = (ctx, owner, root, items) => invoke(ctx, WORKER, 'result', { action: 'put', root, items }, { agent: owner }, undefined, { denyCredentialReads: true }) + +await test('one catalogue entry exposes distinct SDK-valid Native and PTC result contracts', () => { + const entry = CAPABILITIES.filter(value => value.rawName === 'result') + assert.equal(entry.length, 1); assert.deepEqual(entry[0].presentations, ['native', 'ptc', 'both']) + assert.equal(entry[0].nativeProjection.component, 'file') + assertSupportedJsonSchema(NATIVE_RESULT_PARAMETERS); assertSupportedJsonSchema(STORAGE_REFERENCE) + assert.deepEqual(NATIVE_RESULT_PARAMETERS.properties.action.enum, ['page', 'release']) + assert.deepEqual(entry[0].parameters.properties.action.enum, ['put', 'page', 'release']) + assert.ok(entry[0].parameters.properties.ttlMs); assert.ok(entry[0].output.properties.reference) +}) + +await test('file-only Native SDK execution pages/releases with cwd binding, defaults and no business writes', async () => { + await withTempDir('native-reference', async root => { + const cwd = path.join(root, 'nested'); fs.mkdirSync(cwd); fs.writeFileSync(path.join(cwd, 'keep'), 'unchanged') + const { ctx } = await setup(root) + try { + const owner = await agent(ctx, cwd) + const stored = await put(ctx, owner, cwd, [{ n: 1, text: 'alpha' }, { n: 2, text: 'beta' }]) + assert.equal(stored.status, 'completed', JSON.stringify(stored)) + const first = await page(ctx, owner, { action: 'page', id: stored.id, root: '', limit: 1, fields: [] }) + assert.deepEqual(first.value.items, [{ n: 1, text: 'alpha' }]); assert.equal(first.value.nextOffset, 1) + assert.match(first.text, /displayComplete=true; pageComplete=false/); assert.match(first.text, /alpha/) + const last = await page(ctx, owner, { action: 'page', id: stored.id, offset: 1 }) + assert.equal(last.value.complete, true); assert.deepEqual(last.value.items, [{ n: 2, text: 'beta' }]) + owner.session.header.cwd = root + assert.equal((await page(ctx, owner, { action: 'page', id: stored.id })).value.error.code, 'POLICY_DENIED') + const restored = await page(ctx, owner, { action: 'page', id: stored.id, root: cwd, equals: { n: 2 }, fields: ['text'] }) + assert.deepEqual(restored.value.items, [{ text: 'beta' }]) + assert.equal((await page(ctx, owner, { action: 'release', id: stored.id, root: cwd })).value.status, 'released') + assert.equal((await page(ctx, owner, { action: 'page', id: stored.id, root: cwd })).value.error.code, 'REFERENCE_EXPIRED') + assert.equal(fs.readFileSync(path.join(cwd, 'keep'), 'utf8'), 'unchanged'); assert.equal(fs.existsSync(path.join(root, '.dsh-ops')), false) + } finally { await shutdown(ctx) } + }) +}) + +await test('file-only Native spill pages reconstruct one large escaped line exactly through the real host', async () => { + await withTempDir('native-spill-roundtrip', async root => { + const cwd = path.join(root, 'nested'); fs.mkdirSync(cwd) + const original = '\u0000'.repeat(22000) + '😀end' + fs.writeFileSync(path.join(cwd, 'large.txt'), original) + const { ctx } = await setup(root) + try { + const owner = await agent(ctx, cwd) + const read = await ctx.get('tools').execute({ name: 'ops_inspect_local_file', callId: 'spill-' + ++serial, + arguments: { path: 'large.txt', limit: 1 }, agent: owner, signal: new AbortController().signal }) + assert.equal(read.isError, false, JSON.stringify(read)); assert.ok(read.value.reference, JSON.stringify(read.value)) + assert.equal(read.value.complete, false); assert.equal(path.toNamespacedPath(read.value.referenceRoot), path.toNamespacedPath(fs.realpathSync.native(cwd))) + const rendered = read.content.map(block => block.text ?? '').join('\n') + assert.ok(rendered.includes(read.value.reference.refId)); assert.ok(rendered.includes('referenceRoot=')) + assert.ok(Buffer.byteLength(JSON.stringify(read.content)) <= 65536) + let offset = 0, encoded = '', finished = false, chunks = 0 + for (let i = 0; i < 40; i++) { + const current = await page(ctx, owner, { action: 'page', id: read.value.reference.refId, root: read.value.referenceRoot, offset, maxBytes: 24000 }) + assert.equal(current.value.source.itemEncoding, 'tagged-json-v1'); assert.equal(current.value.source.root, read.value.referenceRoot) + assert.equal(current.value.source.captureComplete, true); assert.equal(current.value.source.capturedItems, 1) + assert.match(current.text, /displayComplete=true/); assert.ok(Buffer.byteLength(JSON.stringify(current.content)) <= 65536) + for (const row of current.value.items) { + assert.equal(row.kind, 'item-json-chunk'); assert.equal(row.index, 0); assert.equal(row.chunk, chunks++) + assert.equal(row.byteOffset, Buffer.byteLength(encoded)); encoded += row.text; finished = row.complete + } + if (current.value.complete) break + assert.ok(current.value.nextOffset > offset); offset = current.value.nextOffset + } + assert.equal(finished, true); assert.ok(chunks > 1) + const recovered = JSON.parse(encoded); assert.equal(recovered.text, original); assert.equal(recovered.line, 1) + assert.equal(fs.readFileSync(path.join(cwd, 'large.txt'), 'utf8'), original) + assert.equal((await page(ctx, owner, { action: 'release', id: read.value.reference.refId, root: read.value.referenceRoot })).value.status, 'released') + } finally { await shutdown(ctx) } + }) +}) + +await test('Native pager denies hidden producers, other sessions, narrower authority, invalid actions and disposed callbacks', async () => { + await withTempDir('native-reference-authority', async root => { + const { ctx, file } = await setup(root) + try { + const owner = await agent(ctx, root), sibling = await agent(ctx, root) + const stored = await put(ctx, owner, root, [{ n: 1 }]); assert.equal(stored.status, 'completed', JSON.stringify(stored)) + assert.equal((await page(ctx, sibling, { action: 'page', id: stored.id })).value.error.code, 'POLICY_DENIED') + owner.session.mode = 'read-only' + assert.equal((await page(ctx, owner, { action: 'page', id: stored.id })).value.error.code, 'POLICY_DENIED') + assert.equal((await page(ctx, owner, { action: 'release', id: stored.id })).value.error.code, 'POLICY_DENIED') + owner.session.mode = 'workspace-write' + for (const args of [{ action: 'put', items: [] }, { action: 'purge' }, { action: 'page', id: 'j-audit' }, { action: 'page', id: stored.id, authority: 'fake' }]) { + const rejected = await page(ctx, owner, args); assert.equal(rejected.value.error.code, 'INVALID_ARGUMENT', JSON.stringify(rejected)) + } + const policy = ctx.get('sandboxPolicy').resolve({ session: owner.session }) + const storage = await resultStorage(ctx, owner.session, owner, WORKER, 'result', policy, [], false) + const blob = path.join(storage.root, 's-' + createHash('sha256').update(owner.session.id).digest('hex'), stored.id.split('/').at(-1) + '.json') + // Trusted fixture rewrites only the producer binding to exercise the shared cache's visibility check. + const record = JSON.parse(fs.readFileSync(blob, 'utf8')); record.producerTool = 'grep'; fs.writeFileSync(blob, JSON.stringify(record)) + const grepId = stored.id.replace('/result/', '/grep/') + assert.equal((await page(ctx, owner, { action: 'page', id: grepId })).value.complete, true) + let restore + await owner.ctx.plugin({ inject: ['tools'], apply(inner) { restore = inner.tools.restrict({ deny: ['ops_grep'] }) } }) + assert.equal((await page(ctx, owner, { action: 'page', id: grepId })).value.error.code, 'POLICY_DENIED'); restore() + const retained = ctx.get('tools').get('ops_result', owner) + await file.fiber.dispose() + assert.equal((await retained.execute({ action: 'page', id: grepId }, { agent: owner })).error.code, 'POLICY_DENIED') + } finally { await shutdown(ctx) } + }) +}) + +await test('Native children of PTC parents retain page-only projection and file unload preserves PTC put', async () => { + await withTempDir('native-reference-scopes', async root => { + const { ctx, file } = await setup(root) + try { + await ctx.plugin(inner => inner.provide('ptcRuntime', { language: 'typescript' })) + const component = await ctx.plugin(ptc, { binaryPath: WORKER, required: true }) + const parent = await agent(ctx, root, 'ptc'), child = await agent(ctx, root, 'native', parent) + const definition = scope => ctx.get('tools').get('ops_result', scope) + await waitFor(() => definition(parent)?.parameters.properties.action.enum.includes('put') + && definition(child)?.parameters.properties.action.enum.length === 2, { timeoutMs: 5000, what: 'Native/PTC result projections' }) + assert.equal(ctx.get('tools').schemas(child).some(tool => tool.name === 'ops_batch'), false) + const stored = await put(ctx, child, root, [{ child: true }]) + assert.deepEqual((await page(ctx, child, { action: 'page', id: stored.id })).value.items, [{ child: true }]) + assert.equal((await page(ctx, child, { action: 'put', items: [] })).value.error.code, 'INVALID_ARGUMENT') + const nativeDefinition = definition(child) + await file.fiber.dispose() + await waitFor(() => !ctx.get('tools').schemas(child).some(tool => tool.name === 'ops_result'), { timeoutMs: 5000, what: 'file pager withdrawal' }) + assert.equal((await nativeDefinition.execute({ action: 'page', id: stored.id }, { agent: child })).error.code, 'POLICY_DENIED') + await waitFor(() => definition(parent)?.parameters.properties.action.enum.includes('put'), { timeoutMs: 5000, what: 'PTC remains available' }) + const ptcStored = await definition(parent).execute({ action: 'put', items: [{ ptc: true }] }, { agent: parent, signal: new AbortController().signal }) + assert.equal(ptcStored.status, 'completed', JSON.stringify(ptcStored)) + await component.dispose(); assert.equal(ctx.get('tools').schemas(parent).some(tool => tool.name === 'ops_result'), false) + assert.equal(Object.hasOwn(ctx.get('tools'), 'register'), false) + } finally { await shutdown(ctx) } + }) +}) + +await test('agent-scoped file rows isolate sibling pagers and yield only their exact layer to PTC', async () => { + await withTempDir('native-reference-scoped-rows', async root => { + const { ctx } = await bootHost() + try { + await ctx.plugin(Subprocess); await ctx.plugin(Sandbox) + await ctx.plugin(inner => { + inner.provide('ptcRuntime', { language: 'typescript' }) + inner.provide('sandboxPolicy', { resolve: ({ session }) => ({ mode: session.mode, workspaceRoot: root, sessionId: session.id }) }) + }) + const component = await ctx.plugin(ptc, { binaryPath: WORKER, required: true }) + const parent = await agent(ctx, root, 'ptc'), left = await agent(ctx, root, 'native', parent), right = await agent(ctx, root, 'native', parent) + const current = value => ctx.get('tools').get('ops_result', value) + const mount = value => value.ctx.plugin({ name: 'scoped-file-' + value.id, apply(inner) { return applyComponent(inner, { binaryPath: WORKER, required: true }, 'file') } }) + const leftFile = await mount(left) + await waitFor(() => current(left)?.parameters.properties.action.enum.length === 2, { timeoutMs: 5000, what: 'left scoped Native pager' }) + assert.equal(current(right), undefined, 'a sibling cannot borrow the left file executor') + const rightFile = await mount(right) + await waitFor(() => current(right)?.parameters.properties.action.enum.length === 2, { timeoutMs: 5000, what: 'right scoped Native pager' }) + const rightStored = await put(ctx, right, root, [{ right: true }]) + assert.deepEqual((await page(ctx, right, { action: 'page', id: rightStored.id })).value.items, [{ right: true }]) + left.liftPresentation() + await left.ctx.plugin({ inject: ['tools'], apply(inner) { inner.tools.presentAs('ptc') } }) + await waitFor(() => current(left)?.parameters.properties.action.enum.includes('put'), { timeoutMs: 5000, what: 'exact-layer PTC takeover' }) + const leftStored = await current(left).execute({ action: 'put', items: [{ left: true }] }, { agent: left, signal: new AbortController().signal }) + assert.equal(leftStored.status, 'completed', JSON.stringify(leftStored)) + assert.equal(current(right).parameters.properties.action.enum.length, 2) + const late = await agent(ctx, root, 'ptc') + await waitFor(() => current(late)?.parameters.properties.action.enum.includes('put'), { timeoutMs: 5000, what: 'PTC before file mount' }) + const lateFile = await mount(late) + await waitFor(() => ctx.get('tools').get('ops_grep', late) && current(late)?.parameters.properties.action.enum.includes('put'), { timeoutMs: 5000, what: 'file mounted after PTC' }) + await component.dispose() + await waitFor(() => current(left)?.parameters.properties.action.enum.length === 2 && current(late)?.parameters.properties.action.enum.length === 2, { timeoutMs: 5000, what: 'Native peer resumes on PTC unload' }) + await leftFile.dispose(); assert.equal(current(left), undefined) + assert.equal(current(right).parameters.properties.action.enum.length, 2) + await rightFile.dispose(); await lateFile.dispose(); assert.equal(current(right), undefined) + } finally { await shutdown(ctx) } + }) +}) + +await test('Native page rendering preserves source/capture facts, complete chunks and bounded recoverability', () => { + const render = resultDefinition(async () => null).output.render + const value = { id: 'ref://s/grep/id', root: '/work', complete: true, nextOffset: 2, omitted: 0, + source: { root: '/original-root', scanComplete: false, capturedItems: 1, captureComplete: true, itemEncoding: 'tagged-json-v1' }, + items: [{ kind: 'item-json-chunk', index: 0, chunk: 0, byteOffset: 0, text: '{"text":', complete: false }, + { kind: 'item-json-chunk', index: 0, chunk: 1, byteOffset: 8, text: '"large"}', complete: true }] } + const text = render({ offset: 0 }, value)[0].text + assert.match(text, /root="\/original-root"/); assert.match(text, /"scanComplete":false/); assert.match(text, /"captureComplete":true/) + assert.match(text, /pageComplete=true/); assert.match(text, /final chunk alone is not a complete item/) + assert.ok(text.includes(JSON.stringify(value.items[1]))) + const big = render({ offset: 7 }, { ...value, items: Array.from({ length: 100 }, (_, n) => ({ n, text: '\u0000😀'.repeat(400) })) }) + assert.ok(Buffer.byteLength(JSON.stringify(big)) <= 65536) + assert.match(big[0].text, /displayComplete=false/); assert.match(big[0].text, /repeatOffset=7/) + assert.match(big[0].text, /Do not advance/) + const failed = render({}, { status: 'error', error: { code: 'REFERENCE_EXPIRED', effectState: 'none', message: 'expired' } })[0].text + assert.match(failed, /REFERENCE_EXPIRED/); assert.doesNotMatch(failed, /pageComplete=true/) +}) +report('native-result') diff --git a/test/ptc-cost.test.mjs b/test/ptc-cost.test.mjs index 039ee4d3..0762fe5c 100644 --- a/test/ptc-cost.test.mjs +++ b/test/ptc-cost.test.mjs @@ -50,7 +50,7 @@ await test('measure PTC enhancement separately from the existing file/shell decl for (const [, agent] of agents) await ctx.serial('agent/created', { agent }) const pure = agents.find(([mode]) => mode === 'ptc')[1] const deadline = Date.now() + 5000 - while (!ctx.get('tools').get('ops_result', pure)) { + while (!ctx.get('tools').get('ops_batch', pure) || !ctx.get('tools').get('ops_result', pure)?.parameters.properties.action.enum.includes('put')) { if (Date.now() > deadline) throw new Error('PTC declarations did not become available') await new Promise(resolve => setTimeout(resolve, 10)) } From d3b7ed8fffa1095372f36b85494a4e07a9354f70 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:10:47 +0800 Subject: [PATCH 023/118] feat(ptc): add canonical outcome projection primitives --- src/modes/ptc/continuation.ts | 62 ++++++++++++++++++++++++ src/modes/ptc/outcome.ts | 71 +++++++++++++++++++++++++++ src/modes/ptc/schema.ts | 21 ++++++++ test/ptc-outcome.test.mjs | 90 +++++++++++++++++++++++++++++++++++ 4 files changed, 244 insertions(+) create mode 100644 src/modes/ptc/continuation.ts create mode 100644 src/modes/ptc/outcome.ts create mode 100644 src/modes/ptc/schema.ts create mode 100644 test/ptc-outcome.test.mjs diff --git a/src/modes/ptc/continuation.ts b/src/modes/ptc/continuation.ts new file mode 100644 index 00000000..54fb0a20 --- /dev/null +++ b/src/modes/ptc/continuation.ts @@ -0,0 +1,62 @@ +/** Continuations describe read-only retrieval. They are data, never authority or automatic retries. */ +import type { CanonicalData, JsonValue } from './outcome.js' +const object = (value: unknown): CanonicalData | undefined => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as CanonicalData : undefined +const integer = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0 +const readers = ['inspect_local_file', 'read_many', 'glob', 'grep', 'diff', 'result'] as const +export type ContinuationTool = typeof readers[number] +export interface RetrievalContinuation { version: 1, tool: ContinuationTool, args: CanonicalData } +const PREFIX = 'ops-next-v1:' +function encode(tool: ContinuationTool, args: CanonicalData): string | undefined { + try { + const token = PREFIX + JSON.stringify({ version: 1, tool, args } satisfies RetrievalContinuation) + return Buffer.byteLength(token) <= 8192 ? token : undefined + } catch { return undefined } +} +/** Decode only; callers choose whether and when to invoke an available SDK binding. */ +export function decodeContinuation(token: string): RetrievalContinuation | undefined { + if (typeof token !== 'string' || Buffer.byteLength(token) > 8192 || !token.startsWith(PREFIX)) return undefined + try { + const value = object(JSON.parse(token.slice(PREFIX.length))) + const args = object(value?.args) + if (!value || value.version !== 1 || !readers.includes(value.tool as ContinuationTool) || !args) return undefined + if (value.tool === 'result' && args.action !== 'page') return undefined + return { version: 1, tool: value.tool as ContinuationTool, args } + } catch { return undefined } +} +function readMany(args: CanonicalData, data: CanonicalData): CanonicalData | undefined { + if (!Array.isArray(args.files) || !Array.isArray(data.items) || !integer(data.nextIndex)) return undefined + const files: JsonValue[] = [] + for (const [index, raw] of data.items.entries()) { + const item = object(raw), request = object(args.files[index]) + if (!item || !request || item.error) return undefined + if (item.complete === false) { + if (!integer(item.nextOffset) || item.nextOffset <= Number(request.offset ?? 1)) return undefined + files.push({ ...request, offset: item.nextOffset }) + } + } + files.push(...args.files.slice(data.nextIndex)) + return files.length ? { files } : undefined +} +export function continuationFor(tool: string, rawArgs: unknown, data: CanonicalData): string | undefined { + const args = object(rawArgs) ?? {} + const reference = object(data.reference) + if (typeof reference?.refId === 'string' && reference.refId.startsWith('ref://')) { + return encode('result', { action: 'page', id: reference.refId, offset: 0, + ...(typeof data.referenceRoot === 'string' ? { root: data.referenceRoot } : {}) }) + } + if (tool === 'result' && args.action === 'page' && integer(data.nextOffset) && data.nextOffset > Number(args.offset ?? 0)) { + return encode('result', { ...args, offset: data.nextOffset }) + } + if ((tool === 'grep' || tool === 'glob') && data.scanComplete === true && typeof data.continuation === 'string') { + return encode(tool, { ...args, continuation: data.continuation, ...(integer(data.nextOffset) ? { offset: data.nextOffset } : {}) }) + } + if (tool === 'read_many') { + const next = readMany(args, data) + return next ? encode('read_many', next) : undefined + } + if ((tool === 'inspect_local_file' || tool === 'diff') && integer(data.nextOffset) + && data.nextOffset > Number(args.offset ?? (tool === 'inspect_local_file' ? 1 : 0))) { + return encode(tool, { ...args, offset: data.nextOffset }) + } + return undefined +} diff --git a/src/modes/ptc/outcome.ts b/src/modes/ptc/outcome.ts new file mode 100644 index 00000000..0a491972 --- /dev/null +++ b/src/modes/ptc/outcome.ts @@ -0,0 +1,71 @@ +/** The PTC projection preserves canonical facts and never replays an operation. */ +import { errorPolicy } from '../../kernel/errors.js' +import type { CoverageMeta, EffectState, Outcome } from '../../kernel/outcome.js' +import { continuationFor } from './continuation.js' + +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } +export type CanonicalData = { [key: string]: JsonValue } +export function record(value: unknown): CanonicalData | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as CanonicalData : undefined +} +export const count = (value: unknown): number => Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : 0 +const effects = new Set(['none', 'applied', 'partial', 'unknown']) +function effect(value: CanonicalData, fallback: EffectState): EffectState { + return typeof value.effectState === 'string' && effects.has(value.effectState as EffectState) + ? value.effectState as EffectState : fallback +} +function failure(data: CanonicalData | undefined, code: string, message: string, state: EffectState, nextAction?: string): Outcome { + const policy = errorPolicy(code, state) + return { status: policy.status, error: { code, message, effectState: state, retry: policy.retry, + ...(nextAction ? { nextAction } : {}) }, ...(data ? { data } : {}) } +} +function coverage(data: CanonicalData, complete: boolean): CoverageMeta { + const cause = data.truncatedBy === 'time' ? 'time-limit' : data.truncatedBy + const truncatedBy = cause === 'scan-budget' || cause === 'output-budget' || cause === 'time-limit' || cause === 'row-limit' + ? cause : data.truncated === true ? 'output-budget' : undefined + return { scannedFiles: count(data.scannedFiles), matchedCount: count(data.matchedObserved ?? data.matched ?? data.done), + isComplete: complete, ...(truncatedBy ? { truncatedBy } : {}) } +} +/** Incomplete observations without a safe cursor are errors with retained data, never fabricated pages. */ +export function toOutcome(tool: string, args: unknown, value: unknown): Outcome { + const data = record(value) + if (!data) return failure(undefined, 'RESULT_UNKNOWN', 'The worker did not return a canonical object.', 'unknown') + const error = record(data.error) + if (error) return failure(data, typeof error.code === 'string' ? error.code : 'RESULT_UNKNOWN', + typeof error.message === 'string' ? error.message : 'The operation did not return a settled receipt.', effect(error, 'unknown'), + typeof error.nextAction === 'string' ? error.nextAction : undefined) + if (data.status !== undefined && !['completed', 'planned', 'partial', 'partial_completion', 'unknown', 'rejected', 'conflict', 'error'].includes(String(data.status))) { + return failure(data, 'RESULT_UNKNOWN', 'The worker returned an unrecognized execution status.', 'unknown') + } + if (data.status === undefined && !(tool === 'stat' && typeof data.exists === 'boolean') + && !(typeof data.complete === 'boolean' && Array.isArray(data.items))) { + return failure(data, 'RESULT_UNKNOWN', 'The worker omitted its execution receipt.', 'unknown') + } + const next = typeof data.nextAction === 'string' ? data.nextAction : undefined + if (data.status === 'unknown' || count(data.unknown) > 0) { + return failure(data, 'RESULT_UNKNOWN', 'Some effects are unknown; inspect retained observations before any recovery.', 'unknown', next) + } + if (data.status === 'rejected' || data.status === 'conflict' || data.status === 'error') { + return failure(data, 'RESULT_UNKNOWN', 'A failed receipt omitted its structured error.', 'unknown', next) + } + // Batch summaries intentionally omit audit rows. Successful planning/execution is still complete. + if (tool === 'batch') { + if (data.status === 'completed' || data.status === 'planned') return { status: 'completed', data } + return failure(data, 'RESULT_PARTIAL', 'The batch contains failed or unsettled operations; inspect detailsRef.', + count(data.done) > 0 ? 'partial' : 'none', next) + } + // Stored rows are not echoed by put/release; their own successful operation is complete. + if (tool === 'result' && record(args)?.action !== 'page' && data.status === 'completed') return { status: 'completed', data } + if (count(data.failed) > 0) return failure(data, 'RESULT_PARTIAL', 'Some items failed; inspect each retained receipt.', + effect(data, count(data.changed) > 0 ? 'partial' : 'none'), next) + const incomplete = data.complete === false || data.truncated === true || data.scanComplete === false + || data.pageComplete === false || data.status === 'partial' || data.status === 'partial_completion' + if (incomplete) { + const continuation = continuationFor(tool, args, data) + if (continuation) return { status: 'partial', data, coverage: { ...coverage(data, false), isComplete: false }, continuation } + return failure(data, 'RESULT_PARTIAL', 'Observation is incomplete and has no safe continuation; refine the request.', + effect(data, count(data.changed) > 0 ? 'applied' : 'none'), next) + } + return { status: 'completed', data, ...(typeof data.scanComplete === 'boolean' + ? { coverage: { ...coverage(data, true), isComplete: true as const } } : {}) } +} diff --git a/src/modes/ptc/schema.ts b/src/modes/ptc/schema.ts new file mode 100644 index 00000000..a2c54c52 --- /dev/null +++ b/src/modes/ptc/schema.ts @@ -0,0 +1,21 @@ +/** Discriminated PTC envelopes feed the pinned host's validation and SDK generator. */ +import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +const string: JsonSchemaNode = { type: 'string' } +const integer: JsonSchemaNode = { type: 'integer' } +const object = (properties: Record, required: string[]): JsonSchemaNode => + ({ type: 'object', properties, required, additionalProperties: false }) +const coverage = (complete: boolean): JsonSchemaNode => object({ scannedFiles: integer, matchedCount: integer, + isComplete: { type: 'boolean', const: complete }, + truncatedBy: { type: 'string', enum: ['scan-budget', 'output-budget', 'time-limit', 'row-limit'] }, +}, ['scannedFiles', 'matchedCount', 'isComplete']) +export const ERROR_SCHEMA = object({ code: string, message: string, nextAction: string, + effectState: { type: 'string', enum: ['none', 'applied', 'partial', 'unknown'] }, + retry: { type: 'string', enum: ['never', 'after-input-change', 'after-state-change', 'bounded-transient'] }, +}, ['code', 'message', 'effectState', 'retry']) +export function outcomeSchema(data: JsonSchemaNode): JsonSchemaNode { + return { oneOf: [ + object({ status: { type: 'string', const: 'completed' }, data, coverage: coverage(true) }, ['status', 'data']), + object({ status: { type: 'string', const: 'partial' }, data, coverage: coverage(false), continuation: string }, ['status', 'data', 'coverage', 'continuation']), + object({ status: { type: 'string', enum: ['rejected', 'conflict', 'error', 'unknown'] }, error: ERROR_SCHEMA, data }, ['status', 'error']), + ] } +} diff --git a/test/ptc-outcome.test.mjs b/test/ptc-outcome.test.mjs new file mode 100644 index 00000000..9de97206 --- /dev/null +++ b/test/ptc-outcome.test.mjs @@ -0,0 +1,90 @@ +import { assertSupportedJsonSchema, jsonSchemaToTs, validateJsonSchemaValue } from '@deepseek-ai/dsh-tools' +import { toOutcome } from '../lib/modes/ptc/outcome.js' +import { continuationFor, decodeContinuation } from '../lib/modes/ptc/continuation.js' +import { outcomeSchema } from '../lib/modes/ptc/schema.js' +import { assert, test, report } from './lib/harness.mjs' + +await test('successful observations retain exact canonical values without Native text', () => { + const data = { status: 'completed', complete: true, items: [{ line: 1, text: 'α\r\n😀' }], revision: 'r' } + const before = structuredClone(data), out = toOutcome('inspect_local_file', { path: '/a' }, data) + assert.deepEqual(out, { status: 'completed', data }) + assert.equal(out.data, data) + assert.deepEqual(data, before) + assert.equal(toOutcome('stat', {}, { exists: false, revision: 'absent' }).status, 'completed') +}) +await test('failures use stable retry policies and retain effect-bearing data', () => { + for (const [code, effectState, status, retry] of [ + ['POLICY_DENIED','none','rejected','never'], ['REVISION_CONFLICT','none','conflict','after-state-change'], + ['INVALID_ARGUMENT','none','error','after-input-change'], ['JOB_BUSY','none','error','bounded-transient'], + ['REVISION_CONFLICT','applied','error','never'], ['IO_ERROR','partial','error','never'], + ['IO_ERROR','unknown','unknown','never'], + ]) { + const data = { status: 'error', error: { code, effectState, message: 'failed', nextAction: 'Inspect.' } } + const out = toOutcome('write_file', {}, data) + assert.equal(out.status, status); assert.equal(out.error.retry, retry) + assert.equal(out.error.effectState, effectState); assert.equal(out.data, data) + assert.equal(out.error.nextAction, 'Inspect.') + } + assert.equal(toOutcome('write_file', {}, { status: 'error', error: { code: 'IO_ERROR' } }).status, 'unknown') + assert.equal(toOutcome('batch', {}, { status: 'unknown', unknown: 1, done: 2 }).error.retry, 'never') + assert.equal(toOutcome('write_file', {}, null).status, 'unknown') + assert.equal(toOutcome('write_file', {}, {}).status, 'unknown') + assert.equal(toOutcome('write_file', {}, { status: 'future-success' }).status, 'unknown') +}) +await test('batch summaries distinguish successful plans from partial or unknown effects', () => { + for (const status of ['planned','completed']) assert.equal(toOutcome('batch', {}, { status, complete: false, detailsRef: 'j-a' }).status, 'completed') + const failed = toOutcome('batch', {}, { status: 'partial_completion', done: 2, failed: 1, detailsRef: 'j-a' }) + assert.equal(failed.status, 'error'); assert.equal(failed.error.effectState, 'partial') + assert.equal(failed.error.retry, 'never'); assert.equal(failed.data.detailsRef, 'j-a') + assert.equal('continuation' in failed, false, 'never turn a failed mutation into a replay cursor') +}) +await test('result creation succeeds without echoing rows while partial pages continue readonly', () => { + assert.equal(toOutcome('result', { action: 'put' }, { status: 'completed', complete: false, reference: { refId: 'ref://s/result/x' } }).status, 'completed') + const out = toOutcome('result', { action: 'page', id: 'ref://s/result/x', root: '/original', limit: 1 }, { complete: false, nextOffset: 1, items: [{ n: 0 }] }) + assert.equal(out.status, 'partial'); assert.equal(out.coverage.isComplete, false) + assert.deepEqual(decodeContinuation(out.continuation), { version: 1, tool: 'result', args: { action: 'page', id: 'ref://s/result/x', root: '/original', limit: 1, offset: 1 } }) +}) +await test('search gaps never claim completed coverage or invent resumable scan cursors', () => { + const data = { complete: false, scanComplete: false, pageComplete: true, scannedFiles: 10000, matchedObserved: 8, truncatedBy: 'scan-budget', items: [] } + const out = toOutcome('grep', { pattern: 'x' }, data) + assert.equal(out.status, 'error'); assert.equal(out.error.code, 'RESULT_PARTIAL') + assert.equal(out.data, data); assert.equal('matchedTotal' in out.data, false) + const page = toOutcome('grep', { pattern: 'x', glob: ['*.ts'] }, { ...data, scanComplete: true, matchedTotal: 9, continuation: 'stable', nextOffset: 8, truncatedBy: 'row-limit' }) + assert.equal(page.status, 'partial'); assert.equal(page.coverage.matchedCount, 8) + assert.deepEqual(decodeContinuation(page.continuation).args, { pattern: 'x', glob: ['*.ts'], continuation: 'stable', offset: 8 }) +}) +await test('reference retrieval preserves original root and never resumes the producing mutation', () => { + const data = { status: 'completed', changed: 3, complete: false, referenceRoot: '/original', reference: { refId: 'ref://s/replace/x' }, storageError: { code: 'QUOTA_EXCEEDED', effectState: 'applied' } } + const out = toOutcome('replace', { pattern: 'x', replacement: 'y' }, data) + assert.equal(out.status, 'partial') + assert.deepEqual(decodeContinuation(out.continuation), { version: 1, tool: 'result', args: { action: 'page', id: 'ref://s/replace/x', offset: 0, root: '/original' } }) + assert.equal(out.data.storageError.effectState, 'applied'); assert.equal(out.data.changed, 3) +}) +await test('line and read_many cursors retain unconsumed ranges and reject zero-progress long lines', () => { + const args = { files: [{ path: '/a', offset: 1, limit: 2 }, { path: '/b' }, { path: '/pending' }] } + const data = { status: 'partial', complete: false, nextIndex: 2, items: [{ complete: false, nextOffset: 3 }, { complete: true }] } + assert.deepEqual(decodeContinuation(toOutcome('read_many', args, data).continuation).args, { files: [{ path: '/a', offset: 3, limit: 2 }, { path: '/pending' }] }) + const clipped = toOutcome('inspect_local_file', { path: '/a', offset: 1 }, { complete: false, truncated: true, nextOffset: 1, items: [{ truncatedInLine: true }] }) + assert.equal(clipped.status, 'error'); assert.equal('continuation' in clipped, false) + assert.equal(toOutcome('read_many', args, { ...data, failed: 1 }).status, 'error') +}) +await test('bounded continuation decoding never admits mutation or result-release tokens', () => { + for (const tool of ['write_file','edit_many','batch','bash']) assert.equal(decodeContinuation('ops-next-v1:' + JSON.stringify({ version: 1, tool, args: {} })), undefined) + assert.equal(decodeContinuation('ops-next-v1:' + JSON.stringify({ version: 1, tool: 'result', args: { action: 'release' } })), undefined) + assert.equal(decodeContinuation('garbage'), undefined) + assert.equal(continuationFor('diff', { content: 'x'.repeat(10000) }, { nextOffset: 2 }), undefined) + const cycle = {}; cycle.self = cycle + assert.equal(continuationFor('diff', cycle, { nextOffset: 2 }), undefined) +}) +await test('host schema validation and generated SDK enforce the discriminated envelope', () => { + const schema = outcomeSchema({ type: 'object', properties: { value: { type: 'integer' } }, required: ['value'], additionalProperties: false }) + assertSupportedJsonSchema(schema) + const sdk = jsonSchemaToTs(schema) + assert.match(sdk, /status: "completed"/); assert.match(sdk, /continuation: string/) + assert.doesNotMatch(sdk, /\bany\b|:\s*unknown\b/) + assert.deepEqual(validateJsonSchemaValue(schema, { status: 'completed', data: { value: 2 } }), []) + for (const bad of [{ status: 'partial', data: { value: 2 } }, { status: 'completed', data: { value: 2 }, coverage: { scannedFiles: 0, matchedCount: 0, isComplete: false } }, { status: 'error' }]) { + assert.ok(validateJsonSchemaValue(schema, bad).length > 0) + } +}) +report('ptc-outcome') From 4a23ad2842d16223d76cae80abc4d9e6896dd7f9 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:19:34 +0800 Subject: [PATCH 024/118] feat(ptc): expose canonical file schemas through outcome definitions --- src/modes/ptc/canonical-schema.ts | 40 +++++++++++++++++++++++++++++++ src/modes/ptc/continuation.ts | 11 +++++---- src/modes/ptc/outcome.ts | 8 +++++++ src/modes/ptc/schema.ts | 3 ++- src/ptc/contract.ts | 32 ++++++++++++++++++------- test/ptc-outcome.test.mjs | 13 +++++++++- 6 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 src/modes/ptc/canonical-schema.ts diff --git a/src/modes/ptc/canonical-schema.ts b/src/modes/ptc/canonical-schema.ts new file mode 100644 index 00000000..1a69c014 --- /dev/null +++ b/src/modes/ptc/canonical-schema.ts @@ -0,0 +1,40 @@ +/** Typed fields describe canonical data, independently of the Native display projection. */ +import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import { capability } from '../../capabilities/registry.js' +import type { FileToolName, PtcToolName } from '../../capabilities/registry.js' +import { STORAGE_REFERENCE } from '../../capabilities/result.js' +const string: JsonSchemaNode = { type: 'string' } +const integer: JsonSchemaNode = { type: 'integer' } +const boolean: JsonSchemaNode = { type: 'boolean' } +const object = (properties: Record): JsonSchemaNode => ({ type: 'object', properties }) +const array = (items: JsonSchemaNode): JsonSchemaNode => ({ type: 'array', items }) +const physicalLine = object({ path: string, line: integer, text: string, view: string, offset: integer, + truncatedInLine: boolean, bytesTotal: integer, bytesOmitted: integer }) +const receipt = object({ path: string, to: string, revision: string, revisionBefore: string, + bytes: integer, bytesBefore: integer, bytesAfter: integer, lineCount: integer, removed: boolean, + metadataWarnings: array(string) }) +const facts = { revision: string, bytes: integer, lineCount: { oneOf: [integer, { type: 'null' }] } satisfies JsonSchemaNode } +const cache = { reference: STORAGE_REFERENCE, referenceRoot: string, + storageError: object({ code: string, message: string, effectState: { type: 'string', enum: ['none', 'applied', 'partial', 'unknown'] } }) } +const coverage = { scanComplete: boolean, pageComplete: boolean, scannedFiles: integer, skippedFiles: integer, + skipReasons: { type: 'object', description: 'Nonnegative counts keyed by skip reason.' } satisfies JsonSchemaNode, + matchedObserved: integer, matchedTotal: integer, truncatedBy: string, continuation: string } +const range = { ...facts, path: string, status: string, complete: boolean, truncated: boolean, + nextOffset: integer, total: integer, omitted: integer, nextAction: string, error: { type: 'object' } satisfies JsonSchemaNode } + +export function canonicalOutput(tool: FileToolName | PtcToolName): JsonSchemaNode { + const entry = capability(tool) + if (!entry || !('output' in entry)) throw new Error(`Missing canonical schema: ${tool}`) + const properties: Record = { ...entry.output.properties, ...cache } + if (tool === 'inspect_local_file') Object.assign(properties, facts, { path: string, items: array(physicalLine) }) + else if (tool === 'read_many') properties.items = array(object({ ...range, items: array(physicalLine) })) + else if (tool === 'glob') properties.items = array({ oneOf: [string, object({ path: string, bytes: integer, modified: integer })] }) + else if (tool === 'grep') properties.items = array(object({ path: string, line: integer, text: string, count: integer, matches: integer })) + else if (tool === 'stat') Object.assign(properties, facts, { exists: boolean, path: string, readonly: boolean, mode: string, modifiedMs: integer }) + else if (tool === 'diff') Object.assign(properties, { before: object(facts), after: object(facts), + hunk: object({ startLine: integer, removed: integer, added: integer }), items: array(object({ kind: string, line: integer, text: string })) }) + else if (tool === 'result') properties.source = object({ ...range, ...coverage, root: string, + itemEncoding: string, capturedItems: integer, captureComplete: boolean }) + else if (tool !== 'batch') properties.items = array(receipt) + return { ...entry.output, properties } +} diff --git a/src/modes/ptc/continuation.ts b/src/modes/ptc/continuation.ts index 54fb0a20..459aeffc 100644 --- a/src/modes/ptc/continuation.ts +++ b/src/modes/ptc/continuation.ts @@ -24,7 +24,8 @@ export function decodeContinuation(token: string): RetrievalContinuation | undef } catch { return undefined } } function readMany(args: CanonicalData, data: CanonicalData): CanonicalData | undefined { - if (!Array.isArray(args.files) || !Array.isArray(data.items) || !integer(data.nextIndex)) return undefined + if (!Array.isArray(args.files) || !Array.isArray(data.items) || !integer(data.nextIndex) + || data.nextIndex === 0 || data.nextIndex !== data.items.length) return undefined const files: JsonValue[] = [] for (const [index, raw] of data.items.entries()) { const item = object(raw), request = object(args.files[index]) @@ -39,14 +40,16 @@ function readMany(args: CanonicalData, data: CanonicalData): CanonicalData | und } export function continuationFor(tool: string, rawArgs: unknown, data: CanonicalData): string | undefined { const args = object(rawArgs) ?? {} + // A page carries its original reference too; advance that page before considering a new spill. + if (tool === 'result') { + return args.action === 'page' && integer(data.nextOffset) && data.nextOffset > Number(args.offset ?? 0) + ? encode('result', { ...args, offset: data.nextOffset }) : undefined + } const reference = object(data.reference) if (typeof reference?.refId === 'string' && reference.refId.startsWith('ref://')) { return encode('result', { action: 'page', id: reference.refId, offset: 0, ...(typeof data.referenceRoot === 'string' ? { root: data.referenceRoot } : {}) }) } - if (tool === 'result' && args.action === 'page' && integer(data.nextOffset) && data.nextOffset > Number(args.offset ?? 0)) { - return encode('result', { ...args, offset: data.nextOffset }) - } if ((tool === 'grep' || tool === 'glob') && data.scanComplete === true && typeof data.continuation === 'string') { return encode(tool, { ...args, continuation: data.continuation, ...(integer(data.nextOffset) ? { offset: data.nextOffset } : {}) }) } diff --git a/src/modes/ptc/outcome.ts b/src/modes/ptc/outcome.ts index 0a491972..bec60334 100644 --- a/src/modes/ptc/outcome.ts +++ b/src/modes/ptc/outcome.ts @@ -34,6 +34,14 @@ export function toOutcome(tool: string, args: unknown, value: unknown): Outcome< if (error) return failure(data, typeof error.code === 'string' ? error.code : 'RESULT_UNKNOWN', typeof error.message === 'string' ? error.message : 'The operation did not return a settled receipt.', effect(error, 'unknown'), typeof error.nextAction === 'string' ? error.nextAction : undefined) + const action = record(args)?.action + if (tool === 'result' && action === 'release' && data.status === 'released' && typeof data.id === 'string') { + return { status: 'completed', data } + } + if (tool === 'result' && action === 'put' && data.status === undefined && typeof data.id === 'string' + && typeof data.contentHash === 'string' && Number.isSafeInteger(data.total) && Number(data.total) >= 0) { + return { status: 'completed', data } + } if (data.status !== undefined && !['completed', 'planned', 'partial', 'partial_completion', 'unknown', 'rejected', 'conflict', 'error'].includes(String(data.status))) { return failure(data, 'RESULT_UNKNOWN', 'The worker returned an unrecognized execution status.', 'unknown') } diff --git a/src/modes/ptc/schema.ts b/src/modes/ptc/schema.ts index a2c54c52..2767e645 100644 --- a/src/modes/ptc/schema.ts +++ b/src/modes/ptc/schema.ts @@ -13,9 +13,10 @@ export const ERROR_SCHEMA = object({ code: string, message: string, nextAction: retry: { type: 'string', enum: ['never', 'after-input-change', 'after-state-change', 'bounded-transient'] }, }, ['code', 'message', 'effectState', 'retry']) export function outcomeSchema(data: JsonSchemaNode): JsonSchemaNode { + const failureData: JsonSchemaNode = { ...data, required: [], properties: { ...data.properties, error: { type: 'object' } } } return { oneOf: [ object({ status: { type: 'string', const: 'completed' }, data, coverage: coverage(true) }, ['status', 'data']), object({ status: { type: 'string', const: 'partial' }, data, coverage: coverage(false), continuation: string }, ['status', 'data', 'coverage', 'continuation']), - object({ status: { type: 'string', enum: ['rejected', 'conflict', 'error', 'unknown'] }, error: ERROR_SCHEMA, data }, ['status', 'error']), + object({ status: { type: 'string', enum: ['rejected', 'conflict', 'error', 'unknown'] }, error: ERROR_SCHEMA, data: failureData }, ['status', 'error']), ] } } diff --git a/src/ptc/contract.ts b/src/ptc/contract.ts index 6f435c17..25917fab 100644 --- a/src/ptc/contract.ts +++ b/src/ptc/contract.ts @@ -1,15 +1,29 @@ -/** Existing host adapter over the shared capability catalogue. */ +/** PTC-only typed projection over the shared capability catalogue and canonical worker. */ import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import { publicToolName } from '../policy.js' -import type { PtcToolName } from '../capabilities/registry.js' +import { capability, FILE_TOOLS, PTC_TOOLS } from '../capabilities/registry.js' +import type { FileToolName, PtcToolName } from '../capabilities/registry.js' import { PARAMETERS, DESCRIPTIONS, OUTPUT } from '../capabilities/ptc.js' -export { PTC_TOOLS } from '../capabilities/registry.js' -export { PARAMETERS, DESCRIPTIONS, OUTPUT } +import { outcomeSchema } from '../modes/ptc/schema.js' +import { canonicalOutput } from '../modes/ptc/canonical-schema.js' +import { toOutcome } from '../modes/ptc/outcome.js' +import { WORKER_TIMEOUT_MS } from '../worker-protocol.js' +export { PTC_TOOLS, PARAMETERS, DESCRIPTIONS, OUTPUT } +export const PTC_API_TOOLS = Object.freeze([...FILE_TOOLS, ...PTC_TOOLS]) export const PTC_SECTION = 'dsh-ops:repository-tooling:ptc' -export function definition(tool: PtcToolName, execute: ToolDefinition['execute']): ToolDefinition { - return { name: publicToolName(tool), description: DESCRIPTIONS[tool], parameters: PARAMETERS[tool], - timeoutMs: 300000, - output: { schema: OUTPUT, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] }, - execute, +export function definition(tool: FileToolName | PtcToolName, execute: ToolDefinition['execute']): ToolDefinition { + const entry = capability(tool) + if (!entry || !('parameters' in entry)) throw new Error(`Missing PTC capability: ${tool}`) + const description = entry.description.replace(/ Failures resolve as .*?they are not thrown\./, '') + return { name: publicToolName(tool), description: description + + ' PTC returns Outcome: completed.data; partial.data, coverage and continuation; failures use error.code/effectState/retry. Check status before reading data; never blindly retry a possible write.', + parameters: entry.parameters, timeoutMs: WORKER_TIMEOUT_MS + 2000, + ...(entry.concurrencySafe ? { isConcurrencySafe: () => true } : {}), + output: { schema: outcomeSchema(canonicalOutput(tool)), render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] }, + async execute(args, exec) { + try { return toOutcome(tool, args, await execute(args, exec)) } + catch { return toOutcome(tool, args, { status: 'unknown', error: { code: 'RESULT_UNKNOWN', effectState: 'unknown', + message: 'The operation did not return a settled receipt. Inspect postconditions before recovery.' } }) } + }, } } diff --git a/test/ptc-outcome.test.mjs b/test/ptc-outcome.test.mjs index 9de97206..cd247834 100644 --- a/test/ptc-outcome.test.mjs +++ b/test/ptc-outcome.test.mjs @@ -40,10 +40,16 @@ await test('batch summaries distinguish successful plans from partial or unknown }) await test('result creation succeeds without echoing rows while partial pages continue readonly', () => { assert.equal(toOutcome('result', { action: 'put' }, { status: 'completed', complete: false, reference: { refId: 'ref://s/result/x' } }).status, 'completed') - const out = toOutcome('result', { action: 'page', id: 'ref://s/result/x', root: '/original', limit: 1 }, { complete: false, nextOffset: 1, items: [{ n: 0 }] }) + const out = toOutcome('result', { action: 'page', id: 'ref://s/result/x', root: '/original', limit: 1 }, { complete: false, nextOffset: 1, reference: { refId: 'ref://s/result/x' }, items: [{ n: 0 }] }) assert.equal(out.status, 'partial'); assert.equal(out.coverage.isComplete, false) assert.deepEqual(decodeContinuation(out.continuation), { version: 1, tool: 'result', args: { action: 'page', id: 'ref://s/result/x', root: '/original', limit: 1, offset: 1 } }) }) +await test('successful release and legacy put receipts remain completed', () => { + const released = { status: 'released', id: 'ref://s/result/x' } + assert.equal(toOutcome('result', { action: 'release' }, released).status, 'completed') + assert.equal(toOutcome('write_file', {}, released).status, 'unknown') + assert.equal(toOutcome('result', { action: 'put' }, { id: 'r-x', total: 2, contentHash: 'sha256:x', complete: false }).status, 'completed') +}) await test('search gaps never claim completed coverage or invent resumable scan cursors', () => { const data = { complete: false, scanComplete: false, pageComplete: true, scannedFiles: 10000, matchedObserved: 8, truncatedBy: 'scan-budget', items: [] } const out = toOutcome('grep', { pattern: 'x' }, data) @@ -67,6 +73,11 @@ await test('line and read_many cursors retain unconsumed ranges and reject zero- const clipped = toOutcome('inspect_local_file', { path: '/a', offset: 1 }, { complete: false, truncated: true, nextOffset: 1, items: [{ truncatedInLine: true }] }) assert.equal(clipped.status, 'error'); assert.equal('continuation' in clipped, false) assert.equal(toOutcome('read_many', args, { ...data, failed: 1 }).status, 'error') + const original = { files: Array.from({ length: 32 }, (_, n) => ({ path: `/${n}` })) } + const refusedSpill = { status: 'partial', complete: false, nextIndex: 0, pending: 32, + items: Array.from({ length: 14 }, () => ({ complete: true })), storageError: { code: 'POLICY_DENIED' } } + const halted = toOutcome('read_many', original, refusedSpill) + assert.equal(halted.status, 'error'); assert.equal('continuation' in halted, false, 'a bounded head never repeats the same full request') }) await test('bounded continuation decoding never admits mutation or result-release tokens', () => { for (const tool of ['write_file','edit_many','batch','bash']) assert.equal(decodeContinuation('ops-next-v1:' + JSON.stringify({ version: 1, tool, args: {} })), undefined) From 490ab77837f72f5409f6fb1635e77c20a2585e42 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:25:49 +0800 Subject: [PATCH 025/118] fix(file): scope pager guidance to its visible projection --- src/policy.ts | 4 +++- test/manifest.test.mjs | 6 +++--- test/policy.test.mjs | 5 ++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/policy.ts b/src/policy.ts index fd71be64..fec5b1c1 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -67,9 +67,11 @@ export function renderToolingPolicy({ published, mounted, levels, pwsh7 = false, if (hasFiles) lines.push('- Use file tools for file operations and batch independent requests.') if (names.has(publicToolName('grep'))) lines.push('- ops_grep: summary for aggregate file/match totals; count for per-file counts; content for matching text. Follow nextOffset for remaining pages, but do not loop blindly on truncated/failed scans: they are not exhaustive.') if (hasFiles) lines.push( - '- Single-file tools use `path`, resolved relative to the current DSH session cwd. `to` on copy/move uses the same base. Use `/` separators; do not pass workspace, cwd, or file_path aliases. ops_result accepts the original reference root; read_many takes files.', + '- Single-file tools use `path`, resolved relative to the current DSH session cwd. `to` on copy/move uses the same base. Use `/` separators; do not pass workspace, cwd, or file_path aliases.', '- Native file tools are UTF-8 first and bounded. glob and grep do not traverse symlinks, junctions, reparse points, hidden files, or `.git`.', ) + if (names.has(publicToolName('result'))) lines.push(`- ${publicToolName('result')} accepts the original reference root; page completeness describes stored rows only.`) + if (names.has(publicToolName('read_many'))) lines.push(`- ${publicToolName('read_many')} takes files with independent ranges and per-file recovery.`) const globbing = ['glob', 'grep', 'replace'].filter(raw => names.has(publicToolName(raw))) if (globbing.length) { const arrays = [names.has(publicToolName('glob')) && 'ops_glob `pattern`', (names.has(publicToolName('grep')) || names.has(publicToolName('replace'))) && 'the `glob` filters'].filter(Boolean).join(' and ') diff --git a/test/manifest.test.mjs b/test/manifest.test.mjs index cebb1f30..8101cf44 100644 --- a/test/manifest.test.mjs +++ b/test/manifest.test.mjs @@ -10,7 +10,6 @@ import fs from 'node:fs' import path from 'node:path' import { - FILE_TOOLS, HOST_SHELL_SECTION, IN_PROCESS_TOOLS, TOOLING_SECTION, @@ -18,6 +17,7 @@ import { publicToolName, renderToolingPolicy, } from '../lib/policy.js' +import { NATIVE_FILE_TOOLS } from '../lib/capabilities/registry.js' import { assert, PACKAGE_ROOT, report, test } from './lib/harness.mjs' const manifestFile = path.join(PACKAGE_ROOT, 'dsh-plugin.json') @@ -64,8 +64,8 @@ await test('every declared tool is one the policy actually names', () => { const hosted = namespaces[0] assert.deepEqual( [...hosted.tools].sort(), - [...FILE_TOOLS].sort(), - 'the native namespace declares exactly the four file tools', + [...NATIVE_FILE_TOOLS].sort(), + 'the native namespace declares the owned file tools and Native result projection', ) assert.equal(hosted.transport, 'in-process') for (const tool of hosted.tools) { diff --git a/test/policy.test.mjs b/test/policy.test.mjs index 27c7ebec..8ac6f38f 100644 --- a/test/policy.test.mjs +++ b/test/policy.test.mjs @@ -15,7 +15,10 @@ await test('file guidance names exactly the available file tools', () => { assert.match(text, /^# Repository file tools/) for (const name of files) assert.ok(text.includes(name)) for (const name of [BASH_TOOL, ...removed, 'PowerShell 7']) assert.equal(text.includes(name), false) - assert.match(text, /Every file tool has one `path`/) + assert.match(text, /Single-file tools use `path`/) + assert.equal(text.includes(publicToolName('result')), false, 'a hidden pager is not advertised') + const pager = renderToolingPolicy({ published: [publicToolName('result')] }) + assert.match(pager, /original reference root/) assert.equal(text.includes('Legacy ops_* file paths are absolute'), false) assert.equal(text.includes('FastCtx’s own session cwd'), false) assert.match(text, /do not switch to a shell workaround/) From 0a9d3da62b5c299a33f44961b2dbbff8fe43b350 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:32:25 +0800 Subject: [PATCH 026/118] feat(ptc): generate checked SDK declarations from canonical schemas --- docs/CHANGELOG.md | 9 ++ ...23\346\236\234\345\245\221\347\272\246.md" | 55 ++++++++++++ docs/native-ptc.md | 42 +++++---- docs/ptc-outcomes.md | 67 ++++++++++++++ package.json | 1 + scripts/build.mjs | 5 ++ scripts/lib/build/ptc-sdk.mjs | 26 ++++++ src/modes/ptc/canonical-schema.ts | 5 +- test/package-source.test.mjs | 2 +- test/ptc-projection-worker.test.mjs | 88 +++++++++++++++++++ test/types/ptc-sdk.ts | 41 +++++++++ test/types/tsconfig.json | 3 +- 12 files changed, 325 insertions(+), 19 deletions(-) create mode 100644 "docs/PTC\347\273\223\346\236\234\345\245\221\347\272\246.md" create mode 100644 docs/ptc-outcomes.md create mode 100644 scripts/lib/build/ptc-sdk.mjs create mode 100644 test/ptc-projection-worker.test.mjs create mode 100644 test/types/ptc-sdk.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4cb36ee9..173a4444 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,15 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Expose canonical PTC outcomes and generated declarations (#261, 7/16) + +- Wrap PTC canonical data in discriminated completed/partial/failure outcomes with stable effects + and retry guidance. Safe continuations describe read-only retrieval without replaying mutations. +- Generate and ship type-only `dsh-ops/ptc-sdk` declarations from the same catalogue/output schemas; + verify status narrowing, argument shapes and real worker receipts with the pinned host validator. +- PTC 返回可收窄的规范结果信封;安全续取只描述只读操作,绝不重放修改。构建并发布同源类型声明, + 保留参数、覆盖范围、副作用和重试约束。 + ### Page stored references from Native file tools (#261, 6d/16) - Add a Native page/release projection of the shared ops_result capability, independently owned by diff --git "a/docs/PTC\347\273\223\346\236\234\345\245\221\347\272\246.md" "b/docs/PTC\347\273\223\346\236\234\345\245\221\347\272\246.md" new file mode 100644 index 00000000..4e67f681 --- /dev/null +++ "b/docs/PTC\347\273\223\346\236\234\345\245\221\347\272\246.md" @@ -0,0 +1,55 @@ +# PTC 结果契约 + +English: [PTC result contracts](ptc-outcomes.md). + +0.4.0 的 PTC 组件独立提供文件、批处理和结果引用 SDK,不依赖 Native 文件组件。 +每个绑定都返回 `Outcome` 值。Native 继续输出紧凑文本;PTC 的 `data` 保留规范结构化数据。 +可见性与下一轮激活规则见 [SDK 按需加载](PTC按需加载.md)。 + +| 状态 | 必需字段 | 调用方操作 | +| --- | --- | --- | +| `completed` | `data` | 处理本次请求对应的结果。 | +| `partial` | `data`、不完整的 `coverage`、`continuation` | 检查已保留数据,显式读取剩余范围或引用页。 | +| `rejected`、`conflict`、`error`、`unknown` | `error`,可选保留 `data` | 根据 `code`、`effectState`、`retry` 和 `nextAction` 处理。 | + +成功的 plan 只表示规划完成,不表示修改已执行。`result.put` 不回显行仍可表示存储完成。 +批处理中失败的条目保留在数据中,并返回错误;中断的修改绝不会获得自动重试游标。 +`unknown` 必须通过只读检查确认副作用。不能在 `partial` 上访问 `error`,也不能把所有非错误 +状态当成完整结果。 + +```ts +// 当前 agent 已激活 ops_read_many 后: +const result = await tools.ops_read_many({ + files: [{ path: "src/a.ts", offset: 1, limit: 20 }] +}); +if (result.status === "completed") { + return { lines: result.data.items?.map(file => file.items?.length ?? 0) }; +} +if (result.status === "partial") { + return { coverage: result.coverage, continuation: result.continuation }; +} +return { code: result.error.code, effectState: result.error.effectState, + retry: result.error.retry, action: result.error.nextAction }; +``` + +续取值描述有界只读操作,保留原查询、分页上限、所选字段和操作根目录。修改回执溢出后, +续取使用 `result.page`,绝不重复写入。引用页沿原引用推进,不会生成嵌套引用。没有进展、 +物理搜索未完成、描述符过大或无法安全续取时,返回 `RESULT_PARTIAL`、保留数据与明确指引, +不伪造游标。每次续取仍核实当前权限与 SDK 可见性;没有自动执行或自动重试工具的辅助器。 + +搜索扫描覆盖、快照捕获完整性和页面完整性是不同事实。读完已存储页不代表原始扫描完整。 +使用快照时检查 `data.source` 和原始搜索报告,详见[结果预算](结果预算.md)。 + +## 生成类型声明 + +`npm run build` 从与宿主校验和 SDK 段相同的能力清单及 Outcome schema 生成 +`lib/ptc/sdk.d.ts`,并随 npm 包发布。此文件属于构建产物,不可手工修改。TypeScript 调用方 +可以使用纯类型导出: + +```ts +import type { OpsSdk, Outcome, StorageReference } from "dsh-ops/ptc-sdk"; +``` + +`OpsSdk` 描述完整受支持 API,不代表某个 agent 已加载全部绑定;宿主实际 SDK 段只包含 +当前可见且已激活的绑定。必需参数、枚举、嵌套范围/编辑结构与状态收窄保持类型约束;扩展 +数据保持 JSON。参数说明中的数值上限由 worker 执行。此导出不会加载宿主服务或创建另一运行时。 diff --git a/docs/native-ptc.md b/docs/native-ptc.md index 3092d7cb..4f71fc06 100644 --- a/docs/native-ptc.md +++ b/docs/native-ptc.md @@ -1,4 +1,7 @@ -# Native PTC enhancement — 0.3.1 Windows x64, Linux x64, Apple Silicon +# Native PTC enhancement — Windows x64, Linux x64, Apple Silicon + +0.4.0 PTC callers use [Outcome contracts](ptc-outcomes.md) / [PTC 结果契约](PTC结果契约.md). +The historical worker/recovery details below remain applicable; SDK results now wrap canonical facts in `data`. Issues: [#3](https://github.com/T-Auto/dsh-ops/issues/3), [#8](https://github.com/T-Auto/dsh-ops/issues/8). The file and PTC components use an independent Rust worker. The FastCtx-backed @@ -159,10 +162,12 @@ alone misses these failures. For example: ```ts const result = await tools.ops_grep({ path: "src", pattern: "TODO", outputMode: "summary" }); -if (result.status === "error" || result.status === "rejected") { - console.log({ status: result.status, error: result.error }); +if (result.status === "completed") { + console.log({ files: result.data.files, matches: result.data.matches }); +} else if (result.status === "partial") { + console.log({ coverage: result.coverage, continuation: result.continuation }); } else { - console.log({ files: result.files, matches: result.matches, complete: result.complete }); + console.log({ status: result.status, error: result.error }); } ``` @@ -170,8 +175,8 @@ File parameter bounds are described in the host-compatible SDK schema and enforc by the native worker, even for direct PTC calls. Required fields, enums, nested edit shapes and strict unknown-field rejection remain discoverable. Native file rendering uses `=== FILE: path (Lines a-b of n) ===` and `N | text`, -with concise mutation receipts and explicit paging/effect/retry notices. The raw -execution value returned to PTC remains unchanged. Content blocks are capped at +with concise mutation receipts and explicit paging/effect/retry notices. PTC wraps the unchanged canonical +execution data in a typed Outcome envelope. Content blocks are capped at 64 KiB after JSON escaping; clipped displays instruct the caller to recover undisplayed content without blindly advancing a cursor or repeating a write. Physical LF defines line counts across projections; bare CR/control separators @@ -186,7 +191,10 @@ no-effect manifest and returns a `detailsRef`/job id. It does **not** convert in an executable `run` request. Execute that exact planned manifest with: ```ts -await tools.ops_batch({ action: "resume", id: plan.id }); +if (plan.status === "completed") { + const execution = await tools.ops_batch({ action: "resume", id: plan.data.id }); + console.log(execution); // inspect its status before reading execution.data +} ``` `action: "run"` is a separate immediate path and requires the operations again; @@ -207,8 +215,8 @@ const result = await tools.ops_batch({ { op: "remove", path: "cache/explicitly-approved-target" } ] }); -console.log({status: result.status, done: result.done, failed: result.failed, - unknown: result.unknown, detailsRef: result.detailsRef}); +console.log({ status: result.status, receipt: result.data, + error: result.status === "completed" || result.status === "partial" ? undefined : result.error }); ``` Workspace authority comes only from `sandboxPolicy.resolve({session})`. @@ -239,12 +247,16 @@ preflight and current DSH policy checks run again. ```ts // Small data is better processed directly in JavaScript. -const stored = await tools.ops_result({action: "put", items: operationRows}); -const batch = await tools.ops_batch({action: "run", operationsRef: stored.id}); -const page = await tools.ops_result({action: "page", id: batch.detailsRef, - offset: 0, limit: 64, maxBytes: 16384, fields: ["operation", "state", "error"]}); -console.log(page.items.filter(row => row.state !== "completed")); -await tools.ops_result({action: "release", id: stored.id}); +const stored = await tools.ops_result({ action: "put", items: operationRows }); +if (stored.status !== "completed") return stored; +const batch = await tools.ops_batch({ action: "run", operationsRef: stored.data.id }); +if (batch.data?.detailsRef) { + const page = await tools.ops_result({ action: "page", id: batch.data.detailsRef, + offset: 0, limit: 64, maxBytes: 16384, fields: ["operation", "state", "error"] }); + console.log(page); // partial pages include an explicit continuation +} +const released = await tools.ops_result({ action: "release", id: stored.data.id }); +console.log({ batch, released }); ``` put stores object rows; page supports top-level equality and field selection, diff --git a/docs/ptc-outcomes.md b/docs/ptc-outcomes.md new file mode 100644 index 00000000..97f8bb04 --- /dev/null +++ b/docs/ptc-outcomes.md @@ -0,0 +1,67 @@ +# PTC result contracts + +中文:[PTC 结果契约](PTC结果契约.md). + +In 0.4.0 the PTC component owns its file, batch and result SDK bindings independently +of the Native file row. Every binding returns an `Outcome` value. Native tools +continue to render compact text; PTC receives canonical structured data in `data`. +See [SDK loading](ptc-sdk-loading.md) for availability and next-turn activation. + +| Status | Required fields | Caller action | +| --- | --- | --- | +| `completed` | `data` | Consume the requested operation's result. | +| `partial` | `data`, incomplete `coverage`, `continuation` | Inspect retained data; explicitly retrieve the remaining range or stored page. | +| `rejected`, `conflict`, `error`, `unknown` | `error` and optional retained `data` | Follow `error.code`, `effectState`, `retry` and `nextAction`. | + +A successful plan is completed planning, not completed execution. `result.put` is +completed storage even though the receipt does not echo its rows. Failed batch +items retain their data and report an error; an interrupted mutation never receives +an automatic retry cursor. `unknown` means effects require read-only inspection. +Do not access `error` on `partial`, or treat every non-error status as complete. + +```ts +// After ops_read_many has been activated for this agent: +const result = await tools.ops_read_many({ + files: [{ path: "src/a.ts", offset: 1, limit: 20 }] +}); +if (result.status === "completed") { + return { lines: result.data.items?.map(file => file.items?.length ?? 0) }; +} +if (result.status === "partial") { + return { coverage: result.coverage, continuation: result.continuation }; +} +return { code: result.error.code, effectState: result.error.effectState, + retry: result.error.retry, action: result.error.nextAction }; +``` + +Continuation values describe bounded read-only retrieval. They preserve the +original query, page limits, selected fields and operation root. A spilled mutation +continues with `result.page`, never with a repeated write. A result page advances +its existing reference; it never wraps the reference in another reference. Zero +progress, an incomplete physical search scan, an oversized descriptor or a range +that cannot safely resume produces `RESULT_PARTIAL` with retained data and explicit +guidance, not a fabricated continuation. All retrieval still checks current authority +and available SDK bindings. No helper automatically executes or retries a tool. + +Search scan coverage, captured snapshot completeness and page completeness are +separate facts. Exhausting a stored page does not prove that the original scan was +complete. Read `data.source` and the original search report when consuming snapshots; +see [result budgets](result-budgets.md). + +## Generated declarations + +`npm run build` generates `lib/ptc/sdk.d.ts` from the same capability catalogue and +Outcome schemas that the host validates and uses for its SDK section. The npm package +ships this file. It is build output and must not be edited by hand. TypeScript +consumers can use the type-only package export: + +```ts +import type { OpsSdk, Outcome, StorageReference } from "dsh-ops/ptc-sdk"; +``` + +`OpsSdk` describes the complete supported API, not the bindings currently loaded +in a particular agent. The host's actual SDK section contains only visible, +activated bindings. Required arguments, enums, nested range/edit shapes and +status narrowing remain typed; canonical extension data stays JSON. Bounds in +parameter descriptions are enforced by the worker. This export does not load a +host service or create a second runtime. diff --git a/package.json b/package.json index 4ee014b4..eca1a1b0 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "./ptc": { "default": "./lib/components/ptc/index.js" }, + "./ptc-sdk": { "types": "./lib/ptc/sdk.d.ts" }, "./ptc/package.json": "./lib/components/ptc/package.json", "./ptc/locale/*.json": "./lib/components/ptc/locale/*.json", "./panel": { diff --git a/scripts/build.mjs b/scripts/build.mjs index c5293885..dda4e5f3 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -42,6 +42,11 @@ try { console.error('build: TypeScript compilation failed; assets were not copied.') } else { const assets = copyAssets(source) + if (existsSync(path.join(source, 'ptc/contract.ts'))) { + const { writePtcDeclarations } = await import('./lib/build/ptc-sdk.mjs') + const sdk = await writePtcDeclarations(root) + console.log(`build: generated PTC declarations for ${sdk.tools} capabilities (${sdk.bytes} bytes)`) + } console.log(`build: compiled src/ to lib/ and copied ${assets} assets`) } } catch (error) { diff --git a/scripts/lib/build/ptc-sdk.mjs b/scripts/lib/build/ptc-sdk.mjs new file mode 100644 index 00000000..526ae24e --- /dev/null +++ b/scripts/lib/build/ptc-sdk.mjs @@ -0,0 +1,26 @@ +/** Generate declarations from the same schemas used by PTC registration and host validation. */ +import fs from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { jsonSchemaToTs } from '@deepseek-ai/dsh-tools' + +export async function writePtcDeclarations(root) { + const load = relative => import(pathToFileURL(path.join(root, 'lib', relative)).href) + const [{ CAPABILITIES }, { definition }, { outcomeSchema }, { publicToolName }] = await Promise.all([ + load('capabilities/registry.js'), load('ptc/contract.js'), load('modes/ptc/schema.js'), load('policy.js'), + ]) + const entries = CAPABILITIES.filter(entry => 'parameters' in entry && entry.presentations.includes('ptc')) + .sort((a, b) => a.rawName.localeCompare(b.rawName, 'en')) + const members = entries.map(entry => { + const output = entry.rawName === 'sdk' ? outcomeSchema(entry.output) : definition(entry.rawName, async () => null).output.schema + return ` ${JSON.stringify(publicToolName(entry.rawName))}: (args: ${jsonSchemaToTs(entry.parameters)}) => Promise<${jsonSchemaToTs(output)}>;` + }) + // This source contains type declarations only, and is the authoritative shared Outcome vocabulary. + const contracts = fs.readFileSync(path.join(root, 'src/kernel/outcome.ts'), 'utf8') + const text = '// Derived from the capability catalogue; rebuild with npm run build.\n' + contracts + + '\nexport type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };\n' + + '\n/** Available bindings still depend on component, scope, presentation and SDK activation. */\n' + + 'export interface OpsSdk {\n' + members.join('\n') + '\n}\n' + fs.writeFileSync(path.join(root, 'lib/ptc/sdk.d.ts'), text) + return { tools: entries.length, bytes: Buffer.byteLength(text) } +} diff --git a/src/modes/ptc/canonical-schema.ts b/src/modes/ptc/canonical-schema.ts index 1a69c014..00689dd4 100644 --- a/src/modes/ptc/canonical-schema.ts +++ b/src/modes/ptc/canonical-schema.ts @@ -6,6 +6,7 @@ import { STORAGE_REFERENCE } from '../../capabilities/result.js' const string: JsonSchemaNode = { type: 'string' } const integer: JsonSchemaNode = { type: 'integer' } const boolean: JsonSchemaNode = { type: 'boolean' } +const nullableInteger: JsonSchemaNode = { oneOf: [integer, { type: 'null' }] } const object = (properties: Record): JsonSchemaNode => ({ type: 'object', properties }) const array = (items: JsonSchemaNode): JsonSchemaNode => ({ type: 'array', items }) const physicalLine = object({ path: string, line: integer, text: string, view: string, offset: integer, @@ -28,9 +29,9 @@ export function canonicalOutput(tool: FileToolName | PtcToolName): JsonSchemaNod const properties: Record = { ...entry.output.properties, ...cache } if (tool === 'inspect_local_file') Object.assign(properties, facts, { path: string, items: array(physicalLine) }) else if (tool === 'read_many') properties.items = array(object({ ...range, items: array(physicalLine) })) - else if (tool === 'glob') properties.items = array({ oneOf: [string, object({ path: string, bytes: integer, modified: integer })] }) + else if (tool === 'glob') properties.items = array({ oneOf: [string, object({ path: string, bytes: integer, modified: nullableInteger })] }) else if (tool === 'grep') properties.items = array(object({ path: string, line: integer, text: string, count: integer, matches: integer })) - else if (tool === 'stat') Object.assign(properties, facts, { exists: boolean, path: string, readonly: boolean, mode: string, modifiedMs: integer }) + else if (tool === 'stat') Object.assign(properties, facts, { exists: boolean, path: string, readonly: boolean, mode: string, modifiedMs: nullableInteger }) else if (tool === 'diff') Object.assign(properties, { before: object(facts), after: object(facts), hunk: object({ startLine: integer, removed: integer, added: integer }), items: array(object({ kind: string, line: integer, text: string })) }) else if (tool === 'result') properties.source = object({ ...range, ...coverage, root: string, diff --git a/test/package-source.test.mjs b/test/package-source.test.mjs index ce242689..cada4b64 100644 --- a/test/package-source.test.mjs +++ b/test/package-source.test.mjs @@ -10,7 +10,7 @@ await test('npm pack includes compiled host code and native sources, never host { cwd: PACKAGE_ROOT, encoding: 'utf8', timeout: 30000 }) assert.equal(result.status, 0, result.stderr) const files = JSON.parse(result.stdout)[0].files.map(file => file.path) - const required = ['lib/index.js', 'lib/policy.js', 'lib/components/panel/index.js', + const required = ['lib/ptc/sdk.d.ts', 'lib/index.js', 'lib/policy.js', 'lib/components/panel/index.js', 'lib/components/panel/package.json', 'lib/components/panel/icon.png', 'lib/panel/panel.html', 'lib/components/tui/dsh-plugin.json', 'native/Cargo.toml', 'native/Cargo.lock', 'native/src/lib.rs', 'native/windows-metadata/Cargo.toml', 'native/windows-metadata/src/lib.rs', diff --git a/test/ptc-projection-worker.test.mjs b/test/ptc-projection-worker.test.mjs new file mode 100644 index 00000000..222af4e3 --- /dev/null +++ b/test/ptc-projection-worker.test.mjs @@ -0,0 +1,88 @@ +/** Validate real canonical worker receipts through the actual PTC output schemas. */ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { validateJsonSchemaValue, assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools' +import { definition, PTC_API_TOOLS } from '../lib/ptc/contract.js' +import { decodeContinuation } from '../lib/modes/ptc/continuation.js' +import { workerEnvelope } from '../lib/worker-protocol.js' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { requireWorker, request, callWorker, envelopeOf } from './lib/boundary.mjs' +requireWorker('ptc-projection-worker') +const hash = text => createHash('sha256').update(text).digest('hex') +const seen = new Set() +async function call(root, tool, args, { storage, readOnly = false } = {}) { + const execute = async () => envelopeOf(await callWorker(JSON.stringify(workerEnvelope(tool === 'batch' || tool === 'result' ? 'ptc' : 'file', + request(tool, args, { root, scope: 'ptc-projection', readOnly }), undefined, storage))), assert) + const entry = definition(tool, execute), out = await entry.execute(args, {}) + assertSupportedJsonSchema(entry.output.schema) + assert.deepEqual(validateJsonSchemaValue(entry.output.schema, out), [], `${tool}: ${JSON.stringify(out)}`) + seen.add(tool) + return out +} +await test('all file observations validate and retain text, hex, stat and search canonical types', async () => { + await withTempDir('ptc-read', async root => { + const file = path.join(root, 'a.txt'); fs.writeFileSync(file, 'alpha\r\nβeta\nlast') + for (const [tool, args] of [ + ['inspect_local_file', { path: file }], ['inspect_local_file', { path: file, view: 'hex', limit: 1 }], + ['stat', { path: file }], ['stat', { path: path.join(root, 'absent') }], + ['diff', { path: file, content: 'changed\n' }], ['glob', { path: root, pattern: ['*.txt'], outputMode: 'details' }], + ['glob', { path: root, pattern: ['*.txt'] }], ['grep', { path: root, pattern: 'a' }], + ['read_many', { files: [{ path: file, limit: 1 }] }], + ]) assert.ok(['completed','partial'].includes((await call(root, tool, args)).status), tool) + const failed = await call(root, 'read_many', { files: [{ path: file }, { path: path.join(root, 'missing') }] }) + assert.equal(failed.status, 'error'); assert.equal(failed.data.items[0].items[1].text, 'βeta') + assert.equal((await call(root, 'grep', { path: root, pattern: '[' })).error.code, 'INVALID_ARGUMENT') + }) +}) +await test('mutation receipts and batch plans preserve effects without changing the native kernel', async () => { + await withTempDir('ptc-write', async root => { + const file = path.join(root, 'a.txt'), copy = path.join(root, 'copy.txt'), moved = path.join(root, 'moved.txt') + assert.equal((await call(root, 'write_file', { path: file, content: 'alpha', expectedRevision: 'absent' })).status, 'completed') + for (const tool of ['edit_file', 'edit_many']) assert.equal((await call(root, tool, { path: file, expectedRevision: hash('alpha'), edits: [{ oldText: 'alpha', newText: 'beta' }], dryRun: true })).status, 'completed') + assert.equal((await call(root, 'replace', { path: root, pattern: 'alpha', replacement: 'beta', dryRun: true })).status, 'completed') + assert.equal((await call(root, 'copy', { path: file, to: copy, expectedRevision: hash('alpha') })).status, 'completed') + assert.equal((await call(root, 'move', { path: copy, to: moved, expectedRevision: hash('alpha') })).status, 'completed') + assert.equal((await call(root, 'remove', { path: moved, expectedRevision: hash('alpha') })).status, 'completed') + const denied = await call(root, 'write_file', { path: file, content: 'bad', expectedRevision: hash('alpha') }, { readOnly: true }) + assert.equal(denied.status, 'rejected'); assert.equal(denied.error.retry, 'never'); assert.equal(fs.readFileSync(file, 'utf8'), 'alpha') + const plan = await call(root, 'batch', { action: 'plan', operations: [{ op: 'copy', path: 'a.txt', to: 'batch.txt' }] }) + assert.equal(plan.status, 'completed'); assert.equal(plan.data.status, 'planned') + assert.equal((await call(root, 'batch', { action: 'resume', id: plan.data.id })).status, 'completed') + }) +}) +await test('real reference pages follow a bounded cursor to completion and release reports success', async () => { + await withTempDir('ptc-pages', async root => { + const cache = path.join(root, 'cache'); fs.mkdirSync(cache, { mode: 0o700 }) + const storage = { root: cache, authorityId: 'a'.repeat(64), allowedTools: ['result'] } + const rows = Array.from({ length: 4 }, (_, n) => ({ n, text: 'line' })) + const put = await call(root, 'result', { action: 'put', items: rows }, { storage }) + assert.equal(put.status, 'completed') + let args = { action: 'page', id: put.data.id, limit: 1, maxBytes: 2048, fields: ['n'] }, previous = -1 + const collected = [] + for (let page = 0; page < 4; page++) { + const out = await call(root, 'result', args, { storage }) + collected.push(...out.data.items); assert.ok(out.data.nextOffset > previous); previous = out.data.nextOffset + if (out.status === 'completed') break + assert.equal(out.status, 'partial') + args = decodeContinuation(out.continuation).args + assert.deepEqual(args.fields, ['n']); assert.equal(args.limit, 1); assert.equal(args.maxBytes, 2048) + } + assert.deepEqual(collected, rows.map(({ n }) => ({ n }))) + assert.equal((await call(root, 'result', { action: 'release', id: put.data.id }, { storage })).status, 'completed') + const legacy = await call(root, 'result', { action: 'put', items: rows }) + assert.equal(legacy.status, 'completed'); assert.match(legacy.data.id, /^r-/) + assert.equal((await call(root, 'result', { action: 'release', id: legacy.data.id })).status, 'completed') + }) +}) +await test('every worker API was validated and unexpected callback rejection becomes an unknown value', async () => { + assert.deepEqual([...seen].sort(), [...PTC_API_TOOLS].sort()) + for (const tool of ['result','write_file']) { + const entry = definition(tool, async () => { throw new Error('private transport detail') }) + const out = await entry.execute({}, {}) + assert.equal(out.status, 'unknown'); assert.equal(out.error.retry, 'never') + assert.deepEqual(validateJsonSchemaValue(entry.output.schema, out), []) + assert.doesNotMatch(JSON.stringify(out), /private transport detail/) + } +}) +report('ptc-projection-worker') diff --git a/test/types/ptc-sdk.ts b/test/types/ptc-sdk.ts new file mode 100644 index 00000000..dbb4bc39 --- /dev/null +++ b/test/types/ptc-sdk.ts @@ -0,0 +1,41 @@ +import type { OpsSdk, Outcome, StorageReference } from 'dsh-ops/ptc-sdk' + +declare const tools: OpsSdk +async function read() { + const result = await tools.ops_read_many({ files: [{ path: 'a.ts', offset: 1, limit: 20 }] }) + // @ts-expect-error A result must be narrowed before accessing failure details. + void result.error + if (result.status === 'completed') { + const text: string | undefined = result.data.items?.[0]?.items?.[0]?.text + // @ts-expect-error Canonical text is not a number. + const count: number = result.data.items?.[0]?.items?.[0]?.text + return text + } + if (result.status === 'partial') { + const complete: false = result.coverage.isComplete + const next: string = result.continuation + // @ts-expect-error Partial is an explicit continuation state, not a failure. + void result.error + return { complete, next } + } + const retry: 'never' | 'after-input-change' | 'after-state-change' | 'bounded-transient' = result.error.retry + return { code: result.error.code, retry } +} +async function metadata() { + const result = await tools.ops_stat({ path: 'a.ts' }) + if (result.status === 'completed') { + const modified: number | null | undefined = result.data.modifiedMs + return modified + } +} +void tools.ops_inspect_local_file({ path: 'a.ts', view: 'hex' }) +// @ts-expect-error Tool arguments preserve the original enum contract. +void tools.ops_inspect_local_file({ path: 'a.ts', view: 'pdf' }) +// @ts-expect-error Multi-read requires a list of file ranges. +void tools.ops_read_many({ path: 'a.ts' }) +// @ts-expect-error Editing requires the observed revision. +void tools.ops_edit_many({ path: 'a.ts', edits: [{ oldText: 'a', newText: 'b' }] }) +// @ts-expect-error Tool names are a finite catalogue, not an open arbitrary dictionary. +void tools.ops_unknown_tool({}) +const completed: Outcome = { status: 'completed', data: undefined } +void [read, metadata, completed] diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json index 126e33e3..c99b6ddc 100644 --- a/test/types/tsconfig.json +++ b/test/types/tsconfig.json @@ -5,6 +5,7 @@ "noEmit": true }, "include": [ - "kernel.ts" + "kernel.ts", + "ptc-sdk.ts" ] } From 8d0f2b3278a9e281a3045bbbc223cdff616ff922 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:34:20 +0800 Subject: [PATCH 027/118] test(ptc): cover unavailable metadata timestamps --- test/ptc-outcome.test.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/ptc-outcome.test.mjs b/test/ptc-outcome.test.mjs index cd247834..6b99c167 100644 --- a/test/ptc-outcome.test.mjs +++ b/test/ptc-outcome.test.mjs @@ -2,6 +2,7 @@ import { assertSupportedJsonSchema, jsonSchemaToTs, validateJsonSchemaValue } fr import { toOutcome } from '../lib/modes/ptc/outcome.js' import { continuationFor, decodeContinuation } from '../lib/modes/ptc/continuation.js' import { outcomeSchema } from '../lib/modes/ptc/schema.js' +import { canonicalOutput } from '../lib/modes/ptc/canonical-schema.js' import { assert, test, report } from './lib/harness.mjs' await test('successful observations retain exact canonical values without Native text', () => { @@ -98,4 +99,16 @@ await test('host schema validation and generated SDK enforce the discriminated e assert.ok(validateJsonSchemaValue(schema, bad).length > 0) } }) +await test('canonical metadata accepts unavailable timestamps without weakening numeric fields', () => { + for (const [tool, data] of [ + ['stat', { exists: true, modifiedMs: null, bytes: 2 }], + ['glob', { complete: true, items: [{ path: 'a', bytes: 2, modified: null }] }], + ]) { + const schema = outcomeSchema(canonicalOutput(tool)) + assert.deepEqual(validateJsonSchemaValue(schema, { status: 'completed', data }), []) + const invalid = tool === 'stat' ? { ...data, modifiedMs: 'not-a-time' } + : { ...data, items: [{ ...data.items[0], modified: 'not-a-time' }] } + assert.ok(validateJsonSchemaValue(schema, { status: 'completed', data: invalid }).length > 0) + } +}) report('ptc-outcome') From d5205938a4e96f361485097736765ab75cce9cfe Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:35:40 +0800 Subject: [PATCH 028/118] docs(ptc): align SDK loading and execution limits --- docs/native-ptc.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/native-ptc.md b/docs/native-ptc.md index 4f71fc06..58852009 100644 --- a/docs/native-ptc.md +++ b/docs/native-ptc.md @@ -54,8 +54,8 @@ Rust >=1.88 is required. Build uses `cargo build --release --locked` against session toolchain before building; do not add multi-version toolchains to global PATH. The `dsh-ops/ptc` bundle row is enabled by default after #170; existing explicit -profile disablement is preserved. Its only config keys are `required` -(default false) and an optional absolute `binaryPath`. `DSH_OPS_WORKER_BIN` can +profile disablement is preserved. Config accepts `required` (default false), an optional +absolute `binaryPath`, `allowedTools`, and `alwaysLoaded`; see [SDK loading](ptc-sdk-loading.md). `DSH_OPS_WORKER_BIN` can select a build; otherwise the component uses `runtime/-/dsh-ops-worker[.exe]` before `native/target/release`. Published 0.3.1 packages include the runtime bytes of every supported platform; source checkouts stage them explicitly with @@ -66,7 +66,9 @@ The session must already use DSH's **PTC-only** presentation. The component does not force presentation, replace the registry, or create a REPL. It recognizes the host's public prompt assembly (`tools:ptc-only` section plus the reserved-only wire schema), not private `modeFor`, run_code existence alone or a guessed session key. -This PTC row adds no batch/put capability or extra prompt in Native/both scopes. The file row independently supplies Native ops_result page/release; see [result budgets](result-budgets.md#native-and-ptc-reference-pages). 文件组件独立提供 Native 引用分页/释放,详见[结果预算](结果预算.md#native-与-ptc-引用分页)。 +This PTC row publishes its independent SDK only in PTC-only scopes, with inspect/result and the +loader initially resident. Other APIs require next-step activation or `alwaysLoaded`. It adds +no bindings or extra prompt in Native/both scopes. The file row independently supplies Native ops_result page/release; see [result budgets](result-budgets.md#native-and-ptc-reference-pages). 文件组件独立提供 Native 引用分页/释放,详见[结果预算](结果预算.md#native-与-ptc-引用分页)。 The host generates SDK declarations from the same tool schemas; no duplicate guide or dynamic plan IDs are added to the system prefix. @@ -283,7 +285,7 @@ input is bounded stdin JSON, never a shell command or argv-sized path list. Unlo aborts and waits for in-flight work. Authority downgrade aborts active workers. If host jobs/controller admission is available, batch foreground work uses that registry's owner, cancel and progress surfaces, waits once, then removes its own -collected terminal record. Otherwise it remains a 5-minute bounded foreground call. +collected terminal record. Otherwise it remains a bounded foreground call; the shared host ceiling is 30 seconds. No second background scheduler, detached-job polling tool or cloned REPL is created. Progress is currently preflight/settlement only, not per-file live reporting. From 0b9157906ee4a24a90396e138288deda8d96e44b Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:37:57 +0800 Subject: [PATCH 029/118] feat(ptc): publish independent scoped SDKs on demand --- cordis.patch.yml | 2 + docs/CHANGELOG.md | 6 + ...11\351\234\200\345\212\240\350\275\275.md" | 22 ++ docs/ptc-sdk-loading.md | 22 ++ dsh-plugin.json | 6 +- scripts/validate-manifest.mjs | 6 +- src/capabilities/registry.ts | 4 +- src/capabilities/sdk.ts | 14 ++ src/components/ptc/locale/en.json | 2 +- src/components/ptc/locale/zh.json | 2 +- src/components/ptc/package.json | 2 +- src/config.js | 3 + src/file/result-provider.ts | 57 ++--- src/file/runtime.js | 15 +- src/ptc/config.ts | 21 ++ src/ptc/index.js | 190 ---------------- src/ptc/index.ts | 205 ++++++++++++++++++ src/ptc/invoke.ts | 48 ++++ src/ptc/ownership.ts | 37 ++++ src/ptc/sdk.ts | 27 +++ test/bundle-startup.test.mjs | 10 +- test/capabilities.test.mjs | 12 +- test/mount.test.mjs | 3 +- test/native-result.test.mjs | 2 +- test/ptc-cost.test.mjs | 4 +- test/ptc-sdk.test.mjs | 182 ++++++++++++++++ test/ptc.test.mjs | 39 ++-- 27 files changed, 678 insertions(+), 265 deletions(-) create mode 100644 "docs/PTC\346\214\211\351\234\200\345\212\240\350\275\275.md" create mode 100644 docs/ptc-sdk-loading.md create mode 100644 src/capabilities/sdk.ts create mode 100644 src/ptc/config.ts delete mode 100644 src/ptc/index.js create mode 100644 src/ptc/index.ts create mode 100644 src/ptc/invoke.ts create mode 100644 src/ptc/ownership.ts create mode 100644 src/ptc/sdk.ts create mode 100644 test/ptc-sdk.test.mjs diff --git a/cordis.patch.yml b/cordis.patch.yml index b67947a2..981e1e5a 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -24,6 +24,8 @@ name: dsh-ops/ptc config: required: false + allowedTools: [grep, glob, replace, write_file, edit_file, diff, stat, copy, move, remove, read_many, edit_many, batch] + alwaysLoaded: [] - id: dsh-ops-panel name: dsh-ops/panel config: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 173a4444..852c4b98 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -16,6 +16,12 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - PTC 返回可收窄的规范结果信封;安全续取只描述只读操作,绝不重放修改。构建并发布同源类型声明, 保留参数、覆盖范围、副作用和重试约束。 +### Load scoped PTC SDKs progressively (#261, 7/16) + +- PTC owns typed file, batch and result APIs independently of the Native file row. Start with the reader, result storage/pager and SDK loader; requested advanced APIs publish only at the next agent step. +- Keep per-agent allowed pools and always-loaded selections, Native/both isolation, owned registration handoff and structured Outcome failures. +- PTC 独立提供类型化文件、批处理与引用 API;高级 SDK 按代理在下一步骤加载,支持允许池和始终加载配置,并保持 Native 隔离及结构化失败。 + ### Page stored references from Native file tools (#261, 6d/16) - Add a Native page/release projection of the shared ops_result capability, independently owned by diff --git "a/docs/PTC\346\214\211\351\234\200\345\212\240\350\275\275.md" "b/docs/PTC\346\214\211\351\234\200\345\212\240\350\275\275.md" new file mode 100644 index 00000000..fb56fcf2 --- /dev/null +++ "b/docs/PTC\346\214\211\351\234\200\345\212\240\350\275\275.md" @@ -0,0 +1,22 @@ +# PTC SDK 按需加载 + +PTC 组件独立于 Native 文件组件提供文件 API。PTC-only 代理初始只包含 ops_inspect_local_file、ops_result 和 ops_sdk。宿主根据已注册的参数与 Outcome 输出 schema 生成类型化 SDK;Native 与 both 模式不显示 SDK 加载器。 + +在 run_code 内调用 tools.ops_sdk({ tools: ['grep', 'read_many'] })。成功 Outcome 的 data.tools 列出请求名称,data.nextTurn 为 true。结束当前代码调用后,请求的 API 才会在下一个代理 pre-step 发布,当前冻结的 SDK 快照无法调用新 API。系统提示组装、诊断组装和工具注册通知都不会提前激活排队请求。 + +PTC 行配置由统一配置入口验证: + +| 配置 | 默认值 | 含义 | +| --- | --- | --- | +| allowedTools | 所有已实现的高级文件 API 和 batch | 加载器可请求的原始名称;空数组禁止动态选择。 | +| alwaysLoaded | [] | 立即在本行覆盖的 PTC 代理中发布的高级 API,独立于允许池。 | +| binaryPath | 打包的 worker | 可选的绝对 worker 可执行文件路径。 | +| required | false | worker 不可用时令组件启动失败。 | + +高级名称为 grep、glob、replace、write_file、edit_file、diff、stat、copy、move、remove、read_many、edit_many、batch。每次原子验证 1–5 个名称;未知键、带公开前缀的名称、常驻 API 与未实现功能均拒绝。配置保存为数组,本切片不提供浏览器多选编辑器。 + +选择绑定准确的活动代理;同级与子代理不继承其他代理加载的 SDK。同一代理内,已加载选择可保留至组件或代理卸载。沙箱模式变化会清空排队请求并取消活动调用;每次 worker 调用仍重新检查当前权限。卸载会撤回自身注册并拒绝保留的回调。切换至 Native/both 会移除 PTC API,并仅恢复文件组件此前可见的 Native 定义;重叠 PTC 配置行由最近作用域的行负责,同一作用域内后挂载行优先,卸载后恢复此前的行;不修改共享注册表。 + +先检查 Outcome.status 再使用 data。部分结果保留覆盖信息及 continuation;失败保留错误代码、影响状态和重试策略。加载器不执行文件操作,也不授予文件系统权限。本页不声称 token 基准或 IO 提速。 + +English: [Progressive PTC SDK loading](./ptc-sdk-loading.md). diff --git a/docs/ptc-sdk-loading.md b/docs/ptc-sdk-loading.md new file mode 100644 index 00000000..b8199fe5 --- /dev/null +++ b/docs/ptc-sdk-loading.md @@ -0,0 +1,22 @@ +# Progressive PTC SDK loading + +The PTC row owns its file APIs independently of the Native file row. A PTC-only agent starts with `ops_inspect_local_file`, `ops_result` and `ops_sdk`. The host generates the typed SDK from the registered argument and Outcome output schemas. Native and both presentations receive no SDK loader. + +Call `tools.ops_sdk({ tools: ['grep', 'read_many'] })` inside `run_code`. A completed Outcome contains `data.tools` and `data.nextTurn: true`. Finish that code call; the requested APIs become visible at the next agent pre-step. They are absent from the current frozen SDK snapshot. Prompt assembly, diagnostic assembly and registry notifications never activate queued names. + +The PTC row accepts these settings through central config validation: + +| Key | Default | Meaning | +| --- | --- | --- | +| `allowedTools` | All implemented advanced file APIs and batch | Exact raw names the loader may request. An empty list disables dynamic selections. | +| `alwaysLoaded` | `[]` | Implemented advanced names published immediately in every PTC agent covered by this row; independent of the allowed pool. | +| `binaryPath` | Packaged worker | Optional absolute worker executable selection. | +| `required` | `false` | Fail component startup when the worker is unavailable. | + +Advanced names are `grep, glob, replace, write_file, edit_file, diff, stat, copy, move, remove, read_many, edit_many, batch`. Requests accept 1–5 names atomically. Unknown keys, public-prefixed names, resident names and unimplemented APIs are rejected. Configuration is stored as arrays; this slice does not add a browser multiselect editor. + +Selections belong to the exact live agent and persist until its component or agent unloads. Siblings and descendants do not inherit another agent's loaded SDK. A sandbox mode change drops pending selections and cancels active calls; every worker call independently rechecks current authority. Disposal withdraws owned registrations and rejects retained callbacks. Switching to Native/both removes the PTC surface and restores only the file row's previously visible Native definitions. For overlapping configured PTC rows, the nearest row owns its descendant scopes; a later row wins within the same scope, and unload restores the earlier row. The shared registry is never patched. + +Read Outcome status before data. A partial result retains coverage and a continuation; failures retain code, effect state and retry policy. The loader performs no file work and grants no filesystem permission. No token benchmark or IO speedup is claimed here. + +中文:[PTC SDK 按需加载](./PTC按需加载.md)。 diff --git a/dsh-plugin.json b/dsh-plugin.json index ba7d770a..11c5eeac 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -62,7 +62,7 @@ { "name": "dsh-ops:repository-tooling:ptc", "order": 3252, - "summary": "Scoped PTC batch/result guidance; absent when PTC tools are unavailable." + "summary": "Scoped progressive PTC SDK guidance; absent in Native/both presentation." } ], "x-tool-namespaces": [ @@ -108,9 +108,9 @@ "platforms": ["win32", "linux", "darwin"], "architectures": ["x64", "arm64"], "targets": ["win32-x64", "linux-x64", "darwin-arm64"], - "presentation": "PTC-only", + "presentation": "PTC-only; reader/result/SDK loader resident, other APIs become visible on the next agent step", "runtime": "independent native Rust file worker", - "tools": ["ops_batch", "ops_result"], + "tools": ["ops_inspect_local_file","ops_grep","ops_glob","ops_replace","ops_write_file","ops_edit_file","ops_diff","ops_stat","ops_copy","ops_move","ops_remove","ops_read_many","ops_edit_many","ops_batch","ops_result","ops_sdk"], "permissions": "Per-call original DSH sandboxPolicy and sandbox.confine (host full/partial enforcement reported); full access bypasses confinement and permits explicit operation roots; subprocess owns cancellation and credential scrub" }, { diff --git a/scripts/validate-manifest.mjs b/scripts/validate-manifest.mjs index f62b679d..a02c1f46 100644 --- a/scripts/validate-manifest.mjs +++ b/scripts/validate-manifest.mjs @@ -30,7 +30,7 @@ import { NATIVE_FILE_TOOLS } from '../lib/capabilities/registry.js' import { resolvePtcConfig } from '../lib/ptc/index.js' import { resolveTuiConfig } from '../lib/components/tui/index.js' import { resolvePanelConfig } from '../lib/components/panel/index.js' -import { PTC_TOOLS, definition } from '../lib/ptc/contract.js' +import { PTC_API_TOOLS, definition } from '../lib/ptc/contract.js' import { resolveConfig } from '../lib/config.js' import { resolveHidingConfig } from '../lib/official-tool-hiding.js' @@ -485,8 +485,8 @@ if (display !== undefined) { /** Payload the published package must carry, as `files` entries or paths they cover. */ const ptc = manifestRaw.contributes?.['x-components']?.find(c => c.id === 'dsh-ops-ptc') check(ptc?.entry === 'dsh-ops/ptc' && ptc?.defaultEnabled === true, 'native PTC enhancement is enabled by default; presentation stays scoped') -check(JSON.stringify(ptc?.tools) === JSON.stringify(PTC_TOOLS.map(publicToolName)), 'PTC declaration matches the generated tool contract') -check(PTC_TOOLS.every(t => definition(t, () => {}).parameters.type === 'object'), 'PTC schemas remain structured') +check(JSON.stringify(ptc?.tools) === JSON.stringify([...PTC_API_TOOLS, 'sdk'].map(publicToolName)), 'PTC declaration matches the generated tool contract') +check(PTC_API_TOOLS.every(t => definition(t, () => {}).parameters.type === 'object'), 'PTC schemas remain structured') check(JSON.stringify(pkg.os) === '["win32","linux","darwin"]' && JSON.stringify(pkg.cpu) === '["x64","arm64"]' && JSON.stringify(ptc?.platforms) === '["win32","linux","darwin"]' && JSON.stringify(ptc?.architectures) === '["x64","arm64"]' && JSON.stringify(ptc?.targets) === '["win32-x64","linux-x64","darwin-arm64"]', diff --git a/src/capabilities/registry.ts b/src/capabilities/registry.ts index 9625ae5a..8a97b8ae 100644 --- a/src/capabilities/registry.ts +++ b/src/capabilities/registry.ts @@ -1,6 +1,8 @@ /** One catalogue for owned tools. This module never registers tools or imports host values. */ import { PARAMETERS as fileParameters, DESCRIPTIONS as fileDescriptions, fileOutput } from './file.js' import { PARAMETERS as ptcParameters, DESCRIPTIONS as ptcDescriptions, OUTPUT as ptcOutput } from './ptc.js' +import { SDK_CAPABILITIES } from './sdk.js' +export { SDK_CAPABILITIES, SDK_OUTPUT } from './sdk.js' import { NATIVE_RESULT_PARAMETERS, NATIVE_RESULT_DESCRIPTION } from './result.js' import type { CapabilityMetadata, Component, SchemaCapability } from './types.js' @@ -28,7 +30,7 @@ export const SHELL_CAPABILITIES = Object.freeze([Object.freeze({ rawName: 'bash', component: 'shell', presentations: allPresentations, availability: 'resident', mutates: true, concurrencySafe: false, } as const satisfies CapabilityMetadata)]) -export const CAPABILITIES = Object.freeze([...FILE_CAPABILITIES, ...PTC_CAPABILITIES, ...SHELL_CAPABILITIES]) +export const CAPABILITIES = Object.freeze([...FILE_CAPABILITIES, ...PTC_CAPABILITIES, ...SDK_CAPABILITIES, ...SHELL_CAPABILITIES]) export type Capability = typeof CAPABILITIES[number] export type ToolName = Capability['rawName'] const byName = new Map(CAPABILITIES.map(capability => [capability.rawName, capability])) diff --git a/src/capabilities/sdk.ts b/src/capabilities/sdk.ts new file mode 100644 index 00000000..bcc0ddd9 --- /dev/null +++ b/src/capabilities/sdk.ts @@ -0,0 +1,14 @@ +import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import type { ParameterSchema, SchemaCapability } from './types.js' +export const SDK_PARAMETERS: ParameterSchema = { type: 'object', properties: { + tools: { type: 'array', items: { type: 'string' }, + description: '1-5 exact raw OPS API names, without the public prefix. Select only from the allowed catalogue in this tool description.' }, +}, required: ['tools'], additionalProperties: false } +export const SDK_OUTPUT: JsonSchemaNode = { type: 'object', properties: { + status: { type: 'string', const: 'completed' }, tools: { type: 'array', items: { type: 'string' } }, nextTurn: { type: 'boolean', const: true }, +}, required: ['status', 'tools', 'nextTurn'], additionalProperties: false } +export const SDK_CAPABILITIES = Object.freeze([Object.freeze({ + rawName: 'sdk', component: 'ptc', presentations: Object.freeze(['ptc'] as const), availability: 'resident', + mutates: false, concurrencySafe: false, parameters: SDK_PARAMETERS, output: SDK_OUTPUT, + description: 'Load 1-5 named OPS APIs into this agent’s typed SDK on the next model step. The current run_code snapshot cannot call newly requested APIs. No file operation runs here. Native/both presentations do not expose this loader.', +} as const satisfies SchemaCapability)]) diff --git a/src/components/ptc/locale/en.json b/src/components/ptc/locale/en.json index 69e3df88..0db120a2 100644 --- a/src/components/ptc/locale/en.json +++ b/src/components/ptc/locale/en.json @@ -1 +1 @@ -{"meta":{"title":"PTC enhancement","description":"Native Rust safe batch operations and bounded result references; only enabled in PTC-only sessions."}} +{"meta":{"title":"PTC enhancement","description":"Independent typed file APIs and result references in PTC-only sessions; advanced SDKs load on the next model step."}} diff --git a/src/components/ptc/locale/zh.json b/src/components/ptc/locale/zh.json index e7e34229..29ad0180 100644 --- a/src/components/ptc/locale/zh.json +++ b/src/components/ptc/locale/zh.json @@ -1 +1 @@ -{"meta":{"title":"PTC 增强","description":"原生 Rust 安全批处理与有界结果引用,仅在纯 PTC 会话启用。"}} +{"meta":{"title":"PTC 增强","description":"PTC 会话独立提供类型化文件 API 和结果引用;高级 SDK 在下一个模型步骤加载。"}} diff --git a/src/components/ptc/package.json b/src/components/ptc/package.json index 5b786840..9986d2b0 100644 --- a/src/components/ptc/package.json +++ b/src/components/ptc/package.json @@ -1,6 +1,6 @@ { "name": "dsh-ops-ptc", - "description": "Native Rust PTC batch operations and bounded result references for Windows, macOS and Linux.", + "description": "Independent typed PTC file APIs, progressive SDK loading and bounded result references.", "icon": "./icon.png", "type": "module" } diff --git a/src/config.js b/src/config.js index 9ea8175c..01cf5c6e 100644 --- a/src/config.js +++ b/src/config.js @@ -205,3 +205,6 @@ export function resolveHidingConfig(raw = {}) { } return { strict: true, additionalOfficialTools: [...new Set(aliases)] } } + +/** Independent PTC row configuration uses the same central validation entry point. */ +export { parsePtcConfig as resolvePtcConfig } from './ptc/config.js' diff --git a/src/file/result-provider.ts b/src/file/result-provider.ts index f8976105..c18ec81a 100644 --- a/src/file/result-provider.ts +++ b/src/file/result-provider.ts @@ -1,11 +1,8 @@ -/** Only exact visible OPS registrations can supply a scope's Native pager. */ +/** Exact visible OPS registrations supply Native projections; no shared registry mutation. */ import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import type { Context } from '@deepseek-ai/cordis' -interface Host { - root?: object - get(name: 'tools'): { get(name: string, scope?: unknown): ToolDefinition | undefined } | undefined -} -interface Provider { definition: ToolDefinition; probes: ToolDefinition[]; scope: unknown; active: boolean; publish(visible: boolean): void } +interface Host { root?: object; emit?(name: 'tools/change'): unknown; get(name: 'tools'): { get(name: string, scope?: unknown): ToolDefinition | undefined } | undefined } +interface Provider { definition: ToolDefinition; scope: unknown; active: boolean; publish(visible: boolean): void } interface State { providers: Set; ptc: Set } const owners = new WeakMap() function state(ctx: Host): State { @@ -14,23 +11,34 @@ function state(ctx: Host): State { if (!entry) { entry = { providers: new Set(), ptc: new Set() }; owners.set(key, entry) } return entry } -export async function registerResultProvider(ctx: Host, definition: ToolDefinition, probes: ToolDefinition[], - register: () => (() => void)): Promise<() => void> { - // Public pinned scope API loaded only during activation, never by the package's host entry. +export async function registerNativeProvider(ctx: Host, definitions: ToolDefinition[], register: (tool: ToolDefinition) => (() => void)): Promise<() => void> { const { scopeOf } = await import('@deepseek-ai/dsh-scope') const owner = state(ctx), scope = scopeOf(ctx as Context) - let dispose: (() => void) | undefined - const provider: Provider = { definition, probes, scope, active: true, publish(visible) { - if (visible && provider.active && !dispose) dispose = register() - else if (!visible && dispose) { const remove = dispose; dispose = undefined; remove() } - } } - owner.providers.add(provider) - try { if (!owner.ptc.has(scope)) provider.publish(true) } - catch (error) { provider.active = false; owner.providers.delete(provider); throw error } - return () => { provider.active = false; owner.providers.delete(provider); provider.publish(false) } + const providers = definitions.map(definition => { + let dispose: (() => void) | undefined + const provider: Provider = { definition, scope, active: true, publish(visible) { + if (visible && provider.active && !dispose) dispose = register(definition) + else if (!visible && dispose) { const remove = dispose; dispose = undefined; remove() } + } } + owner.providers.add(provider) + return provider + }) + const stop = () => { + const errors: unknown[] = [] + for (const provider of providers) { + provider.active = false; owner.providers.delete(provider) + try { provider.publish(false) } catch (error) { errors.push(error) } + } + ctx.emit?.('tools/change') + if (errors.length) throw new AggregateError(errors, 'Native provider teardown failed') + } + try { if (!owner.ptc.has(scope)) for (const provider of providers) provider.publish(true) } + catch (error) { stop(); throw error } + ctx.emit?.('tools/change') + return stop } -/** Only a PTC definition in this exact registration layer may temporarily replace its Native peer. */ -export function claimPtcResult(ctx: Host, scope: unknown): () => void { +/** Yield only this plugin's registrations in the exact layer being claimed. */ +export function claimPtcSurface(ctx: Host, scope: unknown): () => void { const owner = state(ctx) owner.ptc.add(scope) for (const provider of owner.providers) if (provider.scope === scope) provider.publish(false) @@ -39,11 +47,8 @@ export function claimPtcResult(ctx: Host, scope: unknown): () => void { for (const provider of owner.providers) if (provider.scope === scope) provider.publish(true) } } -export function nativeResultProvider(ctx: Host, scope: unknown): Provider | undefined { +/** Capture before publishing PTC: external restrictions and sibling scopes remain authoritative. */ +export function nativeProviders(ctx: Host, scope: unknown): Provider[] { const tools = ctx.get('tools') - const visible = [...state(ctx).providers].filter(provider => provider.active - && (tools?.get(provider.definition.name, scope) === provider.definition - || provider.probes.some(definition => tools?.get(definition.name, scope) === definition))) - // Fully masked or ambiguous file rows fail closed; never borrow a sibling's executor. - return visible.length === 1 ? visible[0] : undefined + return [...state(ctx).providers].filter(provider => provider.active && tools?.get(provider.definition.name, scope) === provider.definition) } diff --git a/src/file/runtime.js b/src/file/runtime.js index d99d7534..ac0d52eb 100644 --- a/src/file/runtime.js +++ b/src/file/runtime.js @@ -3,7 +3,7 @@ import { publicToolName } from '../policy.js' import { definition } from './contract.js' import { NATIVE_FILE_TOOLS } from '../capabilities/registry.js' import { resultDefinition, invokeResult } from './result.js' -import { registerResultProvider } from './result-provider.js' +import { registerNativeProvider } from './result-provider.js' import { safeDisposeAll } from '../teardown.js' import { invoke, failure } from './worker.js' @@ -24,16 +24,11 @@ export class NativeFileTools { if (this.config.required) throw new Error('Native file worker path is unavailable.') return [] } - const probes = [] - let pager - for (const rawName of NATIVE_FILE_TOOLS) { + const definitions = NATIVE_FILE_TOOLS.map(rawName => { const execute = (args, exec) => this.call(rawName, args, exec) - const tool = rawName === 'result' ? resultDefinition(execute) : definition(rawName, execute) - if (rawName === 'result') pager = tool - else probes.push(tool) - if (rawName !== 'result') this.disposers.push(this.ctx.tools.register(tool)) - } - if (pager) this.unregisterProvider = await registerResultProvider(this.ctx, pager, probes, () => this.ctx.tools.register(pager)) + return rawName === 'result' ? resultDefinition(execute) : definition(rawName, execute) + }) + this.unregisterProvider = await registerNativeProvider(this.ctx, definitions, tool => this.ctx.tools.register(tool)) return NATIVE_FILE_TOOLS.map(publicToolName) } diff --git a/src/ptc/config.ts b/src/ptc/config.ts new file mode 100644 index 00000000..75d20ea8 --- /dev/null +++ b/src/ptc/config.ts @@ -0,0 +1,21 @@ +import { FILE_TOOLS, PTC_TOOLS } from '../capabilities/registry.js' +import type { FileToolName, PtcToolName } from '../capabilities/registry.js' +export type ApiName = FileToolName | PtcToolName +export const PTC_RESIDENT_TOOLS: readonly ApiName[] = ['inspect_local_file', 'result'] +export const PTC_LOADABLE_TOOLS: readonly ApiName[] = [...FILE_TOOLS, ...PTC_TOOLS].filter(tool => !PTC_RESIDENT_TOOLS.includes(tool)) +export interface PtcConfig { binaryPath?: string; required: boolean; allowedTools: ApiName[]; alwaysLoaded: ApiName[] } +export function parsePtcConfig(raw: unknown = {}): PtcConfig { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('PTC config must be an object') + const input = raw as Record + for (const key of Object.keys(input)) if (!['binaryPath', 'required', 'allowedTools', 'alwaysLoaded'].includes(key)) throw new Error('Unknown PTC config key: ' + key) + if (input.binaryPath !== undefined && typeof input.binaryPath !== 'string') throw new Error('binaryPath must be a string') + if (input.required !== undefined && typeof input.required !== 'boolean') throw new Error('required must be boolean') + const names = (key: string, fallback: readonly ApiName[]): ApiName[] => { + const value = input[key] ?? fallback + if (!Array.isArray(value)) throw new Error(key + ' must be an array of raw API names') + for (const name of value) if (typeof name !== 'string' || !PTC_LOADABLE_TOOLS.includes(name as ApiName)) throw new Error(key + ' contains unsupported API: ' + String(name)) + return [...new Set(value as ApiName[])] + } + return { binaryPath: input.binaryPath, required: input.required ?? false, + allowedTools: names('allowedTools', PTC_LOADABLE_TOOLS), alwaysLoaded: names('alwaysLoaded', []) } +} diff --git a/src/ptc/index.js b/src/ptc/index.js deleted file mode 100644 index f39b0b22..00000000 --- a/src/ptc/index.js +++ /dev/null @@ -1,190 +0,0 @@ -/** PTC-only component. Public prompt assembly identifies the effective presentation; - * never inspect private modeFor/layers, force presentAs, or infer mode from run_code existence. - */ -import { WORKER_TIMEOUT_MS } from '../worker-protocol.js' -import { PTC_TOOLS, PTC_SECTION, definition } from './contract.js' -import { publicToolName } from '../policy.js' -import { invoke, resolveWorker, failure } from './worker.js' -import { mountWorkspacePreparation } from '../workspace-preparation.js' -import { mountResultLifecycle } from '../kernel/result-lifecycle.js' -import { nativeResultProvider, claimPtcResult } from '../file/result-provider.js' -export const name = 'dsh-ops-ptc' -export function resolvePtcConfig(raw = {}) { - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('PTC config must be an object') - for (const k of Object.keys(raw)) if (!['binaryPath', 'required'].includes(k)) throw new Error(`Unknown PTC config key: ${k}`) - if (raw.binaryPath !== undefined && typeof raw.binaryPath !== 'string') throw new Error('binaryPath must be a string') - if (raw.required !== undefined && typeof raw.required !== 'boolean') throw new Error('required must be boolean') - return raw -} -async function ptcOnly(ctx, agent, signal) { - if (!agent?.ctx || !ctx.get('ptcRuntime')) return false - const assembly = await ctx.get('systemPrompt').assemble({ scope: agent, signal }) - // Host wire presentation distinguishes ptc (only reserved transport) from both/native. - return assembly.tools.length === 1 && assembly.tools[0].name === 'run_code' - && assembly.sections.some(section => section.name === 'tools:ptc-only' && section.text.trim() !== '') -} -export function apply(root, rawConfig) { - const config = resolvePtcConfig(rawConfig) - mountWorkspacePreparation(root) - let file - try { file = resolveWorker(config) } catch (e) { - if (config.required) throw e - root.logger?.warn?.(`dsh-ops: ${e.message}`); return - } - root.inject(['tools', 'systemPrompt', 'sandboxPolicy', 'subprocess'], ctx => { - mountResultLifecycle(ctx) - const entries = new Map(), active = new Map() - ctx.effect(() => ctx.get('systemPrompt').section({ - name: PTC_SECTION, order: 3252, interpolate: false, - text: context => { - const names = ctx.get('tools').schemas(context.scope).map(tool => tool.name) - if (!PTC_TOOLS.every(tool => names.includes(publicToolName(tool)))) return '' - return 'Use ops_batch for bounded multi-file copy/move/remove: plan then resume the same id, with protect paths. Use ops_result to page owned observations or batch detailsRef; process pages locally in run_code and return only a needed summary. References do not grant authority. Do not replace read, grep or glob with these tools.' - }, - }), 'dsh-ops: scoped PTC guidance') - let stopped = false, mutating = false, epoch = 0, chain = Promise.resolve() - // One throwing disposer must not strand the rest: `reconcile()` clears every entry before - // republishing, so an exception escaping here would leave `mutating` stuck true — and every - // later `reconcile()` returns at its first line, freezing PTC publish/withdraw for the rest - // of the session. Each disposer is isolated, and the guard is released in `finally`. - const clear = entry => { - for (const dispose of entry.disposers.splice(0)) { - try { dispose() } catch (error) { ctx.logger?.warn?.('dsh-ops: PTC tool teardown failed; continuing with the remaining registrations:', error) } - } - } - const reconcile = () => { - if (stopped || mutating) return - const version = ++epoch - // Withdraw synchronously on presentation changes: no schema leakage during async assembly. - mutating = true - try { - for (const entry of entries.values()) clear(entry) - } finally { - mutating = false - } - chain = chain.then(async () => { - if (stopped || version !== epoch) return - const ready = [] - for (const [agent, entry] of entries) { - if (entry.ctx && await ptcOnly(ctx, agent).catch(() => false)) ready.push([agent, entry]) - } - if (stopped || version !== epoch) return - mutating = true - try { - for (const [agent, entry] of ready) { - for (const tool of PTC_TOOLS) { - const releaseNative = tool === 'result' ? claimPtcResult(entry.ctx, agent) : undefined - try { - const dispose = entry.ctx.tools.register(definition(tool, async (args, exec) => { - const callEpoch = epoch - if (stopped || entries.get(agent) !== entry || exec?.agent !== agent || !await ptcOnly(ctx, agent, exec.signal).catch(() => false)) { - return failure('POLICY_DENIED', 'The calling scope must be in authoritative PTC-only presentation.') - } - if (stopped || callEpoch !== epoch || entries.get(agent) !== entry) return failure('POLICY_DENIED', 'PTC presentation, component or owning scope changed during preparation.') - const controller = new AbortController() - const abort = () => controller.abort(exec.signal?.reason) - if (exec.signal?.aborted) abort(); else exec.signal?.addEventListener('abort', abort, { once: true }) - const timer = setTimeout(() => controller.abort(new Error('Worker deadline exceeded')), WORKER_TIMEOUT_MS); timer.unref?.() - const work = monitoredInvoke(ctx, file, tool, args, exec, controller, config) - active.set(controller, { owner: agent, promise: work }) - try { return await work } - finally { clearTimeout(timer); active.delete(controller); exec.signal?.removeEventListener('abort', abort) } - })) - entry.disposers.push(dispose) - if (releaseNative) entry.disposers.push(releaseNative) - } catch (error) { releaseNative?.(); throw error } - } - } - // Native/both child scopes must not inherit PTC-only capabilities from ancestors. - for (const [agent, entry] of entries) if (entry.ctx && !ready.some(([a]) => a === agent)) { - const names = ctx.get('tools').schemas(agent).map(s => s.name) - // A Native child must shadow a PTC parent's richer result definition with the - // file row's own page/release projection. The optional provider never gates PTC. - const provider = nativeResultProvider(entry.ctx, agent) - const nativeResult = provider?.active ? provider.definition : undefined - if (nativeResult && provider.scope !== agent) entry.disposers.push(entry.ctx.tools.register({ ...nativeResult, execute: async (args, exec) => { - const callEpoch = epoch - if (stopped || !provider.active || entries.get(agent) !== entry || exec?.agent !== agent - || await ptcOnly(ctx, agent, exec.signal).catch(() => true)) { - return failure('POLICY_DENIED', 'Native result projection or owning scope is unavailable.') - } - if (stopped || !provider.active || callEpoch !== epoch || entries.get(agent) !== entry) { - return failure('POLICY_DENIED', 'Native result presentation changed during preparation.') - } - return nativeResult.execute(args, exec) - } })) - const deny = PTC_TOOLS.filter(tool => tool !== 'result' || !nativeResult) - .map(publicToolName).filter(n => names.includes(n)) - if (deny.length) entry.disposers.push(entry.ctx.tools.restrict({ deny })) - } - } finally { mutating = false } - }).catch(e => ctx.logger?.warn?.(`dsh-ops: PTC publication failed: ${e.message}`)) - } - const attach = agent => { - if (stopped || entries.has(agent) || !agent?.ctx) return - const entry = { disposers: [], ctx: undefined, fiber: undefined }; entries.set(agent, entry) - entry.fiber = agent.ctx.plugin(child => { child.inject(['tools'], scoped => { entry.ctx = scoped; reconcile() }) }) - } - const detach = agent => { - const entry = entries.get(agent); if (!entry) return - entries.delete(agent); mutating = true; clear(entry); mutating = false - void entry.fiber?.dispose(); reconcile() - } - ctx.on('agent/created', ({ agent }) => attach(agent)) - ctx.on('agent/disposed', ({ agent }) => { - for (const [controller, work] of active) { - if (work.owner === agent) controller.abort(new Error('Owning agent disposed')) - } - detach(agent) - }) - ctx.on('tools/change', reconcile) - ctx.on('session/event', (_session, event) => { - if (event.type === 'sandbox/mode') { - reconcile() - } - }) - ctx.effect(() => async () => { - stopped = true; ++epoch - for (const controller of active.keys()) controller.abort(new Error('PTC component unloaded')) - await Promise.allSettled([...active.values()].map(work => work.promise)) - mutating = true - for (const entry of entries.values()) clear(entry) - // allSettled, not all: one failing fiber teardown must not abort unloading the others. - await Promise.allSettled([...entries.values()].map(e => e.fiber?.dispose())) - entries.clear(); await chain - }, 'dsh-ops: native PTC lifecycle') - for (const agent of ctx.get('agents')?.list?.() ?? []) attach(agent) - }) -} - -/** Reuse host job admission/ownership/kill when a controller is mounted. Otherwise - * the same foreground work remains bounded by the host call signal and deadline. - * Durable audit IDs are NOT a second background-job scheduler. - */ -async function monitoredInvoke(ctx, file, tool, args, exec, controller, config) { - const jobs = ctx.get('jobs') - if (tool !== 'batch' || typeof jobs?.start !== 'function') return invoke(ctx, file, tool, args, exec, controller.signal, config) - let work, id - try { - id = jobs.start({ kind: 'ops', label: `ops_batch ${args?.action ?? ''}`, owner: exec.agent.session.id, outputLimitBytes: 4096, - run(job) { - job.updateProgress(`preflight 0/${args?.operations?.length ?? '?'} errors=0`) - work = invoke(ctx, file, tool, args, exec, controller.signal, config).then(value => { - job.updateProgress(`${value.phase ?? 'settled'} ${value.done ?? 0}/${value.total ?? '?'} errors=${value.failed ?? 0} unknown=${value.unknown ?? 0}`) - return value - }) - return { cancel: reason => controller.abort(new Error(reason ?? 'Host job cancelled')), - done: work.then(value => ({ status: controller.signal.aborted ? 'killed' : value.status === 'completed' || value.status === 'planned' ? 'completed' : 'failed', - result: JSON.stringify(value) }), error => ({ status: 'failed', detail: String(error) })) } - }, - }) - } catch (e) { - ctx.logger?.warn?.(`dsh-ops: host job admission unavailable; using bounded foreground execution: ${e.message}`) - return work ?? invoke(ctx, file, tool, args, exec, controller.signal, config) - } - const value = await work - // Collect and remove only the job this foreground call owns. No polling. - try { await jobs.wait(id, 1000, exec.agent.session.id); jobs.remove(id, exec.agent.session.id) } - catch { /* Host teardown may have already collected its owned record. */ } - return value -} diff --git a/src/ptc/index.ts b/src/ptc/index.ts new file mode 100644 index 00000000..2d40fd9e --- /dev/null +++ b/src/ptc/index.ts @@ -0,0 +1,205 @@ +/** Independent PTC SDK: scoped publication through public host APIs only. */ +import type { Context, Fiber } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ToolDefinition, ToolRunContext } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' +import { WORKER_TIMEOUT_MS } from '../worker-protocol.js' +import { FILE_TOOLS, PTC_TOOLS } from '../capabilities/registry.js' +import { PTC_SECTION, definition } from './contract.js' +import { publicToolName } from '../policy.js' +import { resolveWorker, failure } from './worker.js' +import { mountWorkspacePreparation } from '../workspace-preparation.js' +import { mountResultLifecycle } from '../kernel/result-lifecycle.js' +import { nativeProviders, claimPtcSurface } from '../file/result-provider.js' +import { resolvePtcConfig } from '../config.js' +import { PTC_RESIDENT_TOOLS } from './config.js' +import type { ApiName } from './config.js' +import { sdkDefinition, requestedApis } from './sdk.js' +import { ownPtcScopes } from './ownership.js' +import { monitoredInvoke } from './invoke.js' +export { resolvePtcConfig } from '../config.js' +export const name = 'dsh-ops-ptc' +const apiTools = [...FILE_TOOLS, ...PTC_TOOLS] +const publishedNames = [...apiTools, 'sdk'].map(publicToolName) +interface Entry { + ctx?: Context; fiber?: Fiber; ready: Promise; disposers: (() => void)[] + selected: Set; pending: Set; owned: Map; ptc: boolean +} +const isPtc = (assembly: PromptAssembly): boolean => assembly.tools.length === 1 && assembly.tools[0]?.name === 'run_code' + && assembly.sections.some(section => section.name === 'tools:ptc-only' && section.text.trim() !== '') + +export function apply(root: Context, rawConfig: unknown): void { + const config = resolvePtcConfig(rawConfig) + mountWorkspacePreparation(root) + let file: string + try { file = resolveWorker(config) } catch (error) { + if (config.required) throw error + root.logger.warn('dsh-ops: ' + String(error)); return + } + root.inject(['tools', 'systemPrompt', 'sandboxPolicy', 'subprocess'], async ctx => { + const tools = ctx.get('tools'), prompt = ctx.get('systemPrompt') + if (!tools || !prompt) return + mountResultLifecycle(ctx) + const entries = new Map() + const active = new Map }>() + let stopped = false, mutating = false, epoch = 0, chain = Promise.resolve(), lastSnapshot = '' + const ids = new WeakMap(); let sequence = 0 + const snapshot = (): string => JSON.stringify([...entries.keys()].map(agent => tools.schemas(agent).map(schema => { + const def = tools.get(schema.name, agent) + if (def && !ids.has(def)) ids.set(def, ++sequence) + return [schema.name, def && ids.get(def)] + }))) + const clear = (entry: Entry) => { + entry.owned.clear() + for (const dispose of entry.disposers.splice(0)) { + try { dispose() } catch (error) { ctx.logger.warn('dsh-ops: PTC teardown failed; continuing:', error) } + } + } + const ptcOnly = async (agent: Agent, signal?: AbortSignal): Promise => Boolean(ctx.get('ptcRuntime')) && isPtc(await prompt.assemble({ scope: agent, signal })) + const abortOwner = (agent: Agent, reason: string) => { + for (const [controller, work] of active) if (work.owner === agent) controller.abort(new Error(reason)) + } + let ownership: Awaited> | undefined + const execute = (agent: Agent, entry: Entry, tool: ApiName | 'sdk'): ToolDefinition['execute'] => async (args, exec) => { + await chain + const version = epoch + const available = () => !stopped && entries.get(agent) === entry && ownership?.owns(agent) && exec.agent === agent && entry.ptc && entry.owned.has(publicToolName(tool)) + if (!available() || !await ptcOnly(agent, exec.signal).catch(() => false) || !available() || version !== epoch) { + return failure('POLICY_DENIED', 'The calling scope must own this API in authoritative PTC-only presentation.') + } + if (tool === 'sdk') { + const requested = requestedApis(args, config.allowedTools) + if (typeof requested === 'string') return failure('INVALID_ARGUMENT', requested) + for (const name of requested) entry.pending.add(name) + return { status: 'completed', tools: requested, nextTurn: true } + } + const controller = new AbortController(), abort = () => controller.abort(exec.signal?.reason) + if (exec.signal?.aborted) abort(); else exec.signal?.addEventListener('abort', abort, { once: true }) + const timer = setTimeout(() => controller.abort(new Error('Worker deadline exceeded')), WORKER_TIMEOUT_MS); timer.unref() + const work = monitoredInvoke(ctx, file, tool, args, exec as ToolRunContext, controller, config) + active.set(controller, { owner: agent, promise: work }) + try { return await work } + finally { clearTimeout(timer); active.delete(controller); exec.signal?.removeEventListener('abort', abort) } + } + const reconcile = (force = false): Promise => { + if (stopped || mutating) return chain + const version = ++epoch + chain = chain.then(async () => { + if (stopped || version !== epoch) return + const ready = new Set() + for (const [agent, entry] of entries) if (entry.ctx && ownership?.owns(agent) && await ptcOnly(agent).catch(() => false)) ready.add(agent) + if (stopped || version !== epoch) return + if (!force && snapshot() === lastSnapshot && [...entries].every(([agent, entry]) => entry.ptc === ready.has(agent))) return + mutating = true; ownership?.begin() + try { + for (const entry of entries.values()) clear(entry) + // Snapshot exact Native visibility only after all our masks/overrides have lifted. + const native = new Map([...entries].map(([agent, entry]) => [agent, nativeProviders(entry.ctx ?? ctx, agent)])) + for (const [agent, entry] of entries) { + if (!entry.ctx || !ownership?.owns(agent)) continue + entry.ptc = ready.has(agent) + if (!entry.ptc) { entry.pending.clear(); abortOwner(agent, 'PTC presentation withdrawn'); continue } + const releaseNative = claimPtcSurface(entry.ctx, agent) + try { + const wanted = new Set([...PTC_RESIDENT_TOOLS, ...config.alwaysLoaded, ...entry.selected]) + const definitions = [...wanted].map(tool => definition(tool, execute(agent, entry, tool))) + definitions.push(sdkDefinition(config.allowedTools, execute(agent, entry, 'sdk'))) + for (const def of definitions) { entry.disposers.push(entry.ctx.tools.register(def)); entry.owned.set(def.name, def) } + entry.disposers.push(releaseNative) + } catch (error) { releaseNative(); throw error } + } + for (const [agent, entry] of entries) { + if (!entry.ctx || !ownership?.owns(agent)) continue + if (!entry.ptc) for (const provider of native.get(agent) ?? []) { + const def = provider.definition + if (provider.scope === agent || tools.get(def.name, agent) === def) continue + const shadow: ToolDefinition = { ...def, execute: async (args, exec) => { + const version = epoch + if (stopped || !provider.active || entries.get(agent) !== entry || exec.agent !== agent || await ptcOnly(agent, exec.signal).catch(() => true) + || stopped || !provider.active || version !== epoch) return failure('POLICY_DENIED', 'Native projection or owning scope is unavailable.') + return def.execute(args, exec) + } } + entry.disposers.push(entry.ctx.tools.register(shadow)); entry.owned.set(def.name, shadow) + } + const nativeNames = new Set((native.get(agent) ?? []).map(provider => provider.definition.name)) + const deny = publishedNames.filter(name => !entry.owned.has(name) && (entry.ptc || !nativeNames.has(name)) && tools.get(name, agent)) + if (deny.length) entry.disposers.push(entry.ctx.tools.restrict({ deny })) + } + lastSnapshot = snapshot() + } catch (error) { + for (const entry of entries.values()) clear(entry) + throw error + } finally { ownership?.end(); mutating = false } + ownership?.changedBelow() + }).catch(error => ctx.logger.warn('dsh-ops: PTC publication failed: ' + String(error))) + return chain + } + const attach = (agent: Agent): Promise => { + if (stopped || entries.has(agent) || !agent.ctx) return entries.get(agent)?.ready ?? Promise.resolve() + const entry: Entry = { disposers: [], selected: new Set(), pending: new Set(), owned: new Map(), ptc: false, ready: Promise.resolve() } + entries.set(agent, entry); ownership?.track(agent) + entry.fiber = agent.ctx.plugin({ inject: ['tools'], apply(scoped) { entry.ctx = scoped } }) + entry.ready = Promise.resolve(entry.fiber).then(() => reconcile(true)) + return entry.ready + } + ctx.on('agent/created', async ({ agent }) => { await attach(agent); return undefined }) + ctx.on('agent/pre-step', async ({ agent, signal }, next) => { + await attach(agent) + const entry = entries.get(agent) + if (entry && !signal.aborted && await ptcOnly(agent, signal).catch(() => false)) { + for (const tool of entry.pending) entry.selected.add(tool) + const changed = entry.pending.size > 0; entry.pending.clear() + await reconcile(changed) + } + return next() + }) + ctx.on('agent/disposed', async ({ agent }) => { + const entry = entries.get(agent); if (!entry) return + entries.delete(agent); ownership?.forget(agent); abortOwner(agent, 'Owning agent disposed'); mutating = true + try { clear(entry) } finally { mutating = false } + await entry.fiber?.dispose(); await reconcile(true) + }) + ctx.on('tools/change', () => { if (!ownership?.publishing()) void reconcile(true) }) + ctx.on('system-prompt/assemble', async (_assembly, context, next) => { + const entry = entries.get(context.scope as Agent), heldPtc = Boolean(entry?.ptc && entry.owned.size) + const result = await next() + if (!entry || !heldPtc || isPtc(result)) return result + mutating = true + try { entry.ptc = false; entry.pending.clear(); abortOwner(context.scope as Agent, 'PTC presentation withdrawn'); clear(entry) } + finally { mutating = false } + void reconcile(true) + return prompt.assemble(context) + }) + ctx.on('session/event', (session, event) => { + if (event.type !== 'sandbox/mode') return + for (const [agent, entry] of entries) if (agent.session === session) { entry.pending.clear(); abortOwner(agent, 'Sandbox authority changed') } + void reconcile(true) + }) + ctx.effect(() => prompt.section({ name: PTC_SECTION, order: 3252, interpolate: false, text: context => { + const entry = entries.get(context.scope as Agent) + return entry?.ptc && entry.owned.has(publicToolName('sdk')) + ? 'OPS APIs return typed Outcome values. Inspect status before data; preserve partial coverage and continuation. Use ' + publicToolName('sdk') + ' to request advanced APIs by raw name; they become callable on the next model step. Process results locally in run_code and return only needed summaries. References do not grant authority.' : '' + } }), 'dsh-ops: progressive PTC guidance') + ctx.effect(() => async () => { + stopped = true; ++epoch; mutating = true + for (const controller of active.keys()) controller.abort(new Error('PTC component unloaded')) + for (const entry of entries.values()) clear(entry) + await Promise.allSettled([...active.values()].map(work => work.promise)) + await Promise.allSettled([...entries.values()].map(entry => entry.fiber?.dispose())) + entries.clear(); ownership?.dispose(); await chain + }, 'dsh-ops: PTC lifecycle') + ownership = await ownPtcScopes(ctx, () => { + mutating = true + try { + for (const [agent, entry] of entries) if (!ownership?.owns(agent)) { + entry.ptc = false; entry.pending.clear(); abortOwner(agent, 'PTC row ownership changed'); clear(entry) + } + } finally { mutating = false } + void reconcile(true) + }) + for (const agent of entries.keys()) ownership.track(agent) + ownership.refresh() + for (const agent of new Set([...(ctx.get('agents')?.list() ?? []), ...ownership.agents()])) void attach(agent) + }) +} diff --git a/src/ptc/invoke.ts b/src/ptc/invoke.ts new file mode 100644 index 00000000..afdcd88e --- /dev/null +++ b/src/ptc/invoke.ts @@ -0,0 +1,48 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import { FILE_TOOLS } from '../capabilities/registry.js' +import { invoke as fileInvoke } from '../file/worker.js' +import { invoke } from './worker.js' +import { record } from '../modes/ptc/outcome.js' +import { publicToolName } from '../policy.js' +import type { ApiName, PtcConfig } from './config.js' +interface JobHooks { updateProgress(text: string): void } +interface Jobs { + start(spec: { kind: string; label: string; owner: string; outputLimitBytes: number; + run(job: JobHooks): { cancel(reason?: string): void; done: Promise<{ status: string; result?: string; detail?: string }> } }): string + wait(id: string, timeout: number, owner: string): Promise + remove(id: string, owner: string): unknown +} +/** File APIs retain the same authority bridge; only batch participates in host jobs. */ +export async function monitoredInvoke(ctx: Context, file: string, tool: ApiName, args: unknown, exec: ToolRunContext, + controller: AbortController, config: PtcConfig): Promise { + const owner = exec.agent?.session.id + if (!owner) return { status: 'error', error: { code: 'POLICY_DENIED', message: 'An owning agent is required.', effectState: 'none' } } + if ((FILE_TOOLS as readonly string[]).includes(tool)) return fileInvoke(ctx, file, tool, args, exec, controller.signal, config) + const jobs = ctx.get('jobs') as Jobs | undefined + if (tool !== 'batch' || typeof jobs?.start !== 'function') return invoke(ctx, file, tool, args, exec, controller.signal, config) + let work: Promise | undefined, id: string + const input = record(args) + try { + id = jobs.start({ kind: 'ops', label: publicToolName('batch') + ' ' + String(input?.action ?? ''), owner: owner, outputLimitBytes: 4096, + run(job) { + job.updateProgress('preflight 0/' + (Array.isArray(input?.operations) ? input.operations.length : '?') + ' errors=0') + work = invoke(ctx, file, tool, args, exec, controller.signal, config).then((raw: unknown) => { + const value = record(raw) + job.updateProgress(String(value?.phase ?? 'settled') + ' ' + (value?.done ?? 0) + '/' + (value?.total ?? '?') + ' errors=' + (value?.failed ?? 0) + ' unknown=' + (value?.unknown ?? 0)) + return raw + }) + return { cancel: reason => controller.abort(new Error(reason ?? 'Host job cancelled')), + done: work!.then(raw => ({ status: controller.signal.aborted ? 'killed' : ['completed', 'planned'].includes(String(record(raw)?.status)) ? 'completed' : 'failed', result: JSON.stringify(raw) }), + error => ({ status: 'failed', detail: String(error) })) } + }, + }) + } catch (error) { + ctx.logger.warn('dsh-ops: host job admission unavailable; using bounded foreground execution: ' + String(error)) + return work ?? invoke(ctx, file, tool, args, exec, controller.signal, config) + } + const value = await work + try { await jobs.wait(id, 1000, owner); jobs.remove(id, owner) } + catch { /* Host teardown may already have collected the owned record. */ } + return value +} diff --git a/src/ptc/ownership.ts b/src/ptc/ownership.ts new file mode 100644 index 00000000..c578c882 --- /dev/null +++ b/src/ptc/ownership.ts @@ -0,0 +1,37 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +interface Row { scope?: object; changed(): void } +interface State { rows: Row[]; agents: Set; publishing: number } +const roots = new WeakMap() +/** The nearest plugin row owns the SDK; overlapping rows never double-register one layer. */ +export async function ownPtcScopes(ctx: Context, changed: () => void) { + const { scopeOf, scopeChainOf } = await import('@deepseek-ai/dsh-scope') + const key = ctx.root ?? ctx + let state = roots.get(key) + if (!state) { state = { rows: [], agents: new Set(), publishing: 0 }; roots.set(key, state) } + const shared = state, row: Row = { scope: scopeOf(ctx), changed } + shared.rows.push(row) + const notify = () => { + ++shared.publishing + try { for (const owner of shared.rows) owner.changed() } finally { --shared.publishing } + } + const owns = (agent: Agent): boolean => { + for (const scope of [...scopeChainOf(agent), undefined]) { + const matching = shared.rows.filter(candidate => candidate.scope === scope) + // Last mounted row wins only within the same explicit scope; unload restores the previous row. + if (matching.length) return matching.at(-1) === row + } + return false + } + // Call refresh after the caller has installed its ownership handle. + return { owns, refresh: notify, publishing: () => shared.publishing > 0, + begin: () => { ++shared.publishing }, end: () => { --shared.publishing }, + changedBelow: () => { + for (const owner of shared.rows) if (owner !== row && owner.scope && owner.scope !== row.scope + && (!row.scope || scopeChainOf(owner.scope).includes(row.scope))) owner.changed() + }, agents: () => [...shared.agents], + track: (agent: Agent) => { shared.agents.add(agent) }, + forget: (agent: Agent) => { shared.agents.delete(agent) }, + dispose: () => { shared.rows.splice(shared.rows.indexOf(row), 1); notify(); if (!shared.rows.length) roots.delete(key) }, + } +} diff --git a/src/ptc/sdk.ts b/src/ptc/sdk.ts new file mode 100644 index 00000000..4b6724c6 --- /dev/null +++ b/src/ptc/sdk.ts @@ -0,0 +1,27 @@ +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import { SDK_CAPABILITIES } from '../capabilities/sdk.js' +import { publicToolName } from '../policy.js' +import { toOutcome } from '../modes/ptc/outcome.js' +import { outcomeSchema } from '../modes/ptc/schema.js' +import type { ApiName } from './config.js' +export function sdkDefinition(allowed: readonly ApiName[], execute: ToolDefinition['execute']): ToolDefinition { + const entry = SDK_CAPABILITIES[0] + return { name: publicToolName(entry.rawName), description: entry.description + ' Allowed raw names: ' + (allowed.join(', ') || '(none)') + '.', + parameters: entry.parameters, output: { schema: outcomeSchema(entry.output), render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] }, + execute: async (args, exec) => { + let value: unknown + try { value = await execute(args, exec) } + catch { value = { status: 'error', error: { code: 'EXECUTOR_UNAVAILABLE', effectState: 'none', message: 'SDK selection is unavailable; no file operation ran.' } } } + const outcome = toOutcome('sdk', args, value) + // Loader rejections have no execution data: keep its successful receipt schema exact. + return 'error' in outcome ? { status: outcome.status, error: outcome.error } : outcome + } } +} +export function requestedApis(args: unknown, allowed: readonly ApiName[]): ApiName[] | string { + if (!args || typeof args !== 'object' || Array.isArray(args)) return 'SDK arguments must be an object.' + const input = args as Record + for (const key of Object.keys(input)) if (key !== 'tools') return 'Unknown SDK argument: ' + key + if (!Array.isArray(input.tools) || input.tools.length < 1 || input.tools.length > 5) return 'tools must contain 1-5 exact raw API names.' + for (const name of input.tools) if (typeof name !== 'string' || !allowed.includes(name as ApiName)) return 'API is not in this scope’s allowed pool: ' + String(name) + return [...new Set(input.tools as ApiName[])] +} diff --git a/test/bundle-startup.test.mjs b/test/bundle-startup.test.mjs index c2a52a1d..b4522f6b 100644 --- a/test/bundle-startup.test.mjs +++ b/test/bundle-startup.test.mjs @@ -45,16 +45,20 @@ for (const mode of ['read-only', 'workspace-write', 'danger-full-access']) { await agent.ctx.plugin(SearchTools, { sampleOverCapGlobResults: false }) await ctx.serial('agent/created', { agent, source: 'startup' }) const names = () => ctx.get('tools').schemas(agent).map(tool => tool.name) - await waitFor(() => names().includes('ops_stat') && names().includes('read') && names().includes('grep') && - names().includes('ops_batch') === (presentation === 'ptc') && + await waitFor(() => names().includes('ops_inspect_local_file') && names().includes('read') && names().includes('grep') && + names().includes('ops_sdk') === (presentation === 'ptc') && names().includes('ops_bash'), { what: `${mode}/${presentation} default publication` }) const assembled = await ctx.get('systemPrompt').assemble({ scope: agent, signal: new AbortController().signal }) assert.equal(assembled.tools.some(tool => tool.name === 'run_code'), presentation !== 'native') assert.ok(assembled.sections.length > 0) assert.ok(names().includes('write'), 'defaults must not mask standard-preset mutation tools') + if (presentation === 'ptc') { + await ctx.get('tools').get('ops_sdk', agent).execute({ tools: ['stat'] }, { agent, signal: new AbortController().signal }) + await ctx.waterfall('agent/pre-step', { agent, messages: [], turn: 1, step: 1, signal: new AbortController().signal }, () => Promise.resolve({ kind: 'continue' })) + } const result = await ctx.get('tools').get('ops_stat', agent).execute({ path: path.join(root, 'absent') }, { agent, signal: new AbortController().signal }) assert.equal(result.status, 'completed', JSON.stringify(result)) - assert.equal(result.exists, false) + assert.equal((presentation === 'ptc' ? result.data : result).exists, false) console.log(` startup ${mode}/${presentation}: ${assembled.tools.length} wire tools; official + OPS intact`) } } finally { await shutdown(ctx) } diff --git a/test/capabilities.test.mjs b/test/capabilities.test.mjs index d3adfeaf..a9458052 100644 --- a/test/capabilities.test.mjs +++ b/test/capabilities.test.mjs @@ -9,7 +9,7 @@ import { BASH_TOOL_NAME } from '../lib/shells/constants.js' import { assert, test, report } from './lib/harness.mjs' await test('the catalogue covers every owned file, PTC and shell tool exactly once', () => { - assert.equal(CAPABILITIES.length, 16) + assert.equal(CAPABILITIES.length, 17) assert.equal(new Set(CAPABILITIES.map(item => item.rawName)).size, CAPABILITIES.length) assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many']) assert.deepEqual(FILE_MUTATIONS, ['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) @@ -43,7 +43,7 @@ const snapshot = (names, definition) => names.map(name => { // Reference metadata was added in6d; preserve the earlier business argument/output snapshot. d.output.schema = structuredClone(d.output.schema) for (const key of ['reference', 'referenceRoot', 'root', 'source', 'storageError', 'nextAction']) { - if (definition === fileDefinition || ['reference', 'source'].includes(key)) delete d.output.schema.properties[key] + if (definition === fileDefinition) delete d.output.schema.properties[key] } return { name: d.name, description: d.description, parameters: d.parameters, output: d.output.schema, concurrent: d.isConcurrencySafe?.({}) ?? false, timeoutMs: d.timeoutMs ?? null } @@ -52,7 +52,9 @@ await test('business file and batch definitions retain their original serialized // Captured before this migration, covering every argument/output contract and published description. const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') assert.equal(hash(snapshot(FILE_TOOLS.filter(name => !['glob', 'grep', 'read_many', 'edit_many'].includes(name)), fileDefinition)), 'f32e33306b4052fdb3d19a11e0fe9d65c255a87d2d485de08a9d7b1275e958fc') - assert.equal(hash(snapshot(['batch'], ptcDefinition)), 'f979821d5fc132d365280a27b18930137c94e3208a82cb780a363b0b06659d80') + const batch = ptcDefinition('batch', async () => null) + assert.equal(batch.parameters, ptcParameters.batch); assert.ok(batch.description.startsWith(ptcDescriptions.batch)); assert.match(batch.description, /PTC returns Outcome/) + assert.ok(batch.output.schema.oneOf.every(branch => branch.properties.status)) }) await test('native registration consumes the catalogue while execute values stay unchanged', async () => { @@ -66,7 +68,9 @@ await test('native registration consumes the catalogue while execute values stay for (const [names, definition] of [[FILE_TOOLS, fileDefinition], [PTC_TOOLS, ptcDefinition]]) { for (const name of names) { const tool = definition(name, async () => canonical) - assert.equal(await tool.execute({}, {}), canonical) + const returned = await tool.execute({}, {}) + if (names === FILE_TOOLS) assert.equal(returned, canonical) + else assert.equal(returned.data, canonical) assert.equal(canonical.items[0].path, '/file') if (names === PTC_TOOLS) assert.deepEqual(JSON.parse(tool.output.render({}, canonical)[0].text), canonical) else assert.equal(typeof tool.output.render({}, canonical)[0].text, 'string') diff --git a/test/mount.test.mjs b/test/mount.test.mjs index 6f09eba8..6944d2cc 100644 --- a/test/mount.test.mjs +++ b/test/mount.test.mjs @@ -117,7 +117,7 @@ await test('PTC batch/result mount only in pure PTC scopes and withdraw on prese try { await executor(ctx) await ctx.plugin(inner => inner.provide('ptcRuntime', { language: 'typescript' })) - const component = await ctx.plugin(ptc, { binaryPath: resolveWorker(), required: true }) + const component = await ctx.plugin(ptc, { binaryPath: resolveWorker(), required: true, alwaysLoaded: ['batch'] }) const native = await agent(ctx, 'ptc-native') const pure = await agent(ctx, 'ptc-only') let lift @@ -129,6 +129,7 @@ await test('PTC batch/result mount only in pure PTC scopes and withdraw on prese } const stale = ctx.get('tools').get('ops_result', pure) lift() + await ctx.get('systemPrompt').assemble({ scope: pure }) assert.equal(scopedToolNames(ctx, pure).includes('ops_result'), false) assert.equal((await stale.execute({ action: 'page', id: 'unused' }, { agent: pure, signal: new AbortController().signal })).error.code, 'POLICY_DENIED') await component.dispose() diff --git a/test/native-result.test.mjs b/test/native-result.test.mjs index 6acc3f3f..ae0f2bfa 100644 --- a/test/native-result.test.mjs +++ b/test/native-result.test.mjs @@ -198,7 +198,7 @@ await test('agent-scoped file rows isolate sibling pagers and yield only their e const late = await agent(ctx, root, 'ptc') await waitFor(() => current(late)?.parameters.properties.action.enum.includes('put'), { timeoutMs: 5000, what: 'PTC before file mount' }) const lateFile = await mount(late) - await waitFor(() => ctx.get('tools').get('ops_grep', late) && current(late)?.parameters.properties.action.enum.includes('put'), { timeoutMs: 5000, what: 'file mounted after PTC' }) + await waitFor(() => !ctx.get('tools').get('ops_grep', late) && current(late)?.parameters.properties.action.enum.includes('put'), { timeoutMs: 5000, what: 'file mounted after PTC' }) await component.dispose() await waitFor(() => current(left)?.parameters.properties.action.enum.length === 2 && current(late)?.parameters.properties.action.enum.length === 2, { timeoutMs: 5000, what: 'Native peer resumes on PTC unload' }) await leftFile.dispose(); assert.equal(current(left), undefined) diff --git a/test/ptc-cost.test.mjs b/test/ptc-cost.test.mjs index 0762fe5c..253c371c 100644 --- a/test/ptc-cost.test.mjs +++ b/test/ptc-cost.test.mjs @@ -50,7 +50,7 @@ await test('measure PTC enhancement separately from the existing file/shell decl for (const [, agent] of agents) await ctx.serial('agent/created', { agent }) const pure = agents.find(([mode]) => mode === 'ptc')[1] const deadline = Date.now() + 5000 - while (!ctx.get('tools').get('ops_batch', pure) || !ctx.get('tools').get('ops_result', pure)?.parameters.properties.action.enum.includes('put')) { + while (!ctx.get('tools').get('ops_sdk', pure) || !ctx.get('tools').get('ops_result', pure)?.parameters.properties.action.enum.includes('put')) { if (Date.now() > deadline) throw new Error('PTC declarations did not become available') await new Promise(resolve => setTimeout(resolve, 10)) } @@ -62,7 +62,7 @@ await test('measure PTC enhancement separately from the existing file/shell decl assert.equal(after.promptSha256, before.promptSha256, `${mode} prompt must not gain PTC declarations: ${JSON.stringify({ before: before.sections, after: after.sections })}`) assert.equal(after.wireSha256, before.wireSha256, `${mode} wire must not gain PTC tools`) } else { - assert.ok(after.promptBytes > before.promptBytes, 'discoverable SDK types have a measurable declaration cost') + assert.ok(after.promptBytes < before.promptBytes, 'progressive SDK replaces the eager Native file catalogue with a smaller initial declaration surface') assert.equal(after.wireSha256, before.wireSha256, 'run_code remains the only direct transport') } return { mode, enhancementPromptChars: after.promptChars - before.promptChars, diff --git a/test/ptc-sdk.test.mjs b/test/ptc-sdk.test.mjs new file mode 100644 index 00000000..3da959c6 --- /dev/null +++ b/test/ptc-sdk.test.mjs @@ -0,0 +1,182 @@ +import fs from 'node:fs' +import path from 'node:path' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import Sandbox from '@deepseek-ai/dsh-sandbox-local' +import FileSystem from '@deepseek-ai/dsh-fs-local' +import NodePtc from '@deepseek-ai/dsh-ptc-runtime-node' +import { createScope } from '@deepseek-ai/dsh-scope' +import { assertSupportedJsonSchema, jsonSchemaToTs } from '@deepseek-ai/dsh-tools' +import { assert, test, report, withTempDir, waitFor } from './lib/harness.mjs' +import { bootHost, shutdown } from './lib/host.mjs' +import { WORKER, requireWorker } from './lib/boundary.mjs' +import * as ptc from '../lib/ptc/index.js' +import { applyComponent } from '../lib/index.js' +import { resolvePtcConfig } from '../lib/config.js' +import { sdkDefinition, requestedApis } from '../lib/ptc/sdk.js' +requireWorker('ptc-sdk') +let serial = 0 +const visible = (ctx, agent) => ctx.get('tools').schemas(agent).map(tool => tool.name).filter(name => name.startsWith('ops_')).sort() +async function setup(root, { native = false, config = {}, real = false } = {}) { + const { ctx } = await bootHost() + await ctx.plugin(Subprocess); await ctx.plugin(Sandbox); await ctx.plugin(FileSystem) + await ctx.plugin(inner => { + inner.provide('sandboxPolicy', { resolve: ({ session } = {}) => ({ mode: session?.mode ?? 'workspace-write', workspaceRoot: root, sessionId: session?.id }) }) + if (!real) inner.provide('ptcRuntime', { language: 'typescript' }) + }) + if (real) await ctx.plugin(NodePtc, {}) + const mountFile = target => target.plugin({ apply(inner) { applyComponent(inner, { binaryPath: WORKER, required: true }, 'file') } }) + if (native) await mountFile(ctx) + const component = await ctx.plugin(ptc, { binaryPath: WORKER, required: true, ...config }) + async function agent(mode = 'ptc', parent) { + const id = 'sdk-' + ++serial + const value = { id, session: { id, mode: 'workspace-write', header: { cwd: root }, append() {}, read() { return [] } } } + value.ctx = createScope(parent?.ctx ?? ctx, value, parent ? { parent } : undefined).ctx + await value.ctx.plugin({ inject: ['tools'], apply(inner) { value.lift = inner.tools.presentAs(mode) } }) + await ctx.serial('agent/created', { agent: value }) + return value + } + return { ctx, component, agent, mountFile } +} +const step = (ctx, agent, signal = new AbortController().signal) => ctx.waterfall('agent/pre-step', { agent, messages: [], turn: 1, step: ++serial, signal }, () => Promise.resolve({ kind: 'continue' })) +const direct = (ctx, agent, name, args) => ctx.get('tools').get(name, agent).execute(args, { agent, signal: new AbortController().signal }) +async function code(ctx, agent, body) { + const value = await ctx.get('tools').execute({ name: 'run_code', callId: 'sdk-call-' + ++serial, agent, + signal: new AbortController().signal, arguments: { description: 'Exercise the progressive OPS SDK', code: body } }) + assert.equal(value.isError, false, JSON.stringify(value)); return value.value.result +} +await test('SDK config and request validation name unknown keys and reject unsupported selections atomically', async () => { + assert.deepEqual(resolvePtcConfig().alwaysLoaded, []) + assert.throws(() => resolvePtcConfig({ surprise: true }), /surprise/) + assert.throws(() => resolvePtcConfig({ allowedTools: ['ops_grep'] }), /allowedTools.*ops_grep/) + assert.throws(() => resolvePtcConfig({ alwaysLoaded: ['sdk'] }), /alwaysLoaded.*sdk/) + for (const input of [{ tools: [] }, { tools: Array(6).fill('grep') }, { tools: ['grep', 'foreign'] }, { tools: ['grep'], root: '/' }]) assert.equal(typeof requestedApis(input, ['grep']), 'string') + assert.deepEqual(requestedApis({ tools: ['grep', 'grep'] }, ['grep']), ['grep']) + const schema = sdkDefinition(['grep'], async () => ({})).output.schema + assertSupportedJsonSchema(schema); assert.match(jsonSchemaToTs(schema), /nextTurn: true/) + const unavailable = await sdkDefinition([], async () => { throw new Error('fixture') }).execute({}, {}) + assert.equal(unavailable.error.code, 'EXECUTOR_UNAVAILABLE'); assert.equal(unavailable.error.effectState, 'none') +}) +await test('real host SDK stays lean without Native file row and activates requested APIs only at next step', async () => { + await withTempDir('ptc-progressive', async root => { + const { ctx, agent } = await setup(root, { real: true }) + try { + fs.writeFileSync(path.join(root, 'note.txt'), 'first\nsecond\n') + const owner = await agent(), sibling = await agent() + assert.deepEqual(visible(ctx, owner), ['ops_inspect_local_file', 'ops_result', 'ops_sdk']) + const before = await ctx.get('systemPrompt').assemble({ scope: owner }) + assert.deepEqual(before.tools.map(tool => tool.name), ['run_code']) + const sdk = before.sections.find(section => section.name === 'tools:sdk').text + assert.match(sdk, /status: "completed"/); assert.doesNotMatch(sdk, /ops_stat:/) + const sameCall = await code(ctx, owner, `const invalid = await tools.ops_sdk({tools:['stat','unknown']}); const loaded = await tools.ops_sdk({tools:['stat','grep','read_many','batch']}); + let refused = false; try { await tools.ops_stat({path:'note.txt'}); } catch { refused = true; } + const read = await tools.ops_inspect_local_file({path:'note.txt',offset:0,limit:1}); return {invalid,loaded,refused,read};`) + assert.equal(sameCall.invalid.error.code, 'INVALID_ARGUMENT'); assert.equal(sameCall.loaded.status, 'completed'); assert.equal(sameCall.loaded.data.nextTurn, true) + assert.equal(sameCall.refused, true); assert.equal(sameCall.read.status, 'partial', JSON.stringify(sameCall.read)); assert.ok(sameCall.read.data.items) + await ctx.get('systemPrompt').assemble({ scope: owner }); await ctx.serial('tools/change') + assert.equal(visible(ctx, owner).includes('ops_stat'), false, 'assembly and registry events cannot activate pending selections') + await step(ctx, owner) + assert.equal(visible(ctx, owner).includes('ops_stat'), true) + assert.deepEqual(visible(ctx, sibling), ['ops_inspect_local_file', 'ops_result', 'ops_sdk']) + const nextCall = await code(ctx, owner, `const stat = await tools.ops_stat({path:'note.txt'}); + const batch = await tools.ops_batch({action:'plan',operations:[{op:'copy',path:'note.txt',to:'copy.txt'}]}); + const stored = await tools.ops_result({action:'put',items:[{n:1}]}); + const page = await tools.ops_result({action:'page',id:stored.data.id}); return {stat,batch,stored,page};`) + for (const result of Object.values(nextCall)) { assert.equal(result.status, 'completed', JSON.stringify(result)); assert.ok(result.data) } + assert.equal(nextCall.stat.data.exists, true); assert.equal(nextCall.batch.data.status, 'planned'); assert.deepEqual(nextCall.page.data.items, [{ n: 1 }]) + owner.session.mode = 'read-only' + const denied = await code(ctx, owner, `return await tools.ops_batch({action:'run',operations:[{op:'remove',path:'note.txt'}]});`) + assert.equal(denied.status, 'rejected'); assert.equal(denied.error.code, 'POLICY_DENIED'); assert.equal(denied.error.effectState, 'none') + assert.equal(fs.existsSync(path.join(root, 'note.txt')), true) + } finally { await shutdown(ctx) } + }) +}) +await test('Native/PTC child scopes preserve projections and same-layer file rows yield without leaking advanced APIs', async () => { + await withTempDir('ptc-projection-scopes', async root => { + const { ctx, agent, mountFile, component } = await setup(root, { native: true }) + try { + const parent = await agent(), child = await agent('native', parent), both = await agent('both'), independent = await agent() + assert.deepEqual(visible(ctx, parent), ['ops_inspect_local_file', 'ops_result', 'ops_sdk']) + for (const scope of [child, both]) { assert.ok(visible(ctx, scope).includes('ops_grep')); assert.equal(visible(ctx, scope).includes('ops_sdk'), false) } + assert.equal(ctx.get('tools').get('ops_result', child).parameters.properties.action.enum.includes('put'), false) + const previousNative = ctx.get('tools').get('ops_stat', child) + const scopedFile = await mountFile(parent.ctx) + await waitFor(() => visible(ctx, parent).length === 3 && ctx.get('tools').get('ops_stat', child) !== previousNative, { timeoutMs: 5000, what: 'own-layer handoff' }) + const loaded = await direct(ctx, parent, 'ops_sdk', { tools: ['grep'] }); assert.equal(loaded.status, 'completed', JSON.stringify(loaded)) + await step(ctx, parent); assert.ok(visible(ctx, parent).includes('ops_grep')) + assert.equal(visible(ctx, independent).includes('ops_grep'), false) + assert.equal(visible(ctx, child).includes('ops_sdk'), false) + const retained = ctx.get('tools').get('ops_grep', parent) + parent.lift(); await parent.ctx.plugin({ inject: ['tools'], apply(inner) { parent.lift = inner.tools.presentAs('native') } }) + const assembled = await ctx.get('systemPrompt').assemble({ scope: parent }) + assert.equal(assembled.tools.some(tool => tool.name === 'ops_sdk'), false) + assert.equal((await retained.execute({ path: root, pattern: 'x' }, { agent: parent, signal: new AbortController().signal })).error.code, 'POLICY_DENIED') + await component.dispose(); assert.ok(visible(ctx, parent).includes('ops_grep')); assert.equal(visible(ctx, child).includes('ops_sdk'), false) + await scopedFile.dispose(); assert.equal(Object.hasOwn(ctx.get('tools'), 'register'), false) + } finally { await shutdown(ctx) } + }) +}) +await test('allowed pools, always-loaded selections, pending cancellation and retained callbacks remain scope bound', async () => { + await withTempDir('ptc-sdk-ownership', async root => { + const { ctx, agent, component } = await setup(root, { config: { allowedTools: ['grep'], alwaysLoaded: ['stat'] } }) + try { + const owner = await agent(), sibling = await agent(), child = await agent('ptc', owner) + assert.ok(visible(ctx, owner).includes('ops_stat')) + const retained = ctx.get('tools').get('ops_sdk', owner) + assert.equal((await retained.execute({ tools: ['grep'] }, { agent: sibling, signal: new AbortController().signal })).error.code, 'POLICY_DENIED') + assert.equal((await direct(ctx, owner, 'ops_sdk', { tools: ['grep', 'batch'] })).error.code, 'INVALID_ARGUMENT') + await step(ctx, owner); assert.equal(visible(ctx, owner).includes('ops_grep'), false) + await direct(ctx, owner, 'ops_sdk', { tools: ['grep'] }) + await ctx.serial('session/event', owner.session, { type: 'sandbox/mode' }) + await step(ctx, owner); assert.equal(visible(ctx, owner).includes('ops_grep'), false) + await direct(ctx, owner, 'ops_sdk', { tools: ['grep'] }); await step(ctx, owner) + assert.ok(visible(ctx, owner).includes('ops_grep')); assert.equal(visible(ctx, sibling).includes('ops_grep'), false); assert.equal(visible(ctx, child).includes('ops_grep'), false) + await ctx.serial('agent/disposed', { agent: owner }) + assert.equal((await retained.execute({ tools: ['grep'] }, { agent: owner, signal: new AbortController().signal })).error.code, 'POLICY_DENIED') + await component.dispose(); assert.deepEqual(visible(ctx, sibling), []) + } finally { await shutdown(ctx) } + }) +}) +await test('Native child restoration honors external ancestor masks and late parent file mounts', async () => { + await withTempDir('ptc-external-masks', async root => { + const { ctx, agent, mountFile } = await setup(root) + try { + const parent = await agent(), child = await agent('native', parent) + assert.deepEqual(visible(ctx, child), []) + const file = await mountFile(parent.ctx) + await waitFor(() => visible(ctx, child).includes('ops_grep'), { timeoutMs: 5000, what: 'late parent Native provider' }) + assert.deepEqual(visible(ctx, parent), ['ops_inspect_local_file', 'ops_result', 'ops_sdk']) + await file.dispose() + await waitFor(() => visible(ctx, child).length === 0, { timeoutMs: 5000, what: 'late provider disposal' }) + await mountFile(ctx) + await waitFor(() => visible(ctx, child).includes('ops_grep'), { timeoutMs: 5000, what: 'global Native provider' }) + const mask = await parent.ctx.plugin({ inject: ['tools'], apply(scoped) { scoped.tools.restrict({ deny: ['ops_grep'] }) } }) + await waitFor(() => !visible(ctx, child).includes('ops_grep'), { timeoutMs: 5000, what: 'external ancestor restriction' }) + assert.ok(visible(ctx, child).includes('ops_stat')) + await mask.dispose() + await waitFor(() => visible(ctx, child).includes('ops_grep'), { timeoutMs: 5000, what: 'external restriction release' }) + } finally { await shutdown(ctx) } + }) +}) +await test('nearer overlapping PTC rows own their scopes without duplicate registration or event loops', async () => { + await withTempDir('ptc-overlap', async root => { + const { ctx, agent } = await setup(root) + try { + const parent = await agent(), child = await agent('ptc', parent), sibling = await agent() + const former = ctx.get('tools').get('ops_sdk', parent) + let events = 0; ctx.on('tools/change', () => { if (++events > 2000) throw new Error('publication event loop') }) + const scoped = await parent.ctx.plugin({ name: 'nearer-ptc-row', apply(inner) { ptc.apply(inner, { binaryPath: WORKER, required: true, allowedTools: ['grep'], alwaysLoaded: ['stat'] }) } }) + await waitFor(() => visible(ctx, parent).includes('ops_stat') && visible(ctx, child).includes('ops_stat'), { timeoutMs: 5000, what: 'nearer PTC row' }).catch(error => { throw new Error(String(error) + ' parent=' + visible(ctx,parent) + ' child=' + visible(ctx,child) + ' events=' + events) }) + assert.equal(visible(ctx, sibling).includes('ops_stat'), false) + assert.equal((await former.execute({ tools: ['batch'] }, { agent: parent, signal: new AbortController().signal })).error.code, 'POLICY_DENIED') + assert.equal((await direct(ctx, parent, 'ops_sdk', { tools: ['batch'] })).error.code, 'INVALID_ARGUMENT') + await direct(ctx, sibling, 'ops_sdk', { tools: ['grep'] }); await step(ctx, sibling) + assert.equal(visible(ctx, child).includes('ops_grep'), false) + const before = events; await ctx.get('systemPrompt').assemble({ scope: child }); await step(ctx, child) + assert.ok(events - before < 100, 'an unchanged boundary must not repeatedly rebuild overlapping rows') + await scoped.dispose() + await waitFor(() => !visible(ctx, parent).includes('ops_stat') && !visible(ctx, child).includes('ops_stat') && visible(ctx, parent).includes('ops_sdk') && visible(ctx, child).includes('ops_sdk'), { timeoutMs: 5000, what: 'outer row restored' }) + assert.equal((await direct(ctx, parent, 'ops_sdk', { tools: ['batch'] })).status, 'completed') + } finally { await shutdown(ctx) } + }) +}) +report('ptc-sdk') diff --git a/test/ptc.test.mjs b/test/ptc.test.mjs index b80ffaf8..9251099a 100644 --- a/test/ptc.test.mjs +++ b/test/ptc.test.mjs @@ -224,7 +224,7 @@ try { return { argv, enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } } }) }) - const component = await ctx.plugin(ptc, { binaryPath: file, required: true }) + const component = await ctx.plugin(ptc, { binaryPath: file, required: true, alwaysLoaded: ['batch'] }) const makeAgent = async (id, presentation, parent) => { const agent = { id, session: { id, mode: 'workspace-write', header: { cwd: dir } } } const scope = createScope(parent?.ctx ?? ctx, agent); agent.ctx = scope.ctx @@ -248,14 +248,14 @@ try { const assembled = await ctx.get('systemPrompt').assemble({scope:pure}) assert.deepEqual(assembled.tools.map(t => t.name), ['run_code']) assert.ok(assembled.sections.some(s => s.text.includes('ops_batch'))) - assert.ok(assembled.sections.some(s => s.name === 'dsh-ops:repository-tooling:ptc' && s.text.includes('Do not replace read'))) + assert.ok(assembled.sections.some(s => s.name === 'dsh-ops:repository-tooling:ptc' && s.text.includes('typed Outcome'))) assert.equal((await ctx.get('systemPrompt').assemble({scope:native})).sections.some(s => s.text.includes('ops_batch')), false) const def = ctx.get('tools').get('ops_batch', pure) const wrongOwner = await def.execute({ action: 'plan', operations: [] }, { agent: ptcChild, signal: new AbortController().signal }) assert.equal(wrongOwner.error.code, 'POLICY_DENIED') write('host-copy', 'host data') const result = await ctx.get('tools').get('ops_batch', pure).execute({ action:'run',operations:[{op:'copy',path:'host-copy',to:'host-dest'}] }, {agent:pure,signal:new AbortController().signal}) - assert.equal(result.status,'completed',JSON.stringify(result)); assert.equal(result.sandbox.enforcement,'full') + assert.equal(result.status,'completed',JSON.stringify(result)); assert.equal(result.data.sandbox.enforcement,'full') const hostJobs = new Map(); let jobNumber = 0 await ctx.plugin(inner => inner.provide('jobs', { start(spec) { @@ -269,7 +269,7 @@ try { pure.session.mode = 'read-only' const readonly = await def.execute({action:'run',operations:[{op:'remove',path:'host-dest'}]}, {agent:pure,signal:new AbortController().signal}) assert.equal(readonly.error.code,'POLICY_DENIED') - assert.equal(readonly.error.permission.current, 'read-only'); assert.match(readonly.error.nextAction, /Do not retry/) + assert.equal(readonly.data.error.permission.current, 'read-only'); assert.match(readonly.error.nextAction, /Do not retry/) pure.session.mode = 'workspace-write'; sandboxAvailable = false assert.equal((await def.execute({action:'run',operations:[{op:'remove',path:'host-dest'}]}, {agent:pure,signal:new AbortController().signal})).error.code,'EXECUTOR_UNAVAILABLE') assert.equal(calls.length,before); assert.equal(hostJobs.size,0) @@ -316,7 +316,7 @@ try { const partial = await invoke(partialSandboxCtx,file,'batch',{action:'plan',operations:[{op:'remove',path:'host-dest'}]}, {agent:pure},new AbortController().signal) assert.equal(partial.status,'planned'); assert.equal(partial.sandbox.enforcement,'partial') const monitored = await def.execute({action:'plan',operations:[{op:'remove',path:'host-dest'}]}, {agent:pure,signal:new AbortController().signal}) - assert.equal(monitored.status,'planned'); assert.equal(hostJobs.size,0); assert.ok(jobNumber >= 1) + assert.equal(monitored.status,'completed'); assert.equal(monitored.data.status,'planned'); assert.equal(hostJobs.size,0); assert.ok(jobNumber >= 1) const aborted = new AbortController(); aborted.abort() const cancelled = await invoke(ctx,file,'batch',{action:'run',operations:[{op:'remove',path:'host-dest'}]}, {agent:pure},aborted.signal) assert.equal(cancelled.error.effectState,'none'); assert.equal(cancelled.error.code,'CANCELLED') @@ -349,7 +349,7 @@ try { await new Promise(resolve => { releaseAssembly = resolve }) return '' } }) - const race = removedDefinition.execute({ action: 'page', id: result.detailsRef }, { agent: ptcChild, signal: new AbortController().signal }) + const race = removedDefinition.execute({ action: 'page', id: result.data.detailsRef }, { agent: ptcChild, signal: new AbortController().signal }) await blocked const callsBeforeRace = calls.length ptcChild.liftPresentation() @@ -357,11 +357,12 @@ try { assert.equal((await race).error.code, 'POLICY_DENIED') assert.equal(calls.length, callsBeforeRace, 'presentation changes during assembly must not spawn a worker') removeSection() - assert.equal(visible(ptcChild).includes('ops_result'), false, 'presentation withdrawal is synchronous') - assert.equal((await removedDefinition.execute({ action: 'page', id: result.detailsRef }, { agent: ptcChild, signal: new AbortController().signal })).error.code, 'POLICY_DENIED') + await ctx.get('systemPrompt').assemble({ scope: ptcChild }); + assert.equal(visible(ptcChild).includes('ops_result'), false, 'presentation withdrawal precedes returned Native assembly') + assert.equal((await removedDefinition.execute({ action: 'page', id: result.data.detailsRef }, { agent: ptcChild, signal: new AbortController().signal })).error.code, 'POLICY_DENIED') await ctx.serial('agent/disposed', { agent: ptcChild }) assert.equal(visible(ptcChild).includes('ops_result'), false) - assert.equal((await removedDefinition.execute({ action: 'page', id: result.detailsRef }, { agent: ptcChild, signal: new AbortController().signal })).error.code, 'POLICY_DENIED') + assert.equal((await removedDefinition.execute({ action: 'page', id: result.data.detailsRef }, { agent: ptcChild, signal: new AbortController().signal })).error.code, 'POLICY_DENIED') // Unload rejects retained callbacks and cleans owned registrations, not foreign tools. await until(() => visible(pure).includes('ops_batch')) const schemaBefore = JSON.stringify(ctx.get('tools').schemas(pure)) @@ -412,7 +413,7 @@ try { write('real-source','native') await real.plugin(NodePtc, {}) await agent.ctx.plugin({ inject: ['tools'], apply(inner) { inner.tools.presentAs('ptc') } }) - const sdkComponent = await real.plugin(ptc, { binaryPath: file, required: true }) + const sdkComponent = await real.plugin(ptc, { binaryPath: file, required: true, alwaysLoaded: ['batch'] }) await real.serial('agent/created', { agent }) for (let n = 0; n < 100 && !real.get('tools').get('ops_result', agent); n++) await new Promise(resolve => setTimeout(resolve, 10)) assert.ok(real.get('tools').get('ops_result', agent), JSON.stringify(await real.get('systemPrompt').assemble({ scope: agent }))) @@ -424,11 +425,12 @@ try { return completion.value.result } const sdkResult = await sdk(` - const stored = await tools.ops_result({action:'put',items:Array.from({length:120},(_,n)=>({n,kind:n%2?'odd':'even',payload:'x'.repeat(80)}))}); + const storedOutcome = await tools.ops_result({action:'put',items:Array.from({length:120},(_,n)=>({n,kind:n%2?'odd':'even',payload:'x'.repeat(80)}))}); + if (storedOutcome.status !== 'completed') return storedOutcome; const stored = storedOutcome.data; const selected = []; let offset = 0; let stable = true; let bounded = true; let contentHash; while (true) { - const page = await tools.ops_result({action:'page',id:stored.id,equals:{kind:'even'},fields:['n'],offset,limit:7,maxBytes:2048}); - if (page.error) return page; + const pageOutcome = await tools.ops_result({action:'page',id:stored.id,equals:{kind:'even'},fields:['n'],offset,limit:7,maxBytes:2048}); + if (pageOutcome.error) return pageOutcome; const page = pageOutcome.data; contentHash ??= page.contentHash; stable = stable && page.contentHash === contentHash && page.sourceTotal === 120 && page.total === 60 && stored.reference.totalItems === 120; bounded = bounded && JSON.stringify(page).length <= 2048; @@ -439,13 +441,14 @@ try { } const invalid = await tools.ops_batch({action:'resume',id:'not-an-id'}); const denied = await tools.ops_batch({action:'plan',root:${JSON.stringify(nonTemp)},operations:[{op:'remove',path:'protected.txt'}]}); - const plan = await tools.ops_batch({action:'plan',operations:[{op:'copy',path:'real-source',to:'sdk-copy'}]}); + const planOutcome = await tools.ops_batch({action:'plan',operations:[{op:'copy',path:'real-source',to:'sdk-copy'}]}); + if (planOutcome.status !== 'completed') return planOutcome; const plan = planOutcome.data; const audit = await tools.ops_result({action:'page',id:plan.detailsRef,fields:['state','revision','entries']}); const resumed = await tools.ops_batch({action:'resume',id:plan.id}); const replay = await tools.ops_batch({action:'resume',id:plan.id}); await tools.ops_result({action:'release',id:stored.id}); - return {stable,bounded,count:selected.length,last:selected[selected.length-1],invalid:invalid.error.code,denied:denied.error, - audit:audit.items[0],resumed:resumed.status,replay:replay.done,detailsRef:plan.detailsRef}; + return {stable,bounded,count:selected.length,last:selected[selected.length-1],invalid:invalid.error.code,denied:denied.data.error, + audit:audit.data.items[0],resumed:resumed.status,replay:replay.data.done,detailsRef:plan.detailsRef}; `) assert.equal(sdkResult.stable, true, JSON.stringify(sdkResult)); assert.equal(sdkResult.bounded, true) assert.equal(sdkResult.count, 60); assert.equal(sdkResult.last, 118) @@ -461,8 +464,8 @@ try { return {denied,page}; `) assert.equal(sdkReadonly.denied.error.code, 'POLICY_DENIED') - assert.equal(sdkReadonly.denied.error.permission.current, 'read-only') - assert.equal(sdkReadonly.page.items[0].state, 'completed') + assert.equal(sdkReadonly.denied.data.error.permission.current, 'read-only') + assert.equal(sdkReadonly.page.data.items[0].state, 'completed') agent.session.mode = 'workspace-write' await sdkComponent.dispose() assert.equal(real.get('tools').schemas(agent).some(schema => schema.name.startsWith('ops_')), false) From 3d7092c656ff5eb2aa71165f7b974313b70e7f24 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:51:40 +0800 Subject: [PATCH 030/118] feat(discovery): activate advanced tools in their owning scope --- docs/CHANGELOG.md | 6 + docs/discovery.md | 41 ++++ ...45\345\205\267\345\217\221\347\216\260.md" | 41 ++++ package-lock.json | 32 ++++ package.json | 4 + src/capabilities/discovery.ts | 31 +++ src/capabilities/registry.ts | 8 +- src/capabilities/types.ts | 2 +- src/components/discovery/icon.png | Bin 0 -> 119677 bytes src/components/discovery/index.ts | 1 + src/components/discovery/locale/en.json | 6 + src/components/discovery/locale/zh.json | 6 + src/components/discovery/package.json | 6 + src/config.js | 1 + src/discovery/config.ts | 45 +++++ src/discovery/index.ts | 177 +++++++++++++++++ src/discovery/search.ts | 24 +++ src/file/runtime.js | 10 +- test/capabilities.test.mjs | 4 +- test/discovery.test.mjs | 65 +++++++ test/lib/discovery.mjs | 180 ++++++++++++++++++ test/mount.test.mjs | 2 + 22 files changed, 685 insertions(+), 7 deletions(-) create mode 100644 docs/discovery.md create mode 100644 "docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" create mode 100644 src/capabilities/discovery.ts create mode 100644 src/components/discovery/icon.png create mode 100644 src/components/discovery/index.ts create mode 100644 src/components/discovery/locale/en.json create mode 100644 src/components/discovery/locale/zh.json create mode 100644 src/components/discovery/package.json create mode 100644 src/discovery/config.ts create mode 100644 src/discovery/index.ts create mode 100644 src/discovery/search.ts create mode 100644 test/discovery.test.mjs create mode 100644 test/lib/discovery.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 852c4b98..185a9b3b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -65,6 +65,12 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm the lock and audit namespace and reports partial cleanup honestly. - Preserve Windows private-temp capability isolation; real confined create/reopen/readonly and foreign-session denial assertions cover the protected cache ACL. +### Discover advanced tools in the requesting Native scope (#261) + +- Add `dsh-ops/discovery` and `ops_discover`: deterministic allowed-pool selection, a 1-5 per-call cap, and always-loaded choices. Publish pending capabilities only at the next model-step boundary, with scope isolation, permission withdrawal and owned cleanup. +- Add pinned schema token measurement and real Cordis/native-worker lifecycle checks. Discovery alone measures 120 cl100k_base tokens; this is not a whole-OPS or PDF acceptance claim. +- 新增按需发现组件与 `ops_discover`:允许池、单次 1-5 项上限和始终加载选择;仅在下一模型步骤挂载,并保证作用域隔离、权限变更撤销及卸载清理。 +- 新增固定分词器的 schema 计量和真实 Cordis/原生 worker 生命周期测试;发现入口为 120 cl100k_base tokens,不代表全体 OPS 或 PDF 验收通过。 ### Render Native file results as bounded, readable text (#261) diff --git a/docs/discovery.md b/docs/discovery.md new file mode 100644 index 00000000..b689b964 --- /dev/null +++ b/docs/discovery.md @@ -0,0 +1,41 @@ +# Advanced tools on demand + +中文:[工具发现](工具发现.md). + +`dsh-ops/discovery` publishes `ops_discover` in each eligible Native scope. Describe a capability (`metadata revision hash`) or select an exact public name (`select:ops_stat`). Discovery only chooses tools; it performs no file action. A successful selection is pending until the next admitted model step (`agent/pre-step`), before that step's prompt is assembled. Current and diagnostic assemblies cannot activate it. Newly published tools remain scoped to their owner; parents, children and siblings must discover independently. + +The component is exported now. The seven-row bundle installation and 0.3 profile migration are a separate release slice. A manually added row can use: + +```yaml +- name: dsh-ops/discovery + config: + enabled: true + maxResults: 3 + allowedTools: [glob, grep, replace, write_file, edit_file, diff, stat, copy, move, remove] + alwaysLoaded: [] +``` + +| Setting | Default | Contract | +| --- | --- | --- | +| `enabled` | `true` | Publish the discovery entry. `false` preserves `alwaysLoaded` tools. Disabling the entire loader row unloads all its owned tools. | +| `maxResults` | `3` | Maximum selections per call, integer 1-5. A call's optional `max_results` is also validated as 1-5 and capped by this setting. | +| `allowedTools` | All implemented advanced names above | Allowed pool of raw capability names. Unknown/unimplemented formats are rejected, rather than advertised as usable. | +| `alwaysLoaded` | `[]` | Choices from the allowed pool that bypass discovery and are available at the first eligible step. | +| `binaryPath` / `required` | Automatic / `false` | Worker selection and activation failure behavior, as for native file tools. | +| `denyCredentialReads` | `true` | Preserve the native file credential fence. | + +Configuration arrays are copied and deduplicated; unknown configuration keys are errors naming the key. Search is a deterministic keyword match over reviewed metadata, with stable tie ordering; it does not call a model. `query` is nonempty and at most 512 characters; extra action arguments are rejected. PDF, Word and table readers cannot be discovered until implementations pass their own acceptance review. The current catalogue does not pretend those formats are ready. + +Every activated file tool keeps its declared path base, argument schema, Native rendering, worker permission checks and revision/CAS contract. Read-only scopes cannot activate mutation tools. A sandbox-mode change clears that session's pending requests, cancels its active calls and re-evaluates registrations; other sessions retain their choices. Disposing an agent or unloading the component removes registrations and invalidates cached definitions. Pure PTC scopes do not publish this Native discovery surface; the PTC adapter owns its SDK. + +Only this plugin's scoped `ctx.tools.register`, `restrict`, prompt sections and lifecycle listeners are used. No host method or shared tool registry is replaced. Publication compares the desired tool names before changing registrations so it can coexist with PTC lifecycle listeners. + +## Measurement boundaries + +The deterministic BM-03 entry check uses `js-tiktoken@1.0.21` with `cl100k_base`, encoding compact JSON of the pinned host `ToolSchema` fields (`name`, `description`, `parameters`). The discovery entry is **120 tokens / 552 UTF-8 bytes**. This is a reproducible schema measurement, not provider billing, and excludes tool-message framing, prompt guidance and other OPS components. The tokenizer is a test-only dependency published on 2025-08-09; its locked `base64-js` dependency is also test-only. + +The unchanged eleven-file-tool surface plus discovery measures **2232 tokens / 10376 bytes** by the same method; this slice does not claim that whole surface passes 400. The next slice reduces the resident file set and records its own complete file/discovery baseline. Shell/PTC or operator always-loaded choices must be measured separately, not silently excluded from a global claim. + +BM-04 runs through the real Cordis tool/prompt services and native worker: an implemented `stat` capability is absent before discovery, remains absent through current-step assembly, appears after the next pre-step boundary, and executes successfully. PDF is unimplemented, so this is the lifecycle gate, not a claim that PDF acceptance passed. + +The browser configuration multi-select is separate work: plain configuration validation does not automatically generate a host selector. Its UI must use the pinned host's browser plugin configuration APIs. diff --git "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" new file mode 100644 index 00000000..9c7ccba2 --- /dev/null +++ "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" @@ -0,0 +1,41 @@ +# 按需高级工具 + +English: [Advanced tools on demand](discovery.md). + +`dsh-ops/discovery` 在每个符合条件的 Native 作用域中发布 `ops_discover`。可描述所需能力(`metadata revision hash`),或指定完整公开名称(`select:ops_stat`)。发现仅选择工具,不执行文件操作。成功选择会保持待激活状态,直到下一次被允许的模型步骤(`agent/pre-step`),在其提示词组装前挂载。当前步骤和诊断性提示词组装都不会激活工具。注册只属于调用者;父、子和同级作用域都需独立发现。 + +当前已导出组件。七行 bundle 安装和 0.3 profile 迁移属于后续发布切片。手动增加的行可使用: + +```yaml +- name: dsh-ops/discovery + config: + enabled: true + maxResults: 3 + allowedTools: [glob, grep, replace, write_file, edit_file, diff, stat, copy, move, remove] + alwaysLoaded: [] +``` + +| 设置 | 默认 | 契约 | +| --- | --- | --- | +| `enabled` | `true` | 发布发现入口。设为 `false` 仍保留 `alwaysLoaded` 工具;禁用整个 Loader 行会卸载该行拥有的全部工具。 | +| `maxResults` | `3` | 每次选择上限,整数 1-5。调用参数 `max_results` 也校验为 1-5,并受本设置限制。 | +| `allowedTools` | 上述全部已实现高级能力 | 使用原始能力名称的允许池。不支持的格式会被拒绝,不会假装可用。 | +| `alwaysLoaded` | `[]` | 允许池中跳过发现流程的选择,从首个符合条件的步骤开始可用。 | +| `binaryPath` / `required` | 自动 / `false` | 与原生文件工具相同的 worker 选择及激活失败行为。 | +| `denyCredentialReads` | `true` | 保留原生文件凭据保护。 | + +配置数组会复制并去重;未知配置键在错误中点名。搜索按经评审元数据确定性匹配关键词,同分时使用固定顺序,不调用模型。`query` 必须非空且不超过 512 个字符;额外操作参数会被拒绝。PDF、Word、表格读取必须各自实现并通过验收后才可发现;当前目录不会假称这些格式已就绪。 + +每个激活的文件工具保留自身路径基准、参数 schema、Native 渲染、worker 权限检查和修订值/CAS 契约。只读作用域不能激活写操作。沙箱模式变更会清空该会话待激活项、取消其执行中的调用并重新计算注册;其它会话保留选择。销毁 agent 或卸载组件会移除注册,缓存的旧定义也失效。纯 PTC 作用域不发布此 Native 发现工具面;SDK 由 PTC 适配器管理。 + +只使用本插件自己的作用域 `ctx.tools.register`、`restrict`、提示词段落和生命周期监听;不替换宿主方法或共享工具注册表。发布前比较目标工具名称,避免与 PTC 生命周期监听器互相触发重复注册。 + +## 测量边界 + +确定性的 BM-03 入口检查固定 `js-tiktoken@1.0.21` 和 `cl100k_base`,编码固定宿主 `ToolSchema` 字段(`name`、`description`、`parameters`)的紧凑 JSON。发现入口为 **120 tokens / 552 UTF-8 字节**。这是可复现的 schema 测量,不是供应商账单;不含消息封装、提示词指引和其它 OPS 组件。分词器是测试依赖,发布于 2025-08-09;锁定的 `base64-js` 也仅供测试使用。 + +未精简的十一项文件工具加发现入口,同口径为 **2232 tokens / 10376 字节**;本切片不宣称整体通过 400 上限。下一切片精简常驻文件集合并记录完整文件/发现基线。Shell、PTC 或用户始终加载的选择必须单独计量,不能在全局节省声明中悄悄排除。 + +BM-04 使用真实 Cordis 工具/提示词服务及原生 worker:已实现的 `stat` 在发现前不可见,在当前步骤组装时仍不可见,下一 pre-step 边界后出现并能执行。PDF 尚未实现,因此这是生命周期门禁,并不代表 PDF 验收已通过。 + +浏览器配置多选界面属于独立工作:普通配置校验不会自动生成宿主选择器,界面必须使用固定宿主版本的浏览器插件配置 API。 diff --git a/package-lock.json b/package-lock.json index 74dfb0f1..57960e2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-tools": "0.2.0-rc.2", "@dsh-std/manifest": "0.1.1-rc.3", "@types/node": "22.20.5", + "js-tiktoken": "1.0.21", "typescript": "6.0.3", "yaml": "^2.9.1" }, @@ -1243,6 +1244,27 @@ "win32" ] }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1269,6 +1291,16 @@ "node": ">=0.3.1" } }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, "node_modules/koffi": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.1.tgz", diff --git a/package.json b/package.json index eca1a1b0..9dbb25bc 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,9 @@ "./tui": { "default": "./lib/components/tui/index.js" }, "./tui/package.json": "./lib/components/tui/package.json", "./tui/locale/*.json": "./lib/components/tui/locale/*.json", + "./discovery": { "default": "./lib/components/discovery/index.js" }, + "./discovery/package.json": "./lib/components/discovery/package.json", + "./discovery/locale/*.json": "./lib/components/discovery/locale/*.json", "./hide-official": { "default": "./lib/components/hide-official/index.js" }, @@ -164,6 +167,7 @@ "@deepseek-ai/dsh-tools": "0.2.0-rc.2", "@dsh-std/manifest": "0.1.1-rc.3", "@types/node": "22.20.5", + "js-tiktoken": "1.0.21", "typescript": "6.0.3", "yaml": "^2.9.1" }, diff --git a/src/capabilities/discovery.ts b/src/capabilities/discovery.ts new file mode 100644 index 00000000..9c36f8a9 --- /dev/null +++ b/src/capabilities/discovery.ts @@ -0,0 +1,31 @@ +import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import type { ParameterSchema } from './types.js' + +/** Reviewed, currently implemented advanced operations. Future formats are absent. */ +export const DISCOVERY_TERMS = Object.freeze({ + glob: 'glob find search file paths patterns', + grep: 'grep search content regex matching text count summary', + replace: 'batch literal text replacement across files', + write_file: 'write create overwrite full file content atomic revision cas', + edit_file: 'edit patch unique text atomic revision cas', + diff: 'diff preview compare proposed file content', + stat: 'stat metadata revision hash bytes size line count', + copy: 'copy duplicate file no clobber revision', + move: 'move rename file no clobber revision', + remove: 'remove delete unlink file revision', +}) +export const DISCOVERY_PARAMETERS: ParameterSchema = { + type: 'object', additionalProperties: false, required: ['query'], + properties: { + query: { type: 'string', description: 'Needed capability, or select:ops_name. Nonempty; at most 512 characters.' }, + max_results: { type: 'integer', default: 3, description: '1-5; default 3, capped by the configured per-call limit.' }, + }, +} +export const DISCOVERY_DESCRIPTION = 'Find and activate allowed advanced OPS tools for the next model step in this scope. Describe a capability or use select:ops_name. Discovery never performs the requested file action.' +export const DISCOVERY_OUTPUT: JsonSchemaNode = { + type: 'object', properties: { + status: { type: 'string' }, activated: { type: 'array', items: { type: 'string' } }, + alreadyActive: { type: 'array', items: { type: 'string' } }, message: { type: 'string' }, + error: { type: 'object' }, + }, +} diff --git a/src/capabilities/registry.ts b/src/capabilities/registry.ts index 8a97b8ae..a86309bc 100644 --- a/src/capabilities/registry.ts +++ b/src/capabilities/registry.ts @@ -1,3 +1,4 @@ +import { DISCOVERY_PARAMETERS, DISCOVERY_DESCRIPTION, DISCOVERY_OUTPUT } from './discovery.js' /** One catalogue for owned tools. This module never registers tools or imports host values. */ import { PARAMETERS as fileParameters, DESCRIPTIONS as fileDescriptions, fileOutput } from './file.js' import { PARAMETERS as ptcParameters, DESCRIPTIONS as ptcDescriptions, OUTPUT as ptcOutput } from './ptc.js' @@ -30,7 +31,12 @@ export const SHELL_CAPABILITIES = Object.freeze([Object.freeze({ rawName: 'bash', component: 'shell', presentations: allPresentations, availability: 'resident', mutates: true, concurrencySafe: false, } as const satisfies CapabilityMetadata)]) -export const CAPABILITIES = Object.freeze([...FILE_CAPABILITIES, ...PTC_CAPABILITIES, ...SDK_CAPABILITIES, ...SHELL_CAPABILITIES]) +export const DISCOVERY_CAPABILITIES = Object.freeze([Object.freeze({ + rawName: 'discover', component: 'discovery', presentations: Object.freeze(['native', 'both'] as const), + availability: 'resident', mutates: false, concurrencySafe: false, + parameters: DISCOVERY_PARAMETERS, description: DISCOVERY_DESCRIPTION, output: DISCOVERY_OUTPUT, +} as const satisfies SchemaCapability)]) +export const CAPABILITIES = Object.freeze([...FILE_CAPABILITIES, ...PTC_CAPABILITIES, ...SDK_CAPABILITIES, ...SHELL_CAPABILITIES, ...DISCOVERY_CAPABILITIES]) export type Capability = typeof CAPABILITIES[number] export type ToolName = Capability['rawName'] const byName = new Map(CAPABILITIES.map(capability => [capability.rawName, capability])) diff --git a/src/capabilities/types.ts b/src/capabilities/types.ts index ae70e775..16bddab0 100644 --- a/src/capabilities/types.ts +++ b/src/capabilities/types.ts @@ -1,6 +1,6 @@ import type { JsonSchemaNode, ObjectJsonSchema, ToolPresentationMode } from '@deepseek-ai/dsh-tools' -export type Component = 'file' | 'ptc' | 'shell' +export type Component = 'file' | 'ptc' | 'shell' | 'discovery' export type ParameterSchema = ObjectJsonSchema & Record & { properties: Record required: string[] diff --git a/src/components/discovery/icon.png b/src/components/discovery/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cb35b43ecb1fc6b24dc382ce5727c26778c1803b GIT binary patch literal 119677 zcmWh!c_5SjAKyldhRuD>gGeMz@w@d3Zd9+d+=j=iq>b^&>JMlE@Xw zvSm=jVY?A9Wf+e1%uf_c14|NF=x z$H}4XLUT2Hd9dVj;0<134mcc44d?FnIqXbCY(6@YNHb+Vdx8($cwCVd(_1y-%~EQ` z;qrwEd@Q;p0qA#<1D7~FgG_B)V1c}?>NF8lMoUnryX=x1isJo3^#)6aRPxD^sELK5r9_I z#f#t|2!>lSkmq^QVCnviI18O|ZX_mA2tnMafJUZ)EQ@)` zD02}nA$_8<8%Rb_F?{~y*zV%kzWszFZ+EWdQ5Uy#=WLn7PM-o51xuJK@(DHSHR?jh z;Q-ChCsr^5z`&G4o5%&`)Q-$!Fs6Kxfr5UHif+T33;Pr29QJlSHMx82<_qfQUzpWy z^y^E9DJ4&$`H8%aEKDo8JwG{5&{;h0L;u{E^T5_5g)lWSHCQpWH`B87_pniCfSJ^C z=^nf-natueX!azL@6ehk9>q+f3s1!wwfR`RR&AJU5*QKt;KAp5TNc5j5Bh#;|98CE z#nOJ$3@8-HcTd89SZQxB#HFiJSazw(8{XDQ92Phr`6ygFaz@9J0$sRArK+6yMjc^I1`GH;8%_~2};6ut>f5;oI7y;p0`jiM4eOd z7$@N8V%(Ljr3m(i3tj*BTP+#T%yKyRHV{-jGFvyRNIc6#!T=NZA;9l|$OIOY$Dg1a zx&q<>!u0q6e0+c`FcfUj2SJFspt8R{oE*xEXgO*L z=h0=HoYMOM2V)DQ6$ec5h-BIGEe9$F?qpeIVU7vMG;lhmKU6+`_yr$pw`O(}6S4jy zUce?~Pko>*RVsY@cUwo_#9rNz|8ni#Vo1=EeTizvv<|7-uE^kYbEY`J(&8yEAnF0r z?kV2*BI4foD*!4VeoC;AK^BC`T>63DEGXz^WkN-h`a&!B7lIkTEU2 zO03t?D#WT0uifzHZJ1e1>Q$s51`ykTlTa(PLj{C|ZH!jb9gc>K72d50nGNi@uPy70 zK*NAkdY>I%WP<-iB;Er>FgL~dNgaffFFXv3*uheOZ0|bAM&+`i!%$fB+B(Ef2!Q2?JoQq@GjJ zXPpnld0K1}JrI@^NrrX~ORX6RIL6_~htkjkHd0_E z5I-6sj@{(h5&FMe_wHX!1cwbMpmsKnzY(8Ic*e{DX5ID@$ zsvY#q)d=1%_Nm+Z9WS6Vycn@vf9Mdp{copYEaG2dkfywId|Smqu`1nORX6rYSw9tj zsi8pvFCHhw1MTenITnI0%We}CPB#)OEiI*xQIJVdn1M7i1_#WSvf};=q9|uJ6phQ|ri9V!txLx(F~Him48pwJw?7 zDJ!#k+i&NwL>wV8Nz^-Bx?0JTYBM?{_iUAGyex1-GSLVCMyI?vyB#u7aTz8CpkNdM zf)5&O9#_rvY*nD9QoG#7;g?LSUiU&eq0{p4B z!#4WnH~Q9Q3-aFIx*!#{zpYxop6ye=_O&`_Iayav+ov8D>3+E!ryUvD&Y57s*K5E> zNr$!w1B+}e3V8wME!6f(5K<8I@)n_#(R>K12rIg?vPCoK418}cF753ZAH1LpgK!e*+~nIDFGFI) zWn_@zf-+oR;2es^#X(CFF^wh59w@q(kEG<%ZaR23+??qIZ^QVMSC@FO+$7pGmiSXs zoJ|;)ATji=S^H$Cij&k{v3Jdiy?@o>ayv)>^wOp0hfpv(gSL}k_L-~me7N@(=D z$1U0ukS*bRBsl9mHH$Rn zhpjse1aKEk4-RI$X{xQ?aX1hy>I)xBO?Gp{{fTJqH9KY1gfwq%nn|KKpELOuZzt1r22Wuau$^qg4$XdnB*HNQH2}j>@ z2v&{BWam3*oHPHeA|;q*1H91Cf>Ts-xf2XYbW2{@U0wG%kUp}mKM6E%#*-LxJJW+L zeaiT3YOI^6s3?My--5LH!-&-RP*zrYl~OrBv`9QoL~tQX@sUms|2>*9Z))ku z1!y-x5`dv8t~5L4w=#LRaS#@ZVE_Rpka>XdPVIVQ`%~FX-?bk61EdM?3tUvVShqc2 zzp1OW&!Hb5qHZG_Ngrs-&H{v5kBH!8l`R!zIdzFhtn%7n5xMgw7r;VShGSg%sAv@Q z8uT;CX@E9Vswv!{v=y0ZuNJ05zf9EoH!KLsC`_<}6l%H&CZMA9;+lm)%Bb@~(>=XUmeCwhEnxXphTk?vEjl?pi8F)6{Xgo5qNv(Xuv(mN~->TsU zCN~H}zFQIIGTPWIvMWS7eEZN-Q?ab?Fg)3!&+4%*B|y@@Fh6DTEHn7Vii!l82azv> z6crtK{`@5W>u29EkvF<3r?BlI9Xl((=Nx_1bD0NdPnF>YrQDz>F<2a?h%_b;rbPuR z>eJ)}PbrA{J9BYbZ1lV%I?g@*lm&wcQm*r%Wul`P!+C)cqcfm5`D2)Hb z;Vd674s`5YTz)2AiIWQ;X9D3u3!fvHI`u<)tfsRfk5(OaqNUw15rMynIjy4R03_~O z#Gk=4XJYXISI99~TsxRhoXFKG_pwna<<$-6`|Ua+A_4BxDacJ-530b*-)1NxMN+YiE;mbWbAAwfp6+V}U&07z_jua8^cAKtvoTK+ew} z8KdiC;6@|6kul0m-d7+l8REH+f%%Oq%b}N)N0!5P1cpj@STu2GJV8l_&g^er$n`Md zvXaNE+sX)OsG95w7f?1H57&Db(oa-2b8g2?i4T znA7W8racd64LHpk))?uJ>0n_}G4haLS0rQCb%SsqE~_t3hE5`yzkz|ay z43_ZyuMaw+0|xxjt_^EQ`vCLT1b-3unUi_n4fA|>Q6IiW&RZB7sn9#VeRJ&KP^h>g>=C;H7!R31f zrsAzH(_P!g4Y6hgFtgU-EZ=$;QOO)c(Fw+|P1Jf0?`r zt%J?!zNIVDM=w>~Y2Tt6Kaq-w`Mosv&hxPiEY|BWui?OQyNfNDP>cKKL+|E^)%Fgi zeTkH)8(?1*2cxfBOJmZzzkj?*{l~@Q#Lr;QjGKL>VH--N%_jh*&^kjbA+(_s|`uN&A%n8&bLI1Os0*9lEekQ9yn`+~Qhz-K8}Gg^wMci`Y1x$Da~|I9!p8Y)Cc% z|8Wf>)j*()fqE45B^92=S5}rWa_XF%q-)^x^zTPtGy;(*?pG6{p=I|-Kr@um7CPI{ zaR1En+4e&ChE4cE;~VZgBSdD-@~_IR%>u2PMN_|0AFBP-`l+R+ak)woi!Q4@h^Y=* z5^EUTsWiAiz#HLgNm<>l2+rImv^ZfH^!iJ!+Nv5rB%Vai_o{C3d(`}V;3PkejI{GW zD2W{7#F40RWPmV?!U4CUPt~(A5fKYVD$;xLMWZIz+Z>X9Ms#%zH4Qbjs+t;e0$sV8 zjD8)B%fxG3(W?Gjv#>X^yNg8Ma&U-)&L&MK+3A@epWwf_ln`L3SVwtAekKxWpM5)I zdel$-3=#+5b&5X6@@^Lx*T!&igzWB5>;`wlvXpWIys{-&cjYXMF=V%5LL->xyLMDs zbMFs?zav0f@}fQ#hg6cG*rIj+W&O-jGXKz$CzSr=oFJ71mas&AvGbr~W054HNe&)r zq1dTDRvWUlVOFy`8nV1BEp?KGTQ4@N#TV6$zY3-PE7EQTyyE${;QTY?nq%2BE6ML| z4Kxz!m3q}Adca}7!-2xD3a4&)Ig-f+Q?fajEb&XxZ|rY;vLb7Ab`qZz>2YC=(qX-H z4j4g%U*Z%8z%NH(sHS$mOEr8s_h3P5k6yoFS-;{@zuOox7Czr;6ERy+zb|ljs3m8D ziIZn82ZeCQkFC{erTI(6+4p?vXkp9~O?#mKIe&1TnBPzq(hAx9)ce73Wwf?>U|`-? zBAfPisNc$;&LENV#6dp&1J7q&G1nBy7>2=9ap$sOFTy$GjPLqim3v?ejN3w)Lz?Q6 z>`0WssBpTg>}-BzU~7s-|L zL}q{8*wdUW4$9S#&F#jfWsQgX3h zBo~T>@Br599D4V+K>aE!jy^iE!{L;m#ovv768gBGV-A_;4Xlh{7{ve?3fZrik%vU~ z=?_EIS^-?nRl|!*zb@AWgx(Jf57=57N!1MR-t*v2){m!DIqK+bC?A4$30kw6+Or(l~ zBs!V|`;pV8I{KjcMJK_7ByLHVear(yJlY;_Y%7>`O;&O^ueGsxQ~5c5t|>k>?Vid! zsEm=~BKM`-tF%->p{t90PePwVMs@}bsiw_8D{6YeZOP#is>|6-bBDfP4l-z$knjV> z*cm5(ZFp;KkrV}tWMdS|q!|j}0TRw#VJ3z4T#QLV0H8d&2At;K-rp|$aXw#e)~U?C z+qI*7kyNZ(Z^4}Qt9Pf`ID{NbKX+IrCdrGtD3#SM6VCYp{6ATz@Z02WHyb}}M_L=H z^z_Oqc;si6ds$|*e#minSHIKgQ(=G7`4R!9_(@m@`BwO=vN)L$_~5;tiY+0*z~?(O zeyFAc2tZy^^i@9ovvD+(nG@yoltIF9IcsQDUy2#5xq7vhUAPCq!Riq8{8vQ4H3Ne` zxNn{29%UsQk#;y87@BM7=EavU?6Ynd{hbTmx|+K;mAj`nHn4RnlKwht_oPSoIYey3 zZ1(;OR~N{7>&TKL%$Vt=V==GT&?^Q&{7XHYuGi!%Qf-Pg)#4Qg2%$mn`;GX>q8Ikd zvYdGdW!yF!GLABwWex8o1KtHUNj8yuPVas-a^R4hNxTaH)8xsZ=G?{q5gnaw{ICBSk{rFDvg(MMAM6H$NqVhhcFR&n|%c>DOXOg*`A; z9JmMk`{+T_w%B$DR~^bRN{7!1p+|iwh|Q@oiO5R^2cAl9LB^1+qgrZqM#i@LB365j z7Mr+JiDlFCJ~a>hR^KiM5ydSnjrC%$v{i7#_sq|l4)@`o7ZFr%g6swYsuzbs6gbl^ z^G_O#n_iG))3rMPQp3(R=?INv4nY-DOCzaxN#sI#^^S8RD^)t&DpBUi8#{yVTUQ8I z{}3^;z7pSc&dU*wo0>m;(k9288taP!I^#gva>QtSCfM(5^Et1!hMXSXO%GDdIa&*v1fl`x0L zaCu^{JnFQC3y}l%w82x=b@7p41Aw5%x4CsfpSuHpPVse?;_W{s@9C4j9D@TMjsz?& z-rVapQ-Hkk!lRyM`O|r+&Y)=FH*fgEj|!<&Z@=rTNVhoR5c98U-Fm#feu%=zl@ma@ zcX`_r>MBR7fkE|7-?~fI8!IJB&mJowM!|Tj0Oa0=e5jh~rHFTLwE?mqIdtSEZJz_n zt8_<)6NJ|0Fju6xe;$QgjuPbR(A!dsI9fhbjo3RHN@dOy%Wmk)u*F1StXf^zQKwnm z!O*!Sns2F}^Y^vWrm?=SsguPf4Mw$OwutGvt-_6Y~t zqTcjI$6{1)2HLclKWAVY!vSD+gZ1ahn>0(PsaU1oc6D6kdNInzP&GD^>xC#efGqaI;W_3mrVmy|d4 z%!(EixIUj(M$$>cO3j=^x}oWCzn%S4PNFjxFXp~%;g!bc8V~2&Gse&-_&j9LP zQOk>)d$()aFjo#ZiPuq)uc=h`@ie^b7wCi9MP0H`3kGH)#67)U&tPP^yu-KDckPjg z6VO)Q?R1_g@qejsqG%d#`Xl5PFGe;UpARa`Qup*xT`%uBUgEQ{_d~}ErN?%+FgVx-M*&e@ zne&>DPoJ0(nxJhiY6bdi{V7qqF#C@FzB4y2lyUA$PT(qvmwQZX=JOm1z6%*0QIh4skd1YxpPE5q4tco%IW z_fe**Ydg?fkU+_Fd?jQ`)G+-bECzEd8?<-uiT6#$#5Fj|lziYgrR}4WB^DORC1HF` z+s5{Z);V&%^(;8lJm8?eS)Afk9+v#tt4cs?cRXfmG;DK!^~zXysaH=y{odk8Am!~l z1*JFS{5ggTrdrB#WPUUm3ig)mifz@}6mt0NWauP#F!H@N_B)qBWAC`LE6TY`-mmGN z>6ZYo3C+6YBOH+_FEONWxO((&RCO$3R&nfLyh)(dslYCUUx3XAEaUZqTujsj*if+$ zL_Fcbp*rs2h$PD4nNAXIISEbch%69Aj5F`AqBesN7v94(y|57{+|sU7I*HLSuX$UQ^z zNNbN5BLs*&k@giqdUD#5GH4qZXsQAA(c^U6>s~t`rZtjW9WL{w z+#Mhx4hZBIGckdoTJD8~)s8&IT(h{_42AzN1ibp{J&~s>&#Yo$du#rld22tG`{L?y z)x-Le(&6ik(=F3mVPjiQ@qq6l`lC5l;2%66vz=S zeK@T5u{{!CQ~ecT*{pnj>N42Eg)2Hiu_TXZEzG|;lRW)HUm)TrsBV4XZxgjG=Afy2 zKVf=_KU2<@d4-*HyMe-JMS(E^6HKFJ2_-`5YodkklR_Rc=x<^@AXP*lq3*i1GZq`Nt z3BBJ1%%mbTHg8Ut>y7U9S8Vccjy{~VY(ig`p>?mIneri$8*j)?*P2SE({x?25N#J;E_BK%MWG)(`T|C%ly`mj2E8e~uBSxojq_Xiyog8ezD!-P1Qt>~ zLhSFf?4ioXYa8%Fv9cgbg!aTZE(-W3_G-4n9O;>{)ZQpSLZPar4uZJvb<_Cnmq3+M zyoAI;E~4Ub7GUY3Gp8^NkZIRwLXeZ7TPqeZmlmb(+l#724*vbC7{3!3R)50*vldz> z9{+nVBffehu+H{T8l%vdI!s#}lI=2%*@=m2#-L%REQVFwv~dzH$sY_2J4waE;XgNA zM4a@&a+@c7a68Orf~OQMit+he0}5)XoZvXGfo5m|Pr7hqns@9?E-uzDrB0H%LjFvS zPx|Z+m5$X%99iZbPQ5?r zpb_6ifCB_CQ~?YKPCXrYE(C4u!yoM8Wq`<vCmwfbEry;V)JSJb30M%A!arX) zkj$G&1pbKZ9$oovB0e4@l#R`7_Qo_CqMnN2!WCwq`6)awQlH(o_wz0!90@hZ z2gy%i%&qRVjJ~eO+zZ)@wKB>Ik`(mppNCJfLjdo4anI`-q}L|Z>}3=*v9aUMlKhcz z+NM{kYt)MK(eZE)C{yG7^N{d0?&~wSq;ImHT`wsIJ-+0Wh#EMLKXWpU(K{h@53Hj{ zEx%d3nd&p5el<)pIc#iMqVbn^>A?Is`t;^v2=g18>plp0SYBU2Rd|L!EhiT{9r_Z_ zs9AF>RDv-~d#CC;`>ApmYq@f~U@WOi<&6XFx6$q6>`N<=jKvH;LqYFERc zoRP#K6}tPACY^;>(H!(h-rMExtktVOQ%f{$;EGq4VsN8RO*4w?F952;S8UXF z&70wZGiGm%=pS>skN(V$efFQ-Jfpcn>N=L-_Fq!flQEE`ko&t}CdW08j5pd^hsU&S z+&-f@$d1osFfw1#ev{6aT#I%5lLoo&iZxc|xR;40t=r>rRh}hT5+2w3;OuIq;5x6?Af4O{M zV1S1I_A{VtBrwUqs|QwQrl;)>6+&g)I3^5M9eh^qu88%x|C<2QxBEw;9 z+$$emG~RNk3!Cp+o9(`CmYbi+`rbfU`R7>gjxP?Vn6LIG03@qU1u=K%Fy&RpnlI z27w_T|Hm0eq?&U#Q}C!36X@4FeuXVij(Gf z|7=ds`ra!k2`+BMpUEo1?SvVg9d?P$M%^T!0Ju{h_TO>71vQ%z0LQ(tT<=_NBmi2xB*1&F0-f{pDcm>I#}(nJWoc&-Q(c9+*hb- zDOQCu(Q^Seq07sTVG?>r0-K_YE_CN=b-6?==i4}YN@;F)mCdGFO|u0UovB;r6uF6| zt6oBSv2LKh>1WZfydZ~U^CW;a*okQ;PD%XlxUz=Y**A`VWRB|}u+LKW&C0>5Mr#ShVpl?(~LFCbW${=472q&!8Yo`l2K(j>mP&Bp? zHuV#r&AZ>Ca5$5@D}D4YRCBA|>C$KQC&!^2&+>FnD{I)w9JM>_df;q(FWgB3sQ{bF zVk)%@A3d}r6@eCtG3}g=c{A5sV1vUqLwAhp_e&3_yXy~kZ`wSc70?V;$iH`g_G!_4 zQFukbZ{fB(T*|I;q~Q|)T`;HG%x}ktQm@Hi)l zy_*%w$kqB;bB;*&Q8U!WaB+vmZn!A+eoI6T8{3c#?-m(z!C@nxydd<-nnQJ6v4eD}-$O--cY=ED--pl)H65H(E z6!59n1c-|LGZkZc9cxM8RKi{J^FHf`HlH+~@9uM7rsoDd5$h<|iL!IW@jKW({ z%uVgT&OQ#Nm`C`9Mnn0|y#47UmLu`#kImTQ*q6ALJtw)jcne$~=b4kwb~s$*z;N9( zc^q5~qHF!b_q<7OdFT6+-XoO)*11{{|8-iE;XGQqeE&blOG__e z$7x>sXJ<8*q$PsN^rpTGyea(Lrq^PG@*24PGIsz^?OiCpiImIx`N{Ok@R-*9{O}$n zSJwgzt>IT{ShBlHZjoVe%MVAw*V8uf>La6<{mF2QG`U0zOU)*|@bimH3F$ih<6m(_wk%6hv=-vh5h3Kt=K;1~SK()p-lb&S20_rYH#CxG`5FlyTe^QwI-athF zn+h5*o~#*|XhY-y*P3q&?IU!w`QIV)*m4dtNZ4|+=3OBghI|W8Z--oed9|{t>It6w z6@b0>!F0G>u|DW|Qp?8fHwbZrb*!Rn@K-<>;7?9W0u?BzSk^e@El9v&iLxr3$m?`O z9zN+x8;AdJC&fi79&d~ihC1JwP!7vSCF7SylfW4re1K+1je}s{%7=p$QT*VlUrjz((}0gamp`C{^VK z=)flVht)plG3qJa1uOxgJc2UXzkXe#9+xTPMGD3D&1n^R z`I=sjQj@vS1+Hh#$*6vO+C9x>Dot4+5^1IzTNjC^Z8-cl zVi_$^ESSWbvCK^#%y>jjQ?D^EZj{y0)peoS&9zJ`!}_Q^=6c>p8{w?1!Cpva7Va2N z+pTolX2~dVOK5sSr{D7UN|OK~pndw^z2&XC{XL)hgRfVDmJCE>u~_1hY&s+1vsl{E ze(K>{`iJhdg-zF(lc~U{)SATDd|pLN8G>;`@5=eb;<%f%#W6ZxBal`l}}R?eViySsuhY+gb49GD;RE~3#Inu$Y7l7cC^ zW-KnQi?TQalrLC1Z2N`pf@spC#ID&Sym3$0OwwsUHVX69jsaF=Gex)a4?oy`LN#^@ zZ%_P*p&RemM_dCpM`5R+p|x!1(mT>y!pJSFNRzClD`q%>%YFfjoicB{d;2H)^Ud)hQtKl0RK5`|4`Vb zNJJMcCYuyAi*qW&Kgx(BK6e`NK~4LrN_LJ8e%0@1MQo=I(XL(%OTjUdllmC*GxMFy z=Ku~DXa0;1#d~RX{!-dZuj+6Kl1R5;_FEK~791Gt7Z^BmH7+}dDwj{&xZ=y}k7X%e z`KAJC3as^ZHfIic){S^m*_v_ElO($UkM`iQQHMqMV$xh!kDMGv=Z-An38A=uuHuqV zOD5&U+t{>yFCDaqMH!y=$+4FcCpzy)wAoF^3Ei16H+H9X9c&|?awJ(Po70zGoxxf@ z#j})RU&%9C2yO;{lk41>3?}d+RKR)+}Ru8quQ~iMr4Ex*OWP5-mG7Ww0mk|S!-4l9vUTH)&!V~FzYBgfj zCF1e-;?#_k9+ZFrF#l}yk?a?irFOEL;(Yj_!@)wtLB-*wXPrcwj;RM~t!1@uKT?Dw6*x%(1W_GWLK7|HVb1ZRyiy`T>*9I zU-;XO@&!4u2C)GCrFEp`%J0(tOQ*v}4ZwZL38e~VKt!w>e@x-}LHUbLuRKLVg1ev3 zHarF+*BuH*JT!?eQ+xUEcb=7vZdAN}+*v#f5jS4S{~h|l`mw1H7We7HSU$~*T4GqN zuR7%`!B%MEC^Kt=lkoyCHN$(^alw|`!{w-t{`zP!$kp+8(6SgiQsCO#1P<@Rk1u2& zs|LH*7!qXOj$3Bj_&R8%dq+|2YW2IkwUUs)V9n%*wWH7Q8Y-pMFnaP^dEA5LrDB`g zl_BBbS;p37_&fc|ZTdwy9f3W2A%8+;uY2D{_Y7Ao3pVZyel1_-SR1I@WiB6#b}vdU zeC?e2hi$(`-ai%b_e^JGdyDQ%()^s8IWzCQ+r7bKGv+TKDyLJrUxw~S9A-zjXgXB2 zv;HRRZ8;1s?)t*_FIdu-_`L^G|wfqPviIGHvx)Ms>tH-{yl%JnJ>!-jo*O><_w`AWhBhYGE zPRE9s609lw@)o5i$jX>Ti*>cKsuQQ*O?>@E3#+cRD;G$q{Bbsz7!UaBP%CYOqsj`DWs$8!I6<+rzgQD>S2Q0Y za0CKm^g8{y%8FNKRoxi#89nG95M5gesoU}&_ENx`h`6sldJETpV`(O~p@^Hee0CST zDS03;HS<@uw@Ow`=%v86cPCNl32D3GpKmXxq;3|?_4Qqzy~O~yeHANse*qoi-Cwu% zSGB5SZ+o(BzEdSuJDL1l#l148_Mo5blqYpO7P4dv1zfoP`>Gp*9lMf_|2?sv3DWCr zYHAA7T%&7b{;g%6v_APbVydlfTtFa7y+#-8m%-f&Er$Ux$G17plR`QMmqS-<>JK-3 zo>33VhrI^Myhl4{b?X&5iE-HiUs;cJ`c=G4K3LQ7;16EnxI6rgwjP)=c!mL+%*i>| zpKbG%h#xsTd>z$iHxd(cO6Yzm*TmcFA3hj?Ke!Lk{??q6+`Zsy>r*njrZ>>J?_xes z2jolvpZFqd2vu)JPXmLiCmd$5;%1Mm=%;7@>R_=E`5Cq1B36yi5AH-nvr|QFT^;DX z9dG2*Czhmyv+ti*Ax9fe?NV$z+MAJmfc=f@6UVc|ow3PLlEGZc%6yp0-s#p)&8J0E zx6|zKB$yLCn8Q4sGL$B+Tv=Dg_T!OdE}g9 zj8?p(W%flQM9O8_Y< z(%Xx#==;=eTvn{#8ja6hO!@jE-+-LoKHW!DS+1L{)(Uv=dF$VgvO4MPOgseDDnF6O zL$3;Y5F!ze>DW4Im`(MO{8b&I@aUdDlX@MN)ULJaqV@N8>KvUfGSH>asrjycqzoxv zTqyEJPr%VZ{lP%}_HuXvw13_M2VZ+|@812;c@-qX#8mG>%jsB5oamD!yQIX}2D%r} zoFF&*eX1i`KUJ##QWOH2KjzJr>b$6;36hpHa>w=k(CwOO!4kWV99}PtHIiehL$?6@=Qn1;e%kV-gK)1iOW$MH|0rVyRWO@RIlep}g07SPc&Cf*H z_Tfq8!vPoHWIcjsouxfVimB@bto8A6 zw}^*O=bxS>da|)lU3h$>5Z~JqviP#_3X7+Bk7T-g6?B5aojw5;q`>0On7z~p-!}N9 z;?)zlrjcUO$inr0g^E~a7|P$dn1jH*EGNrs>=$;A9g2^d zpNy4pX3FQm8W<}pO@7K8GHE~WI~)y3@4u-(63G9S(I%ivuAC}aT&iD9@RfLL%?9gd zmx2{2_$G-~4Mvu9Te*j&r(E8!HbECsnF;+;R*+he?bY+?VwrT@A^U?Jjlb{b1v}2Y z+7|wKd}@(L$I%18VvXUE?#RJ@v4CKApvM0DyQPQ*8KZkiA2FdN=RDTieJ9pCOvQ$_&0H^sJIW}aEdwwIo`#Si$k{3zPtm@*xC+Oa+ zd|$p##u0}h;VShyA?uXPlRe)Aa{G7O+~Un3;z)G5O;MPxtxvzE4AaZT-e&gk#Y=(% zPY5k~S{PdSi|oI@e^`ud*$?lieY#PatFgO1ynJXe(8Zs+^f_%l2o7PD- zw`;f9Mpdrn6(>1r6fl;P3v zQQMfRh56q-dpf&)mPaF6ET-hC#z6G7mlsVN@QLKA_p&r{TpW7lR?*g*>w?Z=Yiv?g zU6PMFALDGY$E`0HAi+&P-P~>|`N?od2dwfT0be9mNU7eg86yDb)7#rBJxXcKaEh&- zI_FGe57*W|bI+qWNpj53x@}fDHM+`=TuX3%i-2-v7ezTE^ndiL4AXG zW&KTbJ~Y9QRYfyI^xD1Dx>Ec3^X6|5i<;T$%c{I@&$glPb3@jwA-S0khm8ZHl5MLo zTK@*GRBTFXTo!8+1SMGYxZAl81;PVUrLW3ekLON$e!I@WILkR%Vs$sYe*cmD2~hN) z_gQ3IWLpshw0l4I7=wzCdiUSamc!A!*7oYa+mwzQS0D$2EiLThaaFcU(;Cz)@n7xS zX&le-fDc|L<&qK2@H3ibdZd6{9K@oJO|mB2q_f>sZ*KRVCp_EZc6{c)Vzoi}?=d{X zJ9IFE+hi{GVu&y*UYmUIzUXv?SB33)Jx2GmN$9D;V>TmXx5NZWr?#mh{`Gst^&M7; zz^O~Q+Ubpg*GQq-5OZaSHH?-=*u%URr7a^T94gN>++T` z4KLoQJHe|nmw9q}%-hFDD~@8k+?ALUAN~`=t=}Ep14BJLSIWg=AHy2HG2B&?bKAjz zIpTTrXM15Gv*5h99{4)HD zC!kl=?9%+{OvvQ>5k7*cN!BSS4bGBLMsZ;qZt=oYQGzRh)%K+WT>SxF8Bf;6NZ$n> z!V}k;rP;@`cD}Zp^r;Em>RQ>^AxI^;N<4y+)5v;rJ#DG2=R0cqvP;_lb(kQsQ16v(^iCKCi zFbs0*__*aMm~pW|JEBk=)@LWF3l%YAN$&B%3f<kMta|cMOCq?!*NIr}lZ~MRp>`kc>{L^cvRwG+0uweFDcH#ba-_ zoak(^ICO(FR5!L+Es2bysLXrF)*P*#SsJbNqJ?P{8knT(hVK8H%vt;K+akFWoihIW zWh6Ka3mqdw6~1Cx>tBK18%}k(c=!%!G3Dl{J}W;_@&VsGLS}pu#{QEqyUB9J#3c*? z^vZ^TU$%`#V}(G`|?g9&ud$ z)$>)CoZS8Wx2M3yCcC;q;P%7Ce7j?3jzidU^M_oMbdPkq7D!e5p8ts#vd>)7U;jMc zBKNm@pQR-o$YD!hx}ND4?O;ct9n|a9&gC~}hOU_vyR4r)qP-WP#nZd*}Wy3{Qgo|97u9v#R!7a;uzZOu3xc6WpFR6=iUnu z*69kb0tL$+-R z8mQuDCK=ysBm)dFN?vF3bnE>$&I+lT4k&qnF|km~NvbQc>vV{Ph)C)iOM2a1He8%- z`8@3PqICVOkNNP;#YfO*NvHEJ%&x^8gvtJ)-WXf%ReIDORWYoL(M!QJtcz>U?C<&h0e*1rx$pCO zKgZ)GO%Qf07B5z-1;d>?&KfOFuLTP7 zP1Cs@pD59WFiwb17_%c>JTKb!22vu&f^G}F|Js3wZerzb3e&lJJl~9{FZC!jJi)C@ zUO!`Ea=%?SyT&fwcGPb-=XUO0x;o6BE-h8;U2ZAf`}{3-IN1C5oM3d1>w#R)vb zJsL~1`lTmfRg-DI4-O8#Za)~;yN5oB(sf35w2HH6M~_I%1pQTFQpJje>+npfy9Dpi zH+|n-spE&x26|?bjJC}b+Zruq5phPbLv!mq#CQ??Vfu9PrNPf9Xr+~vi9Oxj{cmsV zNkj_k$te=DY@6o45exT=B&;}y$w^B)K<#p96<83>JEvz9a3;Xsl%bdnLp*g;79=o0 zmqdkAc5EJA+A&^0?d`19v3&2UzZa

QsJY@LUW$n=fKca9-39^l5qj_J3Q~k|kx&`dejJ%G?E9Y3Tv`1jpojs^5L>ZTrD) zBQvv#O<$?@tKp7Jv(6WKGE-@{;&Kr3vUKY zuz3Xq6~iYdC-ay5qUv187=7WBtYE~s#YJ1M?Erzgj+{Mo+Qs+5SjK^6Z}Hbxdor~SVT%f!cOJ_ zL5oW?CnI)Q5nQ7z!C#tq^f0RM*#Upj&D#FwRG;Td5B!@c*YdAB8)J@`LLHTF_Y(H+kK4yXxV zBi7N5*HQfL=RUW!b5PH(OliXJz(idr=SQsr+uhOJ9FD6yy~pa%jDo4Y_bj`^@?b^F z?o}yw3N#FY#4uRUoxC-X7Brdqu(8q;pyRVPb$R!)I#DigFhqLPwiVXLpqeoju&d)7 zur<}m+=YWXGe6yP77rkK1zEg24HLxCmgsuiTGKs+pHO^+6%{{L$Q#3PacTA!ZC@N{ z-u%Kj&E*dU{pG(Y_h5XmJ)p`#0O^Di1V8S(X~Pf15{)@It8)4f zvj%%Ho1S{PyN$ruP5%1(br=_Gxm2^M1CZD|4UrM(tjQ~AA6Y-Vrg0I_#jy>J( zgseBs09~S6pfJ#%H;v_myQq8HJzfR{SMESa=SAR(i!=&qmASl((P$qs!ITi2ITHP4BD9$#;rrh;C)2 ztvxSrxpm`@s#g>4neh(+D*?(%B4su10<^6ii=MP2BS^ItNy^~z88&R* zXh|+U1adMY6%VeAuBsN*>;2SHKrWaNR#7}K`Hkw%4@($X?}xTyz1}Cri!KVv8p{jY z7`@2ie15D`3wHEGf?Tkl;Z9b3&u1$^P7vHM`SOu*8u@$v`Jj&&4-n?-sJSda3LwBD zl!)H+K3OyoN$qoymico{VBM85Ac2ci z*|K;<7Fw+A@q1#8>%F{kFwBilZ;sE;{hROA>bY;niAs!B;Z2>mX}}2+)n}A|Oud|z zN)dB}*P`Fx$S&%6Yt-XlG_ENPFp7Rb^z8I#gzd4rSKH@K(_TB<@Wmr^TE%^3)`$+J z-?onynD_46V~?G0`nvvYF<<>P7xX(lHmv=UH70j3i+}g-9rx_06=F--r*>v$2Bt#y zsyhv~{GPZhD&EF`%lgCUlAep^ zgR?GVg=q6vec3!(=CgnJH?plGY$aUWE2KH;z|aU+%VmVEuH{JCnBj;@tz@FrcO43f z!6HW~^HYz)6@iPt{n0<6Q(M(lWba2-bjgrac9lw{8F}`V>JvavjB%S;MBS(w!wtX~ z8ol)L9&nR;x#w7Erk)MqYMSk|mGrlnW~Eq5g9EK1<==EnI3`g9oA zWgLB~=ZRw7-Pvj4dZbbII4c`)RF(5)AF4lf{^Qibk;o>L(H`U?Vz#>zvEzw4y^p}R z#Wqdw-)!;wpSLK+t>7H{?^eh#eO-wXJ zCNe!UpGE>u0VnYcsQL~-bhw)vIPL!6*VpYH4`$j5=0gcftL-+c4KG0~bnjb7xdpNyyA3ZrQxW%}mBD_Zt5Mmqy( zDnoS_Zr}QK%fJ2y0sZ8-l(pQqk!9D0doz(KyyrYl?-2JZ9zp2(_&185M|9?=7@E9Y z@21F1D{DSU>!396!alG_4FCLlls+oSd}Tif{VY}7uN}6tv-31!ki08~gjL|~qRG;4 zZctTO)myg*Tx`_oDZseA*2o|~^^zN7p$;V zt6Z$pIZS`_At)&5c4^}vHPVOCeBxVLbTpG(p!?y~5HOvaJLm$B2-TyAmtx zQJ#qRNQpgDyC1oEl8ecIv0Vd#G{S;`hojSOXy8N?=oiQpEyz9Ho;iO}Klj$rx3LGk znNsISo~SQ(rC1oSwuWU6`e!o;jRk3;*O2d=_V#Mg{B^mUqgIYFwhY45{10RQpS;=b zy599G^vB1S7~KWp|I7Ddy_-ngVt#*Vehb7HgRX-vD`~&k{w@J}LUKNb(bJ0?R!4y0 z0pppY=AN*NC1-*@Y<>G< zA9ERZj}?~(7dx_Hso^L`6j9Cz%OtGS>;USb&e`t5o9^#aQCHo6>F1l9?h^CaJdIfB z1+nyh=0*-qt);WNn*h3mZSO2b zLO9>iT4#1afvjRM!jY=+ZRK7I1+%{mqQ3LAt;)pMID<`))5?wdSp*9-df!UZJBm>^ zy!|+6DUQA)+nB_-F@ z_)+n$gTFK9p3@Y_H0~+g+hdBLvD%fD@-hCX@yZ_V9CKryhZM|#-WOMuFpgfY1)tcL zr=S{opU))02tv*faJWERPind5>FMbJ8+|5y`}B9 zq~neBi3|L#ln4Khk`o92Y2uSWWM#xonYStsWx{tao)Qw73#dAIkxWR?>BLn61)4s# zGQ?!w&k6)&uGC5_mjI*_1#VVfS#)O@_^_d8PqNz0q!1(6$IyaWSSgvmC(0o^S;g5Y z?=>4eN z1Sg+R1~4=#Ln{mb6YIJ`bP8&p{IX0LRG|{3{QwR|e3_H_7I1lErsV8NT5MjSgGfU#-rzY}t(LybX~%pJ|aj zAI?$*r{o~??XlkOUn5tOp8R_7qMj7GvwGgge|zx`%_q*vNe(beQVvo?YKO{<0NJ;A~HcCcO`+a@^JZchN9J3 zdajqZ8_(K?nGRzaDC*KaQm7?_s81l$E==|s)UfWAwivtz-+v&*D7`JGX?PsCjP{8r z_Ebvm)z_2t*&TZI7%f4cRgz4fqxJvJI`^J@CZL|Mb|xigd(g zsrJ=`2$Rs;;Lf7f$*mXPB*S6-IRntwR{KjGqs>9b8E*vm+i0kFaOB({)Y;NWnpO{JsLRP&x{ z6msy#k6OfLzv~=Us{LDg!NWV=NflMi>WmjV)18Nra<}l?r_{ha>d;}j*cs4bi|y!0 z-xDJeX44;sa`Z9~MlwM9?)wk5VwN9YF!V^o>2dT!)VNQUmfp9_ht4=OQ;q}GV?^$# zRjiN|%5XRgBx$`!Sg3lsYI$2h`5w4npZ_}B`nnUBglfLe+QFb?`BI9rN^KCb3-uap z^Q-jdC-ymRHH?c+hJ1=v*Hh^zidWLBN@Gn7W-7o@p;wph>k){MzcPs!1W6Xk4^>bfnJ}Fxa)|G71f#;)GAd_AY-mr#COo_=*wys*EU159bH228|eb3XUBRiQdZs>0!&DIM4y)gU!w)3O< za-iS!8IW^cdJ=RT4;vmo7;omE_pi5|b!9a9W1!-h;Rk%~gr$?_#BuCZNK5TkrOl4x z4ybv34rxu^K)f$CPzFxw~=~M-7wsbb*2hJUu zXUO@x-0e|Ax$1K3UuJV3b=O}F?>+urYjmlmtCBxvBn;T^TonSJ6{eZA)9AN5z?EQS6~0Hh8Ozb}(epcJF4Rh>yqSoB@j<;90w}G;^InlSj52LPlIV|Om zKJ>S65LwBm!Dv6PCNt9KI*I8_K>-Y1IUR08E-t%23y?0d^9yAE3$Dxci*sU%NX2O? z!~w7{lA_6OMn*ChDwOqL>vOXQP`kq{3$^zd(78n{bL7?W!O+Z_sE$A>%(7!a#`_-` z+4|Ax?sduHb%(S6L9E#&2E{Dr=jpk(Fz&DruvWb%8{p>a)4G=AdA$orG1ktDvR5T? zzPB??v4V+#3~p$_@VqzL9Edj*ov8z^vW0#%PjwrwJJ!R_GZ!SAer{w0a zMghwwnpeBb{$cl$1R^N2RQNu>RFV~}{@7(15S{ZZj(fP5co;ChvMMlD=0DdCmzS3h zmw7T9?QwztO|$*2?VlZz##LjL8MB?%<;6?gOuU+5u{>V|J6kAP2)IgMf!;NGv&kBwV89DxOkh6xAToAObh?4Yt`bS8v-I8623tono_80-S_-?b&iP2EvMPZ-60v#p zB?YYCh|Z378F|JabhoxFjf!kinwfl5M9U=;j#h&7e_)u!2zPwn-`MbOG%z+bi^242 z+tmswy+D0>9B^4)mDQI<>Bvc-Zr zSWeElMY*dw&7j?-{cr1y%nD$-d*WCRyS5pJ)9uZve5tek#zlwe;uJ3nmC=LRmAdT8 zA5JZSH&>0nEiJ6O16!J!cXISq^7$Wz_@GR2DP|C-bSTtLZEApPVyK}DyQ3>2_Lfdy z=_u7YK#phqr!gr_LQnAS0R^qa$cndX;5yV;{1eHy4}T!($y1YO?h@S=Jxg|d--~Q# zQ{c5g5Qi}4VPS>~>`u8_;8_Nx#pu?+XUweFQC+T?NnU|jUX-pD2`jSce4;!Nz~-*E~}8MRO(CY;p+iTQ$jHhI#5CF;6OYO>p1xSGfc*$fJA-rX(Hs0hGyLI+y2m){sXr`HIOf@7&<6;Gmf`Bt>o5pAW#w?zo8n*) zn7lG+vA~`Cvi2=&==4|4htiS;H;=@SU1+j+C3#9MoE32k-n9}Nx9_w)6l?>}U zA~YtWR)9c2fL@UlRaY;v z|HB`fUdtx+6$Gxr9LYA7$VpfX*~;??74;f6pON7Iw|9Ceqwa;2J|@hvf+*>Nc4o!; zDH)y#3pc9^@2w-P6gJv+H#RVTe~jB9aY{erh;gc6!Xp!eFMqC(aQciORiun2VG5+c zeHYQ6?up9Zi{)YwnEEiH!IO2bSjP7lxOjd)*jl#iXpLiyNScfJ8su`kI#e?1`P3qB z%nBeupZ+yg4{;K^Tir+Ltk0@I@*|r@R9L%Mz}119<|IPCcjOmx)G|U`CzX=$oQYx4 z7olI)Jb$?V5ABKSG&R$&oLA4H*Gn#O9#=ly3U@!}YHCe7C9^t+L+kf+SI}v;UFeI2adwCo^S==v7C= zdL9rxGv%WXr<@ft&NI6?D!j%yV)-&-Po~-&fs1#3RUhr;hjKPntvK5eLrDI`fx>@F zVwOwa-)AXZ8)dNB^%Fb=a)<464j4A1$xeAzGal#}cBL`i`EMOxjc-2aTVraYkY~6P z$pEm8h(!-HI@0psqpd^<;Twf~&#Gp%-pJW^YHF$_KX=tVQF*PTLNeFf#F|*F$IZ;d z6mU4&Qi{2oC|fXQ%}DvH=f?wW3x`?6P-~Qe+FNx-kN+lMukQ$Ya5x+Wm^-FIwj{_y zRk5zULa0=;$`UO z&vYe-Ru%(gmNUC@Dzkdb^A^@Jk=5mhcgw+AhzHt(+|Qx&%fd!HN**Zeh_$LtA0@!k z1t9a42<3c?^s9eMo^feb&RHb5FQ>$N}@!bz$JY{ev33hK9yK zirm5alVUrV34C+dAMU1F7u}S(&}Q|Gq4(sVQ~dGuJ#d zXMO9!-1#=iVua(_xNlP>8{n$}l_HNydqE<&BcWPR(g;B?bmBNbZZ(vaNC4=B9_FLA zgcLz^ik6V2T!sZU*r-G}LytdZ)Vhgb2G`C6y>-FX&kh|828xTTRwdpJDX7q_ z)?8khh56}!t#@yM5f;1Gl?#6rGhah9(G(jSIC$-3{XPWT)*R!)aB2K{FI4(nzp%Sk z&+hW!oU6IOGq>}qbB`UT--3x{md?aEU?R8KkE^<2I_plVRnW`%<_zknk;&u=$`XD< zMcl{e(V7hDT;AO|zmH;OPF{HEKx167n9(kfkhr<1zq+#WQ=&CqLyr_ZPznbq8S+gV zgINY~uCn-~3ftdqvOy>50JRPKvpPPT4)^@Hui=^C1(T6LSfZ@IziV@6r*VsV4v2wH z_&zJ~I)fw;G;E~=2nUzIUIZX z`SBrCU?9}?yOgM^N1$yy5OyB1G<*r5f-mwzM%PtoDmj=J+f1$opft5d=l;JZge$Y7 zKi3D7ZQC)6H^(am1-fo6TPwJaqiGG5YS@A^%-!Y_BHy-p7A9MeK^kCuPK#}Nc7cJ1 zS2Xnj%QqdFV)zrcR-)jav8s>)FB7?AN1AL`#)}UOS9Y)E%@vBJkEf^K6%3mrlv#VV zs>8pJX%-mQ^8=)TqOpku`Xp{yH1%B_O^M^`ZO+wD#$c{WQJm|vGw$g8WNiu?#Wq_ojf(V1a99$8Y{fWr2J10Pq8tVi8 z??G?4Bm(iD-eNRFS$V1WE0}c}+G;7Z$O?EK7m^ozqW{xM+e{RH%pV3glSuJj>yrtRC6Yb)9KP+L}TLPogQ_cZ9@ z-o=NFwnv||>^s}qa#ZeZ#PWVc-}9a#3izwsD(HGAoD!-gA=9+_1UUjxIjyFMwyzcO zx%p1J-*3_@!rT5~9UAinO4C^X$L;Qa1XK!pzO|In&FXe#xeWKszNSL49vxR=p#@rVAJfbrTn8Kx7+1@cC>i5DgWoC+NyTr-hR&;{)Hx) zV6vq(s!9_L;N?+HH^(!n^G=sT;tM%rDAx@F0YraCl_NG{1lpK-9- z@=N_yEk8Y)+Z@j-(YyKOg;`$K&k8%%hWoS9nq$wDlK=F$m`_wFFFGeDDUz~gRS=|c zi)*;h>S$T4e;BcPObCC^pQ-$O>Mlc0lm>Ztztd-$?B@PEgjvcb57Ft5gKanwH#BeQ zq6Oo9{_NJx^Jol?{JpxeM77RJNsu!dtFv=M8d_j6YNx+B`;3R{nA@5w*>JdNE-u`e zY1=hGqMk7txYR5x$TKsJCCO61q%sMow}?_B*>cMo3T2Fv{Pe4N=e|{bc6}Yco>0&t zPS%+i9ARwl@KOh;Mx`}l^Q(4ucr8>=I~Z}CH_J&oes?j*2g57PV9h|>LZ1O60rx-m zApJu%-n;`-`oaK22Ai2;|afT(S)8m2~+61_WWHLNQ@d9@kEU-Ed217 zebZnZGho$eV7!;WBhLIt?Ge|6ib5~Yuo3zMg!4h)jhs89ZQjvm?l@%RAoe2H?ABsi zK)QM@*r&o?j+v3$_KNe>^2$eRRcVJyKO!f>ylASH$4feyEA@+V1>RVCmVmwAhgTU1 z5+ELAkBF=%2UE4?dIl!Cp&&B9kWWm?7 zz=>#aee_gi+YEcIqzaqT+h=y{hAD4^j{AZM?nK-ni`0yGK;d9P_9s}5f#FdBK~1A* z0TqX~59IB$7+SDL>^5T+>czbO;giz^P1sLnOdx-N_WTC-T&D-x?=-N0?&fx}(6Ie1 z_!mVOdC))>G-kv<(Dmft4Mpyf+)=Bz+-0`7YOUPAzlwFsc}UE$qW@+emcPSW%DS@+ zwkIY<@R5&-@NxcKY65eT1$@K-dER;^nmX zw1$Odc^q+b21!LmWxZZ8hw>Wfh#q3(e*Qb_3}Vd<0r7~xjgZ$LQ0F%2DOP>f+XQpXl7w0XciDyM=bzOs%0 zgCo{c|J3F29wSv5?SSjXWdUWJ;1)4h(#u50u=6ONBGTlx{|SHR(XKt9&f@MeOB>XC zVOjwtb-3`BBFLvDcJXSR0(iLj}B!*>yWW4WX$7AddiHrFyqFG>m13OY# zlB2c0{|n|MLw@xkhsZRj5a`98K5odd3|aVfq)E}%jvD9d>xex+dmt}C^TRcc=3T4G;#rh@c-J*H z=pVqV4f6K!Il^G;Z_;nkh5o=##cuTfcyh-3SK?hKmsPVP9CtK^zZ$DlPDxf%tH{)5 zNs5e`H?rV*Qmr)s_Ou`V)RFxwTKH)EU}$r*Rw4mh4s}~R1NVom9isCAnV~98giLCU z)8Vp{`EG~li+5~19Cx~%P)+?)!#fTLExoYUa?4B7&x?OE;O>yPRr-1`Sj0C-(fZ54dgyb;_`(z6WMz5Lc~5?4%pg&^i2b_B#6Ns)^-(OGJ`{8ua62@> zyw_~M_mnC@@Czt($d>Kpz4u_Xh&&2Q8-_?v%mdedn3k@(Wm4iku@}Y0ZoKjl1XU!D z6fvuLtq=d&)gG|Yt_vpEJ+jZy53cnsh&O$bfLt!0HtH`yso+LM7 z9u)%|wbn_s2od!pB()-S9d_Qc!w}zRp!zy*m#uOtg&+jx&d4tqU-mG5OG|uRh7$ zzKA`)sy4eu_w^}ZiG_%7qpZ#18YlLx+1y_AF~C)<$Izp>^&KYmt?<21c>EK>$+taH zd+ox!B!+p7>MJ_K3+FQqKhjTrpDhS0+RW}a{Y0+k(Q^D3#gRD*oL4Yf8&oUknVN={ z#ar=LFPU}SlXs=)bgREID+r-0H%pl>iuQ1^bWtMlp=CCSd}-XpkJ=U@(_&u-KCwtQ zLcp|zxTWBkne;#yDsOku?^PDCYh_w2a)4_^!m0TVwJUF#ei@6@O6`dt0G!B)TDZMA zi)akmUrKfTLuKb2PgFH5Rz^z*$@}t(YkAp}soKYnX0^50+L6ibBTpuZ&&&@<=$b-1Th-}SfF`m#yFDNmIhwHkM5*;qr1#T*tU zbQ>pOss}}^7S^$S%(qi!`TXwyAnK7)i}470j9*8^^etee4*Aw^&ZE<7bqd%k59;<- zF86|j$Xywa#>d?lmz{6cyZ)RgPJ5!I91)_C6Moa-^8Fj!pnFOUw%u0nlOJbY)~5b{ zr#zd9SnB>8>Xn!S0-L+@emw$qD%lhZQWVPE!_Ru5F-K@rM3flzs5y^e@aqygg6luKVB1kVYX`O7sD9GTHeTX+8pqj4E56$5C4UFOi&b zB4z1570nVldo^^~Y7ewwbp}Cv?5|_cwGq)u z#}v)GqL+RLkB$TO0vHC0@K%^v*Eti@JW1>YWnN2qYue*>9260n`00~98YA_fV0@D7 zWMZQWm65VMh>ZBP7+{b2onMh2U zP-u;QET_l{gDS5zZp>Wcu~~07<`3=4$;l_U2FT3&{Dh&T;kC-lFdb*@mm;v+B!h)V zRc)%)N}eG3J>BhcC|?KJ)62Z7S@K|wBxa9RYTng^O_v1IM%&@ z2_hm;NKa4Ll==9cXZnA?1XPE$OPw&{Xi;!^B!wIhr>V~Unb(92RMQ5&@-ey#~(wBFbcs0^vm@a2o0~Z*;f{DnA(Im8B4UXnrr=YMTr0H z^_jH^9FKV#ZzL;i(C0cS_WSW2s8VpH zF>l8i1~y;RFG3BiO>Yf#;r+OAxunSZvGrx#Tfeo};W{d?BNq9gPm~sqMW65^zAjpZ z!Fo@u-gO&;Mf+VDOWJZQXM?A44;x@vpI&)W#B@VRGmC2*$6Fpyy%JfnnWdCc0V8O| z@h3pu{S)33WkF!E6UiL75r=QfZdypH%+r?di6#quMf)C9#-YfYo!+cK(k;o=r)tZM zU^)V+Kr&$X$;8KpvJ%W`BNj~oH|mAG;K_O3Xk4Q*R7P9ksVUQAFX?wW zX#;!iLi19jv=DJ25M8mlTyuin#cF4XxY%LSUp`f6>!}u9^9PRFuqmK+0lW3AqU|u_ zqbQIL@b^50l&m64h~$M{@9{w%?52ZXM?d1!e^bdJ_)h2{6=!ItejTY(HUo77y-J8+ zDZ@JvlEI zg3rx8@W+;MjjMoPyZ8OEYE(Z1uo_2&5H0y?uvP6X`9y=+nX7_?x<=xM@{cOWx5no`>QdLpac6#4REpE;3FO>lK`uzjodE?_l zCunN4AAw(=d0$&5tM^|I5>ku&HFLO)yeAlPnZj>ezMC`4;R8Fc`5$ zFI);G1!UsK?xby{&3&V248v*YW|4f8WM*RI!~Lv|{fXb{H$WMT$X)y^d6URoD=v=1 zSz}&4tTU!Nss07Ze)vd8`&IcnZ#OJ=N{Xt-0JgZ(SGLxbw6+S5z$mZK~4)btQZ9{LM7P3*Y5hYtM5+Vi7@bf~ zE%SBL_-1@R!<1^>!r;&>H?@UA@!_0b@M_HzRcd#cHm7y+DMQb%7r(&a7=xS$1sLLf zwN_c0c}*@m>janc@K?!)bYaimaN(|YoP+Q)6I?_B_;uvuUeGN-cUVB371urUaC)L( zB%%2*8`=&h9L}zO`ADZP7gL7zt!yLaw0Dd%oM`-}kFWWCS|H=|t+Q2dul>l-Vleqb zGPY%RgdY9@^M~geaZ*uYn|P0;moUNs*a(=gBEgc)UE_UE=H0~yFK_Q{MHksPSFH|} zkK<~dbn(DalZqZZusNnyKavBtlp8knDz1^)mr~HOIroXjSifu5D80JPFb-hLQMvy$D zBlmv=TpF>Aj7%?aWY8(;p_D0>5Qv?Y2nLe^N2k7`Zb(z;w^?b#0$+)TO^#Uju;hB& zb99^W7q{k#&K9d*B!rl~X`gx{C#xaJ7DwR+`2<7D5{QSD?` zeNTcz6YsOV#O1l7TRocv`b`&&DSCGQ?f;QXb?cN4va0Ry@YPJdPs&#k;9et$z<|RP z!r?>e6QEd)zix3C@lt!ah(=6ZixhzY&${}P8sx>}+okJ0vX%D>mvf0B^25M373cN? zPT`VQiW3mS{Q2QcD=I$Y*5{9iKsp-Y;+dq6DeKr62eN)A0sCXf;{}Us-JmXXl>-We zgrY9CT29lWyK_DSRFf{@;ezqGC!@5e?hzzgwy_)5U76;7_J5S0?X6p!=L@}#nr*$V zn=#xx7&S}=<>Pr(H7TJF7B$r2I%=pu%|D!#^OcH$)oM{(4R2htk`6PSfk}fSLU%Tk!f)PQ3g05 zh0_Q7a@L8ra*eAhyOZq7D9W$)M90KSmruELJ3>ZH36hlkNp%`IEBTAP#Ea&?1p}q8 zE5~ic3iG;{EH|irBOGn`#-;|jHQIm?E7E3&CPyk?z9(ioM&kuk)N2LF3A38|2~vQr zmui1$R$A+Pv!lwv)zu7OEd8J9B6qca7j&{aPwxiqmM# zX@U^nn@psd&4>y;LrZ~7%C$b;EJf};3-&o09RrzHo&a&NoH6SW5K8BzVP$D^`t|NSX0wV!q`~_`*wGq1*O;h0RF~ zbVHTS$Z`&J+{F4%uXnAQ7Pc?S$Iws>U>Wo4ohFO{6*#>-=_RNd%_9UVou(ksuN(7F zxBER!Jb$rNGw%ET!uJ$v38C#!{TSMA=lVy)k@2o#65P}ob2Os{%7^#-!XvTa2$v~{ z_QTVK>cv^LL_<}Lm8(~~<+4qti=}DaF>cR-H&b*4vO25KFstl} z58c7fc}P-Xo9`+;(@IuD_rZFQDeLs48si&~1;77%Hu64aY<0W|HcsKBg;^|1YztQ< z#oa3+NUHt`@8AvC2MSYwvY=)g#?|*{!qz&qY;6AKeAW!R47! za2bPIE0gw=W)g zU~Ho1C0;+&hkkjDt9p0_tmgz#&PY~2d7~Qc``bknB3PD2UF}9rM5LFxlBYv+B#?l~ zX}0}pg6U0z+HpU6usNtK!>nZJYOLfTHW4=!{9F)ifd6*r*)r-gY0=H_215YVUy1O% z_2s3Enbb`om-(-^4M4=x)F=@trg8%Ee%|l3wZ4-Rj!edtEI7sk;Z+c4MVXV9auz6- z`^W-11X2UZ65;Ye_5>~SPAV~8dvhnU?&_+7WdR^YaV6IR^cCPcHA!_kl`|*9P-)AiDODkfB%4e${EI9 zsM^8#CG$$D&g3wH)^I5?k(N-21w@K0D=l-4J53 zM~bVtm?Wz4L~Vssh)((Bjqe;U4a|`ynp{LI!Gk zJI~vl=f-J~?9yWNjGClb9S(<|A4JL-^JG?sl|Z@iYSE99{U;1o;2Ic1Yc3{lm2pX2 z-cN89Oq1$0MxC341>h6>ZL&_xeLs@x9Js#IvLGn|gx$7{J&Xs9OZBNR9Cc!rmbDp5 zvfwp21t7H2a2(&067C%o@wV^lQpObNHpjQcknQX=c>=MTjutT*{$Rbv2s?!G;?oUIj>oSJ^EX^@QcJ5W9?}`Egj(695&4a)Gjo%FG zk(SWs_k}ZI=|^QD%B8c1(5ybccdC`f$T@{@xg7lcG#D14w?ET+Mz_Bpm|11|+xCux z=neAWE5UwW96U$*;}6LX3AS@w6%CCVrR3Mm_?J`8)3C7unSwF;qrQ|V?xse}8wC+gs`d|q;L3eALsi}^aoTri5D zbd_Dt%F)l)|Ejy=l52p=5xc?t3}N%Y(7LVpsK`~80dMebkcjNps5NTdY3>Bp`ias; zu59YRWs!mcp`M@2wyGb8cZu23@#}e_W!&D2BDKhv2eoY5HLRh~7u2Ff+Wox?b-kda z8k&s8#pGuGsNhva7NHSiZhY}Ke8!Idl>1YVT|9`z>~<@&>i`+#<#94nc}~p|Uosr{ zVp?|@5X^h)@U-|^eM;-6_x+p=iGHy_Y38Q-&dnF<^x;Rin{6|(2wJ0|iwQ1&rt=K= zc)LR-_Xn;2QFPw%RQ-P(zg8qdW+}m=l36vhljt;IiL6E{d&EgufvYV`xuO~H)m4vZS#b55YeF)<}5=b z0#vNF^VQaVjhGz$;>ChsCGIesBT1LVxTHvGMLHDGXS5r_2^CqnxX}5ie;$$v$6t(} z+KUy6N;!Xo*)8@4HAC(*JzAnGb}ucf3WKfPCF~^EX-=HA!Sfq*H+j(o-%OkIA zZNd;+!YO0ug%T*mRiyVkieruX9ajczk!xb$qS!Y!sNjD=$IG{~!DO|rR;+;6H{-{O zo8KCZzXu~6zYoO^=addGCUaSqER*uej~(EW#ef)y|B6RoLf&~*_=uJ7rB;eV8m|-UvH*SJx!uf2n z-#;!fYHG#G=w+_4e?Q(_FSh%IzI{96M?~63DKn+esG+!t&#EM_FKI@^jaQ59A_4BX zwGg!8>GIMKj0W&%IWp`gbnXhtTbMj&>c_Hsa3mm^1VV=_;o`-s=K=JryCnaPjFRL z`v9mdes<2O&f@Np)&TFM1>@2eVpC5oW6GSY&x+BVpJ^5fV(01yb58Tx{v?$k_MgWp zao^(Axy6kKYwBhmmd7=vRX$A-q%Y(c`Jh9kLRs1;?;+%OGe=v?^`SV9BH#Ruj+V;I zgPOw$%zSzt)xi0v^zhzd7a;sOdpvQEvAxFHzS})OEo`RfDDek7Bj8J;K*Fmm*Uzm} zvakH6c~7^~){v$AQ#rE&6TOOD;K2hU{pW;Be|t^grY&W-q74nc2rB41q$V*kWEfGK z1A7{wR7>N%&T6G>XuXX&{3G7V6M{9WkL5+l8sX0y{yg!CN; zDvK0^>KRsImC*Gg7Jr&%KNSsee21m?tgh)ep7R2Z=a?7)>uHOs%jm0r_^qh}pSN#+ zHPZ63In&B1&-~7#z2tA$?~@_dM7nt174)x7BaOvdJbMnVjI^jyG_8CZF2=7aYb`R< zxHoS_G^3>aC`S68DjZ;iySH*oR(yUMU60gy0e1A`kEQyfCjW;zvSH4HS<-=~`u6Ra zQs`>qI*Jx+wqrf+8}5)Vfxiot$}rGad@064b*^yhu>9vkjdDKyxUC$wmL@e4-i`bH zIvh{QLH5xm_3{cPs^t|Z2Lwpmz5=1y6TVp&57yHw#j^f; zCBmS}p}>#<0o?hIumD=&&>wzuvR-rbbA(!xs4H?)4Wyo_vNTFbNUy9?6o#5eS@>v8 z5r>_ctVA-w0wRK{R5r5(w$@Be3k^$;%Uxd_6};LyJBW6$D-OA#yJz>J*@7tbsiQ>p z$ZBgHU5Ct=^USrk|6pXH&N5FS{_?3~{m%0^Nv0uofRQedL7$I33q&1P8qnY}A#f4n zvr3-fY0sI{t=3};!Oyy=m=ea){MQs^IdcQ4O=bIIvQ>masjWkDHAl zqabA+2B{dbQTAM)pH4ZJC^0CfWs&O)gveIR>oCI~=Jub~#6Np~=?m;8R#ncKAml=jRI(EsAhWDd$P+b>E`;a3*fq z{y@^y=kaSyYXG2bU~gbxJ%7%VaiaeoJ6Cr^vJ&~%!HY2sr7!Sfim?V+agc?OmG&IU zxRXbMaq(<*HE`j)5q_nAIU^dnc&kuaY2UcB9H|=rf>3zYYFZt8{`TA8XMSzxO}73m z&o_j%6p#@bbJ`ulk3`+~Fs+zxhPP}7UKZ!U@w$7_Vgew(Nu9XyLC+}_>Zvn8anpWu zmTvu0fg|Ryns!v$Tw@IzuKU$*z&z1vqszpF<$rY{{>i> z^2w=_^E~S|r4wTAVIxLf|97TGaXYp=A8F*5W(h+`mEgRi#?FR^rS9g4WDL2}fWN6r zG$l&$IZH9#;e9c86cP4cVwqoQ@`Kp6)4m@7=4j;T*~QFMhg?#Gmov`^0CHGVIXS?K zcnHUqdssP%;ot4pYYD4{%|p0ILHKSc=K%*R1DS#LZQW778vg7J@1mBc!EVs_u;;nd z$QQs;vfs!>-cQ#8=J#^XDk$Cl1#B-qvspo z=b^lvf1iVhqH~Kj7`j0<(z3d|RwVx0-M^2WIWo=2kh|36t}0c|&N`~KrNfDZ$jQCw zKWnrlXGr2%beD=JO$1sqV zw_G$Qg`Zwg{}cOs)WCO<5gR9bU=1N9oBx%dBUbZ2>Q=F&vY|J*+IVfH%o47g@wZUB zM7znRwCqDQHe103cDOJqp|`OCW#C_Wa(Tw83sH?R?+}=~-n$*bUl@RO~x7 zP<>7S8J5M_V^9w1LVGq^35^r#uQTtmo|P12ufx_cx5{?Tw%I3(81o}cyizabpu zp?mr3l&HI-a)f;I*)r*v&o8Sum)LAWPO+1Vd!DU#`#WGW)AnP2w+O0QP|g@ipowO8 z*SF4sS5>RRqjl2FGMnM6tdNw_#TsQ~tz}KF{}H_N1T)cqJ|qdVP(wfMD_@|Fw)-wt znKQB$RD>4;^ta>;__LCnS44Ncijr?w3-SIKfF-)Q`B%J8av-{DBWMWY>`crh=Joyf z@gr~iP3Lib+O0bt=V&p1#M0@h1Oeg1b?@H&b=D=D$9?iJW>@2E;{oP}D~QMpls_`E zv_9?gfd?6tVgtw|O1pyFz7)wuUGyeI$?KVvM>(t72xu;l`!0zlm^t4>d)kd(rg?K34|@JBFemX-nR;qQhT2c`QhwMMt0_F{n}0m)-r+Wr z%}zHnt@C7#p8YUXw~FH@p`dUiQ#}hd=K5LdbW%<>X`^S=x}J|Q@0l_)W&(n)Q!DjJ z6pJ2}@?f=odon-?n00eT%)Qz#r)2;!Z*pqk3KqI0I>LGj*3JV(Grr#mI;TUZ>;M(4 zV{=d5yo%4D*y--PK!;wOj0w!n9XYjP=0{rZJ`K6}E>C=>tV+l~_KfW*?oT!zOz(Z` zg`O07Ai@2|sZ}ESc1FTYJ0T-&Q?VPQ5JC=pzZ}}0G z9oYKl0hKTx!N_P(Ox*+h9l{zOZl5JI1#Z?&z8F;RAB9wG9KA@Kg(+c5fY|s15F=Z{s`SmR(%cR ze44E@7$R4C`o#9D4(Wi_Igd^-b^ahL6T>ex9&VuiJSQu2(nLcqJm`OO^q8Njsp+TVLI zFL|k)N@$1d4v@4e_h_|aYi#uZ1A=w#ENkrp{5in2~w*de)9c#U^JHEvNJ(SJd3Nps;JJdSOc%^dC2I*k9)Zy*B#% zVE_{gar6J9h1=d(KUekbB$VIMsL+K;=>_zW95MA*iuyO}J0K3Ck=A~1x7_jao`9RI zWVAgyv$TCaM+`$Li+Zk)L~wUl(eK~uIx4z}qTsu*0rrZa97)f7=}~S27*9!cZWKSw zQju=4SR-D%P&C*@B`a36My4xQ^tv@PuvY#hI*IPKo^iZr=h@)c*qG9#0AMs*xWd_;)p3% zlE`nE@9-mTUI?fE%~$6o1Wm5PM3D9M*9^63C2!~Q7`L!)YJHHZo42irki8z1cE7=7h?`Y4#ljNkg2YbaSQ){HWLunQH~TB?H(Q5Pj;SWQdhfhFGX zs;CTa{+dQdOa$h|XD&<)W~7@3WJch>7y+b1 z^6i5AnMQrGY^+9umAjLgQXe zj{yr*Qyf`)651Z&{Bph-8Sd;3{POItlj&nyDtK_2=Z()vm6#HwvUy_cmT-KZ0)~FG z{Trql>urs`jR5$Mr$Kvxz|BD^bwqWpC<|p@)|~%2vhd=A?KM2qiLELC`23ng4t48N@uHlcPvP*nhn}sDi@+S^ zZ*#mh1~|P=PjKr4=%wO8YijOKVkTOKO`%i6DEOu9yn7?Y^1LgCD@iP7)Yqe|;Knnj z4(kFNpmr-?Mv?v#mGf|Wc`qBVEmzHmr^-D{tdmAIOwU^YO@-BgWGrLu?|@bSU{h3u z6vDSZCUNkxG0?9GV-pvoE9CtB@$Gv=-N$|HANfyT`ppcHQJLej*-~e|@qg$4?0Eww z^7U^7Wyzo1xEA42x+k#BAZdX}wn+gBKWYXwUW7NwD7SR7D}l;a7hp&Z@AJ1^;$}sE zx8Ljco|Lj*<5;+wZ-aR?l6_u}nTN>O4prCGp!RQIB_$kmT-Z$!RXk3qvtbk6m4VQ( zUi*N1%2e6fd_rpcO}4r=X40^hrLPnP(_he}1CQa8EU z=vZ1){qj?Ni!vy2GRa_vlG)Ny+yNy`Gqjoi00fv^+b~!rXH+;X9o=nbOt0(_xr4`d zWTOXW&Z9s9(Aoesopb3mKuLrLANFtotPy$GrhVPtK{cS~^*Gn{jO#M_I_AyJJXrnz zOMi$^13f8kA6ueWSB#Ms+Oc&mudvYc!+yfPSg+Aw`)re5((QheDlH0_I&aj@Co$pL zX-~h3i(#)|Ap(GCB{~Je{rm!ae2lWWlmZUc_8#1@vuiKv_crl+k9jT>&jtlsqG>Ki zWjx@%LE5HlL+UAj;BDddMe>8Uzo2F%9wR?npt<#Nb&PwPEORJ?1YtZMJaDeTX{qMw z&KRmui-%|%9@Rg#A`oBRk$4|hy2T;c@je*#Z3xQ*)0rB+((HPo?yrC9ILQ6;$DkD@ zH;_ul@|1dyVc+b`1#hjeYIDN1ygH5skClRU|12&|`-EWT?>@t(P5LahlYtf_``C!q zX!pc%ml=rG%ts7;(L{I{-ZI$1@Z|45fF;>$-7gy+R+nTc>FlAeBPN&%t;U^J=T^Xd z*v_{6_h(==0Xqc>fz-V7flx%PtkK7?AC_opI9To^?n$+y&0s`F7D9%aU- zx?IO`yen+tz6u83r-A|m9D*|@4<~}dH-8tyg z6x0i1LCe;Cob7LZoYF)A4dln$tE-UwOr?viW5uhJ2ms9%d@vBRaE6n)uXskTY=mgF z`|`3zW;Xvut?W*M4u+mr0?2BriKi3&G5G4bdMbRlB@BhNcm}Dejoqi^$*{h!_>Lyb1qGCKja*M8SIfwF zwOC{2H4|g{0xMQN>MPz+zN}Vxh(u5+p7cFwhx;CSqDiXNYUj2~M?im~TALai z|3c90)4u+cuh1`Chro{r+B>MT&ht;sixCkKbc!dBd*24V&9XE@6_@(H6_$hB? zEJM3b{%>sR+1K;ZtySZfRuwIl&I|#!wV(fF9Ar-@pUIm!ZuRJF?P#u)^0Sdif`WtI zzII0wW9Hca6~dS^4-VWOL_-!OkZILhzR?20N@u+z*BYURH4}o-Vf32mBXR;F%&Oza zy!7W2()s%@t&A8fYP^!nsxn^&4an9kzLCoWn9ZLEkXfLz&Pk`a7SVIEw#SH773y7p z0MHXNqQg0FWS=GM`<*-C)K~%G0{%$$9^N5!-S&9XA+VscXc>(Q(`^+ht$9AsN?`!s zAChlu#yOvDlkV|kq8wi= z94nno>t8NscAV_Ra9#dN?kKa`ICKKaI&w?3mv&DEibtiRV1kxm{jztA#(kr8v>t$# z^L)L_DL4FeNYjAk0RG76Y<1bp5`pI~5U+O9Hk13^$UfP}nY}^Nkxpyp-iYKv1s-0m zAiau=&jD0^*V?8uEuo{jAD_F|)_(rdfT_n-q)$SM17<2|>l|iE4KIOJiP&xAGEkA+ z=#s;~8WiANeZJ!C%nBx$zYO?D55I5BPEkvA0!p=Wn{<~%MMe9(L23M7u6siNb%w5| zvuZP#I%PM_pKab3HBF1ifv*~&u)5p}>=-K-c1B~X zCm`?}?~CSD=kCP2w4ABkNUglVp^~0h+-;<#H!nk4KZ_1_$!R2cZxSEv7O>z#`N4h& z`Jd_fTkM^0f(TLzpkC0ot8>4v3eU!Eq64yLQK zj?8>H_DG>G%h+r#v|=)FN;=<3tw|S(9sr^^(0H-i9*ixXA%J&5b@bIciY5!^V>vK| zvhS>g_DkUm+*nTx*Ho$ukv6u$h7yFg%r}$J@3z}gHpx=&>-L>9&60;s%Nr>POol+>zk3iU3GeK zuuT5lOZo|vhUW+>t_HL|@YVn=v}eGBIv@E*@oITV_Bnx*;dW2aI!s<;%$t`t!@BHM zEjYgRC)_^14iG9Aj?<;?ww^RnFh~{O43jDmerZ?0ozU0)?QHx5dzFi7oG?UXtbk9F z=bWk}UVPdl2-2)KjgnV1BBf-w%K;Tel2YkeK@FTAeBgQLag(WspLG5V0tZh+1Wa$P znjv2m1#CrPC!T(>6C`BpXBX+I4h?;vn-932FI(_eb3x?S{Pl-_fmAoO)`V^mmCi9r z`@>#A{c>d-$tyn1bDk}pbr!*={{0!jiTN7iN!3B;8cBaJ}7m;kr&CV!JRxq2b z6^ea?!ZzYWc-dhS-?#IM35}0PJa(~7zdfaOfy@@7PK=L5Q9*us3lHuMJl?S_C|t>d zp>ZwKL06Xmesne0>!P>wfY5P(Zuud zTv}K-$U0uiGkm@(I?nQ$`eDszL)XrmMBZ3><3Two9yXZik1V*MYhsv%wgUq0l1BDF92BI54qrF&GdzU@Inq>mg)v;m&;Mbw} z+uJsK_zXAjgpvlV&MYdJHb7ZZA0lbt#zQb^^F2PgM?~?Tu@Px4$z4>52zG812LGf< zzit{6UuK{dFK5p3P>9z7@gK#Eg~vTL{xbjvEcRT)z2sRYK-q-aqnbNftQV{q3>Uo4 zL*ATkUmC$HOjUd_jhB?6*IeOYj|mn&Cg~4#2JVB%|Et{lnbNmDruI;LNUCp5m~X`p z!f1T8JXXwe)MuoWG;4Q)Kkken@9)=GxZDOxHDj)?o&aWr9*yM0!)71m0@YO--P{mm zCmgmCp+i|j8mTrf=V!e^C09np``>@09#72Wn*UQaeu!K{jide=i6=aFs;6QT=PrMz zmecs?JmA02XO<~Crkk~OVFt~3`GFl2hCRDLd;9$U@%LHqMh?ZRpGtys+L$bA*3s{p z&VDZtYLQxu>3JVL53x7RN!q=pP2_=cam6QpR0J`_-L|PFMG(R z?gSHhq;#?yK^MA`8FGf->ymvA9i}g)DrKWBp;wSt=JeA|irm<$u+030_!egMM4&? z*)Z$5y5EdF+T6699llMMoCO;USkn++9Vm9b-d?y2WX+523*XoQm0fU~k#( z1E~WJKjRUKnXMl9^+F4PM4G1OyWJNkD|sAC|Egjd0*!NXT!@@*O9U4d+J3;2e&vI@ zTz2hMb9Ed*Z529#2?PCw7c+$?QL+cfS`a&Nrty5OIJskY-U?G)VXC@2e|{SDh})qY zpQ??MliLAuJoVgB^j~FiJO1B+n+qS5Q%a8fxzmLIY=9e}&-?j9wEb6O;=dQ|aAXOP z&i&;969HxoaEL0yo*x36V$hmEylWTp~&-%I&(z5~U9Z+;EE=sX81ts>|U660d1 z%%zw6GRW3dBVUceA88}(#`mFG%|kP*ubX{&%D>OD2l~M=)7zj|V!6Q8KPmt-SE*t6 zMI57&!JJ1!In^l$Gub^X+T+)k`?M-*;s?PKihPEjMFDE{dPKv zrOmXLw4_*(+4UT}_l zWxuz>;`q3+vo1oc&+z=@W}6286t6XwOZ@p#JaQSu#{Bz%8q9GgfBU86QxGWcO>9xB zk$Ss?Iahd{)0CG=Nd{>w#?y%=aj6mi`$p%iXP@`QX4!%tzS7Ct@9pxl(Wo1@5YK|n z4(?{(ltV6a+u2Yfc1d;&`y` zSvoPuyJ0QFx4uuy$dY&TCHiI>OtNc~9d>UE3xNc#ZQ8QB2y*?s1b1PV)A+rml$p&w zeSUu6b|Y>~R4*(oUrbJ{%c5-Hm%P0*gQ|oSr#Sczl(U&dZLNjnW>gj2ticy$?M9JE zEokYnr0HO;!Muh3xO5z{e2bOt{?%mXKh~YX`^jR0&#Jdt7S32Z_a|#YiJ@moCqgyt zujMhzBJ6}*f2UHf2@*1(;nR_Gc&3p$L$-hul`~p?+`~32iN^R3%Fs9BG@c9exoIw_PCzw0V^t2=Loq+zQY6bN_ zUp+B~S=AdyC_i>*3pa{1uB+Wk{rd{9aXdwIA`O*@Eiluv6KMD*0gVoa$%Zn?MbHwA})h7A9YpRekRR#F9=u zU)-`PPP8Q^3*W=OR1DTsb3l2t22>y?+z|{ot-MgrWYf^-muI+^>Mftj+>R+3Og;Gy z3=EWh3py&B-bq+XeYDdElu@pZ9``3+f9yOGP*7Du;2{k-%4?uES1IIRZQb@Hz3^;o zq}?@(PMEX7PCEBN1@?Po&6GtyukN^bPVQj~{Pj8B`%x4O?{br3b1S-_Z1av+{+kZ( z4zWIJdy&&u&GFuB{UJ@WK90`$<4+Nw6b1%7nZc{>vJFe+k|jxXvJZ9gOXhX&9BK9P zjVOG51_k-lzDrfnB+^vGX=qna(iNKA5XW11A;$bXru^hGjfh-9r^M@(&{K}SyO)q7 z7cEJzj#F1C$|wc&^+`-*v`$4jz40TD-6nl6xdnx%N>d!VRT~f?iIG;8l6h29&*+mK zBLwiW+oPid;=N4a0^IbUa+$*249T2PJVSCMpG$=WeStOs41S%IhnzMBg>@UQj?kIQ z*e@Y>B8Ern36-1bvrmsGADihj(MOoT##97A7RYf94tHc@b7&{lodyI+XV|Lr zA2JA5dZ)JSkSQ|tm$j%@GaI<+;VH}ZQ*OGFSvOCpx*()*0nCFqwIFyw4`V6xY-TESLW21s8_>avgPw;fk} zjF?P#ljOGf=2A~bR#ujC2?Tn6*A9dI1l!X zAvg|I71i%O75R1ON3Rjj>`3eRa6o)l+!g zTe#(fuC|}7QXU!pF>{Jc6M~zi@_**D07yqG&aA9RGn>ZE<^wA=DsQ}>&1_obSjf9zJ2dvSdL+N3 z7OqYWXTcYvvrCot=(cm-KTo@}x{4bEEoG<)B3%1iXo z$idU*7R!iQw~uO>5sWEO>X9>+i$27wx$WhK0;S98!m~P=?FE2jUr`m&GmScJuen(4 z481O1S?pS0_r_#}p5`u`<}X~D6Ay>i7wuk~HR*NR)13AJ`OhSZ`%0~kHEm{BflKU) zvRcV|GB6O?LU=dX5bnScHUNwK)`|w$EL~x%TC;Z=- z)T;h9jZq>qHQ(z%*FzN=n_>WB*42w;42`1Uk@Da$o}hUtatHCrd+kkYqPS8xT~)+0 z8*&Ko05Up#+x*5?tFkg{xxTTmhPfcNVl$eYlA@5DIH%i{dW%Lsd=!-|&~1J(RS_PY zXm!kA`Xk0N*GwJywkd+1J`I%rMTMrR)AGFf?V@SsFg%327S}7gYVC4n12CO+9|X|b zsiYBva_Mubh(GfB{_h4f10aV!iX72Ur0$j1n3V-^zNHUgHGWR#T59`KKNY) zxF)(t4s#f=s-f2T&mX=(xU!w)?*piZl@{ZU%39DelV9d&1NzEh(nUo!%5Q{apI()7 z>6;)uKA_3JB#aHJpkNWa1zP}!8BIGKuQL-|*fLKiT5U&hJ*Qx+5%wyDSnGf2rH{Qq zA(**DuoZM-dP4f%2cB+o+rS?7e0H$hpzLI~fb8 z(IB6*nn`%h_M*1lv(9mwNUzrFTv#1Xpj=1BH^CzXi+iXhjx@->U&#Xy^JZL2c4@)- z96I(N;h(-*_6Wgae8ZZK-2a&udW^bb!br;o=PaN>Sv#fm2@>Z050RO3SQ2_QZraKI zR}yOPL_+>PZHb95tk&W_H6~~6W)Ysjedfvv{7Q=vXJy3(=!n0ncKelj**9C1n% zog)Y`L402d_Us?`vkhdc(ZBlA(sJ2-t1#%I?(h@o>%}e4%Yy+-P4IqT?qT1ZppAT) z>tdzjGOrQU&S|Da3%=D#+ZXX;>{i+RbJ51u)wd~J*(f8{f@<*$*^v0ZeCX^obL{Tk zz>5J2Wt1POfsa2vU_nsF@aEy*RpIk1W87Fnk|@%G$P`z-F*fH3jRX7&_bA9u6u|DJ zAH_xpw~&l!MqgN9=z9LcI?AUkhOJK|`yfQ>%u?4xF>CEkjy#O*Ssa(Ku`I}+~xudd#awiW1z{u&u8M9B+`2UGDiF*Jb2T(Gws9BO&c9l_f zFX3Cqmr2>^y^ji*da36Tc6|V`k64uefsZ$M%uc`9K)m~^H!AcH`C`?GeWylx zswzOjp~Bh?Eyl;Z=q8?5O8s9uBMD-^k_l9lg=twg?JWX@>}+mXmEkR-6$MOck{;31 z)82ZZGkx<+>j%@dwi}r;$cdZ8`Cj<9G{ed_)vKuT4ahCh4Q@@28`u%LIS0&Wl1CR)^pD^POG{ZhBtrNB@+r+eOWnxSodGS$aDvMZS_y zcsW#a`E#V>Y*1URVRozh=ECVVos-4u`J>)rC?_0@ZJfIp%*yNA8eYk_YMT1L$E0n6 zHsU^GOSt-EaF}pLRc}ywzloMxtLGd4?YybH=TAv}z@$)cRP&n8jWO=Ww>W&5E9q1| zibrY-X2(kYC*A9E#9&nPDK1vs4Hp+Ho$EJ_qc8H9m3_XVQW1KO;XC9HM9Y-1yJ5re z7s^Xdf9IeTOMi4(q)S!A9Tq@9A2WqJAC*R?6~ENMc!v1AuAMz3hP=k#)Lht6<1S6p zcF)C8MZ7MI{~XOwk(TF^{hBS>;8$P*6;|gHY=7FmleO@up$`J3Nnle|J(!R9>e+fY z5BpNA=Y9~(yb6iW&`+0(bei{@&yh{K@pk^kLzxotZuSBsHe>QT3oq#Il7+VGvmR1Q zvX>ArKj#8#5HaS08d|GW6ah@75kR; zsDqI-C6D@Uv5u<822t@n!%+wXiPGzTX*vpeF^4 zX7TannH~9(P!IXrhY^u@_C%q%7_(n_@F~Emr@q14{g?(wG*$KTzO{{Nbuxp!07ty4 zxJ$3~D8bUaYT5b-q!3g9Dr*(o(%C(AXEwA5h$tsbq-Sap#apQThjp3BOEquva*M;J zHM&9{x&CG^YCYcRJloJlnz2F%&vtP!1|(U;*6{Md60-13K4f#vp-d@NVU?pW7Y*X67Ll4-(n*yr$!ShL+{XJIRqA;_+BK8TKJ zjrl=$U(uCwxTUXYZt}3@W=NgITLNGxGf}%ko+!H(b9J)&8c>Owo5dWohk-sH`1qq& zsgfLO+{)U|BS;${6M3&mUVz;`A9jeVGE^hkpg}GTW=6qT&uLxwQCK{rY^Rs&Zm!x4 zKOjvhC>Yv|vg^2^G5CX4VEjii*_{k#USH~8d}GzuONQC)&k%PYuzWu((~>Zc6i-SW z7`f%o*cN)Q8Q1c?aW?Udo>XvO)@+k!uWXu8Q!cGg*AH3R+@cC)ZhC1o(DyLx@_>Pn z_~!~!`VX%*d|U%phlUT_fBRADNpgM1m3y zQ;Tl+#`+--DC)CaY4@{P#RM>h-ypB6otTN_=GCQ|Z|PRQWeH3C>|x9v84zpv;G=KO zGAcb-NFz|-%asOnjHw3p!Pgc_6IGh)c2hNsIqqU7?+!&ZI4ayX4!L*YYlw& z?`YO5h0c6RMt?-K%lhln*DkvPTRgV%S5Qi#$SQ#*O? zO)|4{o>H*P9}!iD&(*XBZZI$iyOuP_N5)I_S-_7r-KV@vTTrCv(tM(@Rf3hOAfJBn zm(6ESk4p>^#smMNo><&NN>+PSqr^O(1a`QN-ED}UY_12rt>M+&o7bQBM2mjWk-I7R zLO1>7+mPbhnW>z}hS@^^eWP@JOG$`(^>9iF84z6=Th2@6koZEbRJOpTfDU|fpRJh3 z2XFca^u;RewE?^_U{rpBrBa*_@i4zx%*};T2-c;#p*m zY$YwL8mOce1hnc(>jh+L`2f6H>#2FjQWSmUG9y&{JkEk%i=+EK!TpjbY@vz8j zfz)Y-o08a!KNmn#qI$14&gqsETyK1tll6zAAUMezYtc}k_-+?ivY7&+e3uo=sd?x1 z%JtFl^_J50*1{$0^~Rf!>mS$m-z#3N6$73@GQ#iV>*ZwIyu#2$OySv~(tg?1FRt^b zCfj$5ucVb^<%h&R)-~7jQL{vcSa240?EXxs+xhUio4c;<_4RiClO~|(zF!uhK6-=h z0=pAhJGbe1kLwt0i>`^F1I3TqD;rFIj$P)A@#c)V$3uUY5TsR%4e7AZt+i9zQMe@} zV6pzgi9O6r9M4`%1+(G)FT}ISicEQSII*t037I<)NX0Z>KJ$>6w~<7z+4aXsH2ihn zO&z_9R7`3o5LyiN3%GB5Fds95Uq4<|lQ@#3F~ z*uX?4USy`?Q>aYcpHe_G^0ERHZK)rVE^|)F5U?ye-H+X4=KSICOJHa2m;_dQ(y5tV zQ(ZIis0N^cbWw5z=V?pf@87>4bSAMQ?Qt?7dU?Wi-dlKmK~UVEFPjfI>}4f*^piIK zTmAbxE{onC$1RQkXGp>>4u{XuM}qC7ZCCAX@iFkGR>+|<_JKATtVu`9m)|Qhk1_zY zYi!twBW8}HrtNI)!XDok`765b32UDDLwSjQHiCz-kCv-ief$T`Xq#F8vj_N;4+JA^ zr&XhaN;M|rs3iep_=T7IgL>rFQR(vEpvMmBL3PQ$NU*%GMX+hpL5rvnKBCeD;bV5c z-6*od?NQm~O}+21Ieui8@6(He^>Nz^WTC`P$2rBbhafQ|BmvuJWd(~{daMp%E9NxY zI^CS8SH_)gUbuq!k{ApmDLzmgE}CyIGkLx&6AK^XBM-yOyXkF_7w}GNA7edPGV<&J zJGsK^KIUrcsw1`7tiPJksKfczRt@dS^`ouv*<-!Ve=9Xnf8gj1rJvfPmQa*&nIZTt z1x+>^B?-1Xbwy3iJPUc8D#Tr@tF`Ye+slj&rbdbS$%FZE~)#vfB$Gce|9H$b*q)~ zhtk!)(*C&8F&q$qJ9no?f;P%R{`M5t*a$bYITp)%))5d zm3QZ>21!awNT;_N1uJA_8YyY7DQB? z);oKrMD#?u&d`fq$H1YFAgVhPf{`xyPM&&lKga(BrLtiDPWdiVhY5lJ(T~v(0oAGV zXleBLASs#3q?rZ($u$pz($Eo(BE53waHYN8&`Yl?4?!#}S1?Q4Y{{^KSw-uG_#tW; zErhLR`4R9$NQyXQqOt)paEtTy#mjg=6UWIy5KtvxQuG!rn3Q4prhQp^(Jfkem;p=G%EBq-^+UqN-^vfJDl|s?6s&$N(HXx*V|8rG4=S3t7lW~^KyiUpWjT55+d z5^mS}XyqJJbK_zT57PrK6GuR)bRqa*^dYF;S{Rm|R&2(N%qVpg&_W!~WYX(OsvdU3 zY;v_#RPSx*zrWQ-{o^I*wvjr$BwASKkNt2DM)1!uPGaittnUtUHLpUUNKR?KBr~s5 z;V&rFxwisDBsp^*;xijrc==)EaYcWI+&AY#n`b|cTa7V)~+9)M} zSClba%td(gn?FW3*XJb!)^Kf9A0f#UY$pW#Y-Ps1q7Nr7wnyU-?zwF3b>{X zYANzUqfU`B3uh5EA%}nee%6=k|0SI$=9wxGZb6i+sqn}Rn6&*!2dcKGRK z%|(~Jn3AmQ1+Y!+s-2r_4m|Jh@1ZK)a&P*RNAwiHred)8h?O>v9H0RBi3Vj=g!=JV zgvKEGsI{khKr(PXE9p0HpQ)^v@v$OJ6F_%2fP*y8$iAArIgB(=^37y=LK2sWdN>Fd zL*HG!5I>P;B$G?@wJrVn==nFuK9HE=fy(6WRKX5DiLu|iL+Q7;@x?OI&#LlFJMlDU zxOLb`R}K7J++qG55#G`ReY8Qw3-!#OpYgsw5z?>0>N{klKMO`4{l^VV3DsdG5vp6^ z^odlW1e@_;lCdYDL2oA{Z5$gP-qL6u9Cq5@yttU(8#_IeV$T^_rA&GmuouN@+eXK| z%yICN#A%|zg_gRqg1X?^NTX^{Ah%IGbak%DOM2ET=)9GXnF%fvU?tdI_Q@z+H;-^~ zq0%JD`hL2r%zNYHNvejt+Rg@x1(LlUJ)-yK?B5!FhGwDo?lvM#gzN;NS2i~4z4cg4 zs#g9TESIEJR)%kRQU)$y;GUm{N!Y4l?P79l!b{8>;2p%(85^owcb^oB6nJ@MoyuVf zV*dir7C1m=1BHdP-O_v;9E`4@!1^d1J%0aB>cB{=qB(8FUheiKaNYO_|NJMyJ42mX zdoU_HmmV)8gPbUJRkw(F+&7bYi>J@~O@O!4ci_J*Z8BA~kNh(jAE@TC$C0LJR}bOz zG##C{nBsYg^g698eU(Gwp}IxMCF+An(d^!Y^C%rkuKyA<;;! zwVI5HmM77hDe+vL7ivHcvS2#Wt*6sOA>H^rsdT3ryC|K}$9D66^vuB??HxC}s2{N4H-V)QPU6E`aUgV**+s^6|u&hz55 zF1bmMDYsqITZI4lJ5!Gl!+Y$!k0La;ZZ~Dl+W@fG;{xPx$0T1&ngoAJA@Uc1d{qM4 z)U-@JDu5;NYK);L zBU!pi%s0LjovFwZhjDLYfE_Ev-7rjVdKl@#pd|v+`T$QmD!cJ<{;*}|kl4DWZLvk< zs=zVmo93LoNM-)elVre#sPMNwayzKoY`rCE>Lx}U+x?DJnN zEKd?qydW}er$hTa+{kvny#ZwFY85x|@b|Fw7`KBk6P>|}N9Gjd&hJex;zlqVf*@rl zpYl2l<#FfA4=ZVq_j`*C?k*nM&g=pgKiyQd5RZFV@_PWv=Bo$R_3-haj1|jj#heUs zkSH#LTpa)469MqQTY3YyU(pkq8`bAs5AN1rE(^U6Zm#23-)sZL(S=YVhX57skiqjI z214nk>0>7&zNDt_i{13iB*S5{a-2)g#Uku=6lC>p$AX2_W+FG26ux|O-rn)YSKJi1 zFM6z=B%7uUH!fY_7A}qIjGR%L2KTn5-eWQLi@wbax7D{-4mVM42e(&i%d4kA){==M z4K6{MWIZZ`p+`^E3)j0}=DMcc&p21OiNg0BI zMHwyz>F=Vsg#j^)U9+e>{GWyGkaKxKetUO!_v*8l%Xv#*-)Z!2^L9$nRWfc%l2hS? zoRGV}QLUOP9%zBLwzr&e!#1Qm5Hv(&pf3`3#&tCga)3fLbO8H&-0s8M5{!OXfmDOl zJ=Peg7ou0x{_Ce{S0L_)(;mPrdTv+an;EniR!P~ul7gKvPjEm2)k;eEerk&5YRiC{ zIX;COJ!B~;L_~UIUnm4H4j;JLDX;!~>?VeTRc$SyzQf)z!&OoXu-`P~4cM?LCti=? z5A6JY3m_d^4{jVAG#@^M6l8!7j?dPtp;}R~jN~UgG7Y|RSdeNg0Z^*0x_;Rn2~r&; zqt%g(A@UZLjjlYoskl996Gs4phwt)H_T_?mE7Hk#SB``52`=r>vikDc|GMe+Z`(!1 z?ViKc#_!&|$z`gx+vSbJu{t}P6k&$3u}njwz10g)pyLbLyU|HJ@SSEdOsu$!m1^Fq zKcb&O1!`ZL?!=twt}HJ*?_-aeMUrtQ|Mx={{M)&FyV1TC{EI&CRqd{L!5y2w=@6L9 zW$E`H*snGXJpiUIp&1EGDB6KWt_t@-U*GQojzL>m;=2M8+ty99g&A6W4cJ;cfawGu zP#u5&;QmA!LH6Yx+Jv95$4TGbg^#xfask>K0Nl;bctg5ikm9J}9m4c!-p*k;cdUoBL6aNF&Jj` z4@c_K9WbR+b7LzTGn=P)7s zLdp8+7jsSo;;=RAlb+>&;WAR{AeH&C+bjX)Q1j0()`8n+|LdvSp_V3hK7M{RH3$FI z(nS{lMFEA!RrszN`r{q0{cnfyK%Mu^xc^O{I*UnnGm9D9PTKy~wS-e7skLwXa~Uz2 zI_ytn)DwLP2c~yFVKwmgz(})h)-J}x34aV)gAqLG(P&RGSH=yZb$2s8vcuz#w4qf$ z*}ZWX-}@^JJ<#=Bya#E!X9Xh%>r4j66l3qa`M^WaFU>w+$V19q=kUa;c{~U?(ZL97f>@cegvJruH|J_pT zjdNe;<)OONj8V5r`zwc^$OGXBkxHzlzz+Te)r-zRdrUwBo z1GFgozoRskqJ#BzPcTR`A=^Pt_KT5$*2E;wf1wK`h#?{}BKF+hMAIR6U$t}OxJwu6 z*CgHfYq?vpZEJzQo<2XftcwtP%&YfIsTVX!=yTBPcf3c=KhaTtN&-CiBU>4YfdijYbn^eRwCj&3sdTReGFToH&#dajyJb2qi>4SX|s zwg8*s54@Y7G!s-vW+D}fVXR0kT!=>}n9qU3B%P=i%x?vZD7+mLYSOYK9ta@>@pp4Pq`G@d%%SX^*t(IB@oQI?*Enc<+$vtN~7|KU`$N zPDD(>cUC_rvR!82;0GK9;xy_Y*B;#Fy8K$aU6sWVcP13g630$+=+g& z=vX8{N8q|#P-Zp2QzLpz1bCFn5G~bF?wH@rHo6h(=?=|KUS5aCJMY8j>+G}rwsu7< z7c9RVMexYUc4U}^<*|iPnxKYPf40=`REVs4X_>tQ<%kU{@6VAGnNwTB|K_Q)Tw&VI zb{Ef@{P9;SR~H*zl|~7$MgQB&TYTGzX?A8N57mUkOHD16)4fHR7T4`HEUN9_;x&2O zjg{Z}{{CO!q?gJb&S-x|B_k;%#%OP!GD ztI65TaDV+wMqV4)FIJk{4kP(-2-f+aexOyd{hm9Viy#TCkb3`iZYhLsgW`jD2-$Wg^2p$kK0okCP)NF%4(W-~-NuMX(c= z^M1~_8wX5f7L`Vmxo)C}GTN_3-}b%<{Z}_(76!z5t*R&u6yBEjKBCg+VR}br6i!XA zke{@cmK2QWUus6ShkQUx@8oU;hw_rgKo`WthioP~Dp}#>cR{_adWCOP4iYcx7Cl!J z@qVWvgOepOM$TJWG7>2ytS&dhgiWn7`n+Z6V(ceod$(B*{H{EY6IvH8&?65T;A27F zleWG|fAtT$Z2Kr4X-}_2lrcirV19DOQm$J-ch?oFM}3HKP8^t&s0io7mAPXX;@^?6 z2Q6*Ue542oA$iuT)LR;em-g5PSX384g6rJQU%~0PeHH|nGhO`5&*zq`7qR+qsRvdkn4O0ObFlS_0y4A{{3{;t8C8{GqMfglCYv#%8Y zu_0-DVAY{JZie!!ff`-ppwJ1;c+#9c(H*0|p-)ji_~uvWug#WQ2A>opv^Z#n%lGJf zNjMZezIRTYM3d)~)#qeEW1prg!3!_LA}v=Sl~B6V1S0f1p4BNv@=yb0?emAZnU0_w zNmbB?g8E7|r7CG5d2EAhxJ|KwtzEhzDM349RnJdS0q=LI>wBAT$BF1f+Zw+-Z!%Wz z`5?iuosTuWNo5^Diw0$I7;Km6@ekTI$aQ5n%TRE4r89*E#FV1H=tPt>Ajx_INB*7ni^drzamZ0&cT$l#rjm=ReTxS3qDRB*!@EosCT z6CF*e`mn>YDLq&*5buzg)q1mm;P_Rxt-D|^a_z-5eO5@eeN-LOb`WKSzx{Z0_Esc& zgp#VMb8=_@S8bH0WR5P)28_atv|ZF4z3q=*^j*haWiHOyxrdO1yaCS9ekIfEnJE@Y zpC9pLc%Sl<>26PMi>6(`;vJ6-Nyg8g2Lq08;3BLtd?#(O<-YoQGxg5%U$&VXtRub)vsUGrJJ2Wz_YEZhMAXD_Xi?$v^bd z63tj{pyCI--bx_IC;}z`C?&4BwmMXR!jM{*p$~q_`7!p1n6|nToz#CLQy>r?A_A`;%Q4O zYuALb`7Mc$@}6yLvPw!x_?$1CcUxEpN6?bStp4kjIo)f*H=pdU#>_7D@T!gj5DP}y zA!~|VVUea2o&AMi4E!A__74h3T25nf^E`8AteHO|@lZJrHe=7}o-spS-NzBMQ1pI!f&C zeN$xW0H>g-Gk1+{z;XTm@c)T#%R9BU&nGXH24kF3CGZ$wkVxn{~K~cb`X3OIqv_$c^Qt4d0P4{h zGLf2HceM7luT10+Z9tG%ku_?ZFeo_i)r_ID1*iFj(~gz#Gl=Rw0FpBYx-e1?-OvZa z3k|jD>j_}?p1vUnU?)n$(l?oy8o=)gB+K_$iw&d0e>%?FPhlE<*AUh>H1NHe)qI)U z+}cXi^(_M3ie;3;rR&N*J-?5DRM46$RESz3`%BV6UlOWaDI+<`%3tZ^by1fg({PE-58A)Q6W4(QG#-MhDL11kdHb1MFmBa+Q`RrYj^YOgM_PUA z)-;jqbb^LG^M&L<$nAX2|D-=KaC*mUg6(-l;9b_kKMq=qv$bN9qW4-Khj8{SoF@SA zXwrZl$hYd+(43`&#r8=9L)#axN?!s+Pt{9mx;&hM`I7lUh`?v*D#Z{`zEYrB9Dv8| zHdsZyC>Wxv4(trHiW#}^6N%1)(uJ0xBhEjcT$GkhuB>?Z-p;#Ysj_bN(-)gX{oR~A z&$Y6>PuJFysZ2b+XO18+4-v{bVBW86F1xBSCXy1DdbhjyjO;=ZW;RTL4QXIAqYk9X5YNblgJ<`wfk7(Y7A^6v2aSKV=YduiCo8${o%?*Fp{ zfvD~}u^?spp=?kil_O&OOcCcvm-pyxhrJ4%D=$&g!`2(|rE+dDn*r%4X*^*U_Ce&t z<31Y?7i& zlTSJKN$c$5uVhgO1vWuC>d49AD#|Y*fzAecc5Ay;^1tK-F=fGh>D%!N^SoOyZp|PC zT^Bo?*k@HNjT}~q&m8;L?8EyarNP1~?9ii#s=g45O60{c|IN-wi}&T;&EA59%-OBk%YdYgTN9l{#3sfe2gJy0nHLhC|!xHXWg~hX$ z?(Q;!NLIMzu#2>oyh5DJ<&IXTvm@@`{K-jw+i7Xr+3`d5##+?2N+Wjd?m7cV*_`hy z?@5by(?H6I|MECMG`Z<9Ysh%P69yG5fek5Hv=0qfs!$7x;%Cd>AzJC>81r0>lUG!0 zG=8tX(DV>SHAj%SPn13j)9!dry;Y02WE-F-=#r@Gn2V+QZnKrkT%#H#ANZP`S)T1I zcHmhi)i*VL;Y%>1QQKXq_Z)RIcC=r04e#xU4% zb;-j_2mcYmGAgBzxa2|4*$HO&tWe$oEGcm)@?LNO@4YanT~2HQDSQMjp<({zRT^Aq z=J}T7ehybp-goE>xg)8H))(Oy-EYk8-dmzTOsJG@PZ5Tw!R8;^>mPlqgbk`a_ZsbS zbH&?J<&Rk(-w8N%LRK?V5m!(1LUbcGS%IRm1`kUmFsuK@W{ls80)#g6jwQ|B;Nz|; zF3alkwrh9)Gpq!jnVC7lG07tE;BQDp8@jHJ^}k2IP$*O(Vz&IwPokfd(%cd4=0XjQ z8$%UWJ9;dBXJ1CGn^RPec=^%JiRpO$s*~Zoz-!>uue!%R*W|Q%=3)CaZ`H!pQPSd5 zSO)F%@0s5d`KT`I&$ULv@=k4D{45Vzfu12o=`BU{S0(s`4bY=#}tvVPkEC4e~Lklpn7db)ATa zi>11qL+f>tHOePJB18%2R{P)%DG}G8ji~N--@UMqMSuLo!2#4d-T3~hGRq~oAgkMH=c_)JN3UvfYmqUk3Ve{o=%B;iU9i47aGG%c>E$As%ecdH zQ}|2iw@B7!E(O*1&EnwKyN|gan{g7UrYU<*5wQRJZ2MSFKzMw7vyxOW z0;?N1id|Dkt=CXF=j2`M^VH=ub3B$#Gy0)^_uc{mPNT8U{$lnU@k4@$+`o-(4My~K zAn1rQE&~cn$#V|^@oLCH>U zFgjYSC47$?f_TAgJKIAd!$pi~;Jg#oLD~_=p_KPN2po(=qy}o-LqF}v*C(>1QHXV6 zR^eu(`g+d>DO3`ejd{f7vw0=3p@1{a5&sSivkbQhczgI(bCq z=t>Dr+P|3As==j6H@*D%qRI3+FWG-z_+F@RuUGO`f6O$J4$B zwxMd&;9p29H|dLs_|uq}cX@%Qc~!@}j838^c%voyJ0I=s3}_;{$6VE9 zArbj?Qez)KxY(5@ghN`lL%D)-5}tm1?Syf)OBbG1or0wsq}#xQx;=|R2?cxTTc%Xp z?FcN=yxV?S~wjI>u9t~XR#m1 zpuHP6HY3g|FDfV=^m}4ZyDUOElJadZeVOor#KiNvOV11B&Aur@G#GX7aX}IveT@~L z62pfkoT@#HMT7btJPCJ?PSsO=|BlpC!*Q(^QQ8PqRKlf9yN+Jw)C^SbEbP68(o1@r z8+~NKolm4p`#3f0Gh?N$zgmr>@Y1&wzJt0z43GcfCSIg?v%}U`y8^cB3 zf!t$u`n-quY#KI=NB_wQ!N|i$pFSzb2_?^>P4s+S&H<-WeMZgRQpyoXdWU4uj^bT( zCHbDU@SR^;Oiby9hOq6%5|X?F#}Ds`JzMnolF19uLBHL0KMH`T6(vQ*Idh`Wu6lK2 z-n+qC0e66IiI#s&ZMP(k_>3zR{;k&e&xxsrdYaI8qP8>K2iLzD2v3O|N{Ya|7mXWT z4)R81PP^h`b{FQa{{hv)*qFtv5!~vi`AY^SyN$_QI~e7s+og|YHu~n$RqlHuH#er#wh z{UA-m_P1A`_ap|>}T@n1vhg+rImn69^hWrE0y#ED5!0VDE zl&J&pu+oufI_2+``P#>yMqL$cR1<9GpHmSAEy*VtKVzF=gsx?jruFEP%K}rVKp&u~ z)L|IlBnpK!fMu{85pT%4np$m@?RN~F5(0g!sa2fRiPv-^pcZ|wY-SMG|8%xUd6V_} zZ&b_mb>BkPZT=SSjdI$_5z>{*1up8Mxy(`SUfQ-y#RW&$U4;KT-McKGRJ>C{|DV7!r7CnS&|@m% zIv(grlyMq75YHPDNF}Gil$Q|Qj>rv?S7VVu2%=m;Wb9Rr9k1%_hPkaMwGw!r3B2w= zk;zWEVpH<(s4COh39*agcswC@#IlAauPANfeOaF8)8E09+HfvGp#{rO)8NTtn z4DGDcQS;-oJgSlCXpyOBY|b@5{P#Oq8-8DJINYSKI~Uhvy;sckJ^*ra@&5Km-(A+L z@kO7*;%6r3%S;Oiu80j?mJu}j4gBcyry8Vk@|Au zW}!Uhb>I_ib$}yx;W1YmHR&ZpKi7ohAFQUa;Q7yn^71;lt)a=?(b47jf=OmZfALtF zQmJz|YB*iQ0jnDCaU8#FEy;Wa_Q9!T&s+=@$rOJ85@(3&O&96xJF>=8J@k1eGv-xB zd^xA+RNlMZAuAfv2YTS(rtwovl_U2_o zISXnkzrL|`Pvka2T?CbIyD%tPVAuy#oraR;|xGG!h|J7S^`YzPfWkPEDTw_P~#NGC1BZ@ z?>tAfoQ5TGLBFBzy$xugQb7=DC#=o=ZV>h)eG^J5N>4HF_@2kECYt#ry!P1}ny24U z!>`*Wz)qV>N{dQLdv6?XZ0PFfc~^6A0L{q>o;IP{ZuR=P5V&1vSpppy z>ZsvWXO%LSQ5D_W?${L}Pf^|}lEBrHD&gv$#@ZK=eAQ)JS5@UofzAk|)HKhlk`vT! z?$96vpY{*4JIiOBY7`UdpGp9o4(b4qbiJo6Q;I`SD zOWkx~k=w`2H+q)ZNqj2bTK|U?7F4&Y#${jjCAS_2kD4C>hEw~+7D1^jtj=Q}Bfdv* zo|hADNd{95Wn5;Ff$hI+nF3e;Hqzr4^p9~+t--JVqq zm6r38AQb30^gg_jriW0ILEl;=j;WA5?Z5=bH`_;lyHXsIQ(1jZsZZTQ4|kcdrY6NC zuYZuQ8WP8Al}Bn0NRY58mNwc)IRADv)|$#Y=$jdG1za|tO++G=nuvaaHwsPU#PnYS zbrM#`zZl1BqNp-4WyP!aaxfX(7G(zu9PI3(iv|1kQ)n2|mp^C}BgL3s^b=c6W87uD z?PnEEwU0P@F%MQV@LPj7G;)#bKjjsk-(8THvQZTtgv4Ws@1~U>whJQ1(GQo2XjGyf z!jFFp34fg~t!Nwfw8BpDnYJAEn%-{gwSPyJmu#1m97$e=WuE~+`kfKU{iQoQzOS;` zHNak5{k$c;YQ})0 zjL`D})jcUgjMV$=-=KU%LIW^(P597rSHA}YA4i3xFFt8}Myb*VycX(MK5((?&8{4P zNu-Y~Hn_d8%QT$Y&5%Gv#T6=S6tVjLk-V-4ge@g)qUMwre#aXUQwwbiZo5cU&sw+n zy>@CF6{Hm>V{#{{*-P)A{`<#T>VA8I**AGIR~JqAmh2=+OoJppNZ!Wcw-+wh6AphP z8y#)M9z=gt&zq)@$?)oV!~vU(@5avP&CP{*GxBCGF@(kMxKgS0Ux@#q`c1zKYcz4) z9^Rp>(_9kf*4jtGy%If(S@$#%DYs91ZNO;PZ3awnnsj31mzaVfufH6fa-Qn0s0E1%G$BpF zdpX@Flcm@s?UxcZMEmV`@przD62EGhMzPVC1qm@twykKN2?j z*neUzUos?n`oZ0AXHbT&QzlKw+tb#6R*PphJg28rsL{@LM@dHi^P*OOvb~(#@!8}t zD}eCePYGxlL7g8*jI%-w#REjF+Tp z-O6C48o($K{Zt%zUMDLfKf(PW@e65c0}?P+pJ;HiMHJo%i0b#4u4N_!**laOqGn1S zM-mS5hhrOfua>VOKgqzVcaO~@p_uhh4C($ z{((nS%AOinyG(0{{4c1GY9{U7LT1A*XMrDd3gpO zev>FIL{{aNCx5x4QKFEDU2Cc9vF30!w!yccQxQxBI)yJmYhdMKN&fgI9!2TbBi&$$ zsf~3uG5!{Js65CKV_J5D$P)9ELEB|9CfmIeJ#^k=MByi=soaYHsc*!dC2Pj$pN1u zCVH*h zbgiEk{CaQCUPmOKi|42}+sDVxLvrBgcjNqg3mBgDi0uRA0J2EU8<&vFwK5}I{Z%(Z z70g8(ywg9ODVT^iIiF$e0h5`-;XDE|$9;iy<))^*8!^%seRZuzQl(|1(>Lw*NfyAT zYNna1MmYWeHeNG6ePCvQs&I(J_SzXYbwloi1-a^M9(Zg|V8^LsT+TbD^0HmLW4w z`8=jb>;u)Pk3w^5Y`1MiO$3Fv--yC_dsiE`n@`0L4v!>x1_>ZNOSID|hy7qJKR`wh zOV%Y&P9b~?CtP~U`lgEg?SO}Tfs0Bw8rzc+N|s?gLf8-aLEQQM=h;shBD*r-;^U%a z$1dm)u9x?L59bF9G3#{Qpm+BjnvvD^5|292+jWYaqk`%(jZPO6Q*GzhtJ8C?_U;Z2 z$fF6Td3#kw>91Xrit<7X61t409yh6m?x)d3gBqzQ)t)+_NqBpAW1wKksvBcfq zzHf=ket(Kw?uTOc@Q4y`4I`e9=9t zYa)cyVFcQ>p0?W?!-hGVhW;0syHQaVI*&a4_J$f_{0~6tm+W1mts2Gk}VKraNW6-bv)1 z1P=Q&-nM~8c3Gb$_?@6^*wPys;)G%JNi&yLk0Qx&78~3GU8r~?Nl+He3(}g#k*|?z?Nky{e>1K=46nTK3>S#yZ)=# z%(i1h+y2X_xoM2;`Z^!ZjI#y$>&_

GKkG*_}fx` z6P@ESPZ6u5`l*JOT5+Ph_o5sn)8ny9B$uvQrC!s_rTuwSQy5f?lJMY{Zbm_R3ReFe z>e47g6AN2`z+@)(wM19F@76?-^ks2+-Jay-jv!My)2aQu@uEI!bg#PP`PsHZv){>K zVlJ2e)xX)WI6Ap{_giUX%J7DFNC;bf84v!=IM5y#y!*ogI2WuinN2RsJDpUWe;-FDzu?38JS4S)RC-Q|`()K~ckFKQ$4FqI7X$J-_0-arL24#rW`cs1mLP zD;^~K+c1w>LqDz1k}mJWTF55dOE??IqTuLd*~YgzFxb(9<41mbJhum?x8!&7B)+!K zYz)r=H32QNZhV7P5syYBVyKLyr~EfS+?F)#=kDu6shOLf5B9vLsA4EbGFu$?=FX>m z{5)~T9C~~=%h`T!7~&HHGf&W`_6yC9mc6hS`OHXrm-4>ezS0yCqM=9=UD}`eC<9J; zz9XNPjB=5uR^$aPsp~4ERfw^={f-S-Pb;&Un;V7*9}@UQ>EfVi>S;V0iNpLqXHB2{ zq?a^dMf1U~fC00Xc`hR*v0?gl-ttHH+mHVKcn((I9^}~#m5JAB<|%D4AbN$qFY1lx zekqTa`=Elx09yvebtn>&H)kS_+~UbTU#qz8(38HzN#6n@f{D1VN*K{pLpfYln~~PZ zIAU8i?j^&2p`kik*xe>$9eCD!gZut|_d8f|A4Nb%CAr2!?mR-|?D@G*37R7v`UcOOkxm`kT`^OX^ z1#UIVhK!=meCg|b=qOE?DB*vn#v0LPp}Y^|lqEmGBfZYPKTE`RfBXfJ zPZ>j#YH%O69%bHnjq=V((;R4^)bH6&hcTgEQHT9R@_8iFjP~ejihE?6<;J&5<|`@0 z=_Ux!DrmscpV_%Pw?E7sjuarl=bf^9#QqV!52Ep`Z1KGa3t^S47HjZy#|OtqA9ZyL z>Wpu-_pK?+x?VPgXsj*mTf^;!6QyPb%jd3o^k62 zbY{)o4yQ$J`^8wa!_`?Pc=ac+>F4faFTA!hzNJtbC@6mVD6grzP4rD)7$r9pMHaWX zI4@D%Al;9QY!_sGVN28(5lN77)Y-li#$%OvgyS>8MC78jb_I(hmOAqQU1L^8^lUTx zN-k?UC=0Je>5bqw8_=LxrUyHHpqrgF1bSosc)avHS=T#tJAD+fW$i^Hmx8g(+@6la z1Q*&oG_kGJ_rmdINL`y=7u~j-ULUm`&k4pjlt+CKbuq^Is?^KL6Vgn5p?dINSE#)C zeXefk;ofngo{5a_=A6gnF>2dgF_3_rOpsmp3rQ;blJLL*uob!*^Y^Th5q>U@@{N@4 zA=0?dUTd5_rk((?WanhPvKD2GR?I}SxGR=Q3U+9|(uvgIu6p{W>)gl7@uw-x`e(14 zsQxNuaN?M%ah)nE0aNx$k^d1AA;{`udBi&ye_!CWTvX+^_Yv~ELvg7M{Oi=3d$hr^ z|IO<}GPev?MLv0s8oLnhrnH7%N{Xsv=_xmr^ihK}KdN0|IrYmlpARA}YLLd7;m^{I zri9t#RHn;9q08^sQw5@+d81tX1DS$W>Y36K%oTR_pD9TS_`-PyxjdXq4SY8vhVi~;zC$$efxUzzCaCA2Wkc)8T4Ik&56H)In~3yhf<5?fWBidCQt1# zj|S)dI;E2J&M~rzo+$7)?~khc+>Cn(`QSHB0t2OpQZbfRBfD25x67uS1&V>rqFfL` zo50e5Vw%U^W2Oa9;3{vX=l=YGE$W*ZUUpeY+e8`g0zMPfr}4{<`ha-n>N+W;y4_g5 zJu~WY%zAy&cC~mlb$fbyb9>@{c{Z-TrWg!{;erc_ioxvMG^9tK?vhlhozVZret`{1 zh(^!7YX2X7_yQ4a}OfU(+;`1>%QQN-#zuA7%Y)X z7~aiPVr`srkF^P#>6B+X&vUO8X$qZkSfDHy6o1oE)yFDYchq-B&(!}i6FkjmX!-A& zY0CfLQR~=H;jeb<$p#FVF`@A2ZBygt%!tZ9LE;R)c~QsH+rnQUr2`v=%+m6XN{?GmuiAYPuA2mqrqV0sBO zQt{tz@%ybKvzwildAok=AZ6Lc`dRKW2ODH0)5GU`eu4hdy^GErgxoDvzaR>Yz z`cz6`JN`br?8t=YE-AB5Xz<$}F#0L{Ylp6cIWqPCPZC12ZFk_x?*0 z8*|`rc>jzSVebrYMalDnDI40V%q3|u#q`AH zz@fm`2?5t!OM2x6*RLJ^CTqVid%YGhV%q4z&Q%K3oZm10dkx&>*;jwAGaty#9Ck9(sI6^N7Z_M!4#2~rZ0)>Z=|BT4`Mu7Mp$dukmeHdN*VIGM5u@oyl)1!831% zu_V9W9E#=3DN`S_&&mB@@z`%?nsQ)fVS%jd?Ukd63EIxiN3PCIFW1}tCHw7KwO!$| zd@nXDCkM}Kkd)}xkkHpD-L5;n7qe#L&fB7j+Fk5jSex|Cp#V+NUztilKw;cr%z`tF zKZzD}pX3cO0r*j8jjc0WAB?s)cK7jEg8zKlm>nU`z${FmyI!p{y z(#-Wb-`@5U<|Ti}j6VGRN*;LSW3!)6eR9f#@E}Ju@^M70igc#5y{^r$jX)Vqn0&PI z^Ao9=6v+`1dUbITC8_k-CH;w zEiIB=A=$aoR?d&|iCmSu98zKd&~S_AE9!pB!RnH?v$q?I3v=_Ck6D@Lnl8J}Gx2!5 zNW?h?i$!gV$Wh3gJTy&PXp=C+!Vp!3KN*Q&J>z)~f*kOV^Iv&?{2gv-=ks^E=uz}S zn`g`JoZyJ$3FcDdcFO-SruHfAEC@6%`d0q6w#<245vAK{Rr^x4>CISX-2$Zs;o#yG zlmE$1>TtJN>c%^=xKvWx`Vyb96`D|Go7bR!Ah5CHLd1R|37`MwpsCOKCBM{B7aSNL zJZ{)#Q`AsN9~&`V#cxqI`}eA zH5?ZP4HrQ24lX-GIW*;Mr6r_&FO0IRGTp=ee2+rgj5w6N{Yxu*EIpwb#b%QLM?Ha1 zeiF*Ejwdjitr_ZI*DODr<18}6HPNqZRwpa1x)SM74x~@{%_)SC!Vvj1XOs{*P%(}Y z)0?9Cn=?q33&w;q3J2@l3$&u<5;UAb4_B9O6I+P&q1+!+9=fFY9$R5{e2(+fua{YU zI~fJjRg(wLT}(~|b6kLI@@>E|i26|nJVyHGj}n=iYZ;%7Q64Lj>6;TKHzRGO-rn(G z`pb)`^sih!eF_H`vv|J?EB}p~8+Nj`gF}SU=^o{wBu{wt_LFbSBF5vM(Q$WCI)EqW zGQQ$=Q{C^M7Q2c8Wrd{UiT@4B?N5QAU%lhg6<2HOFa;b-eU-B=sR5E90om4W{eVw{Ya61|Rj=6~-vRn%tU<2KA z3}uT{&KELgFxcL%otAq>#$gr>O?E z_oP87R&JYyul4y#XWi|oKN-->8WyD&J|QY_(Gh&N)(2I@U$gP+2fgX5`DBTjs49tk zks$XVO)dHB{?bP(YE8%!PTivZ$YCSEn*voPc>VqG#V8y)UN74sSqOP(Xjg)-R9wip9AIO`V>-iGPfkA2`%2YN)CK`i%my+5jn(Xn zwav`TmX^T&jSXw~5%y-ZA%xZQW;|OQE!J@HD=>2Frsc8{!G!m3Zj&!)IXde8ENPIg z8Ut$BS~(acaK2i}zT7!&Jt*28z2VT~H*Ipd+>DVr?N;~Ch4K250_(&|aCI0lC3om! zHO7C^UnSypE*nDuf(Lik+s8R!#0r(@N&;x^$I$j*ddNfNj#nHcu_<8j2OJ($^58py zrI2T+u8_Zy1Y431qZ6hl0+k=h#yw8zSSd{-F!4XV*u89uF>av|50UTHDGdXM5j@9n ze6*~}+dOC=B?!$}SP*yrmqE?~%0yy9=r8qM6jVTFYn*#iFb+B6srY`cj-C1Xo4te4 z*E;;vm3nHeMUXp_KnaIeEevdYMQ3OvsXUhlb- zS$Rjr104TDsqd=0DfBatnaB+Kp3qRv2fmIoDY;x|wZc>rmo+$Zf(0Og>3<>BumRq+ z_4_)))oEOERJ`)q4>$y=4tJJASm-04`R}7;j7%2=6r~-ji?kB5!~*QSCRDBF(30tQ z@xWz$So-C-=-eN#r7uTTBK?fPeHhd8)NHSvWs;jsnalfZV_$DZZ_i>BQ~l0%ggs9? z1l6Tn&R6!@msx$bNvgMgG42}G`PzmkyW1hRCb;?cuUT78$N8l%cPo5PX3$I$5=Hil zO}G16>cF9Xab8&bHf}ewXG8-_!vP=7+T6fI(pQhQX*Ahh<3U8_ce+M61c8nxBd+AFjOwM#WNwTW6) zV#ikdect2n!3T#UAy4w$_wT&UEB3eQYlICdiW9~PAr$;@4+=uTv)2DK9cnmLdO>!b zwVzGaBGp=0z)p!JX#uMQ`Xh zqM+4VRYS?`CNBT63cvfCpQBX@wXR2JH+x!0^Pu}gvMYNY-N3UXpF9u0VSPy%BJ-?v zs_5GyT`(DR%HhW{PqDJ3=4!J9$;Vpj3&p2htNaN$PeaQqDrH$n2vpRk8SG(@lm1c$ zWe;gb^jyOzmXa)D;z>6IAYPzFjv{%ENT?biLcBib;j^p3y9A)^usnA>|N5UNRpRSB zM;+cQ{<;%KiGi5q;^*{)`KD}lDq}$_$_4HsK^pbFq2-rtEd#^lvMhw0*>;b!%O60q z+Fpb|B2T&n4N+F2AtB^iEiMF{p2r(o`CE_#&9xo$ZyB=$|2M^$MJ^W5PXXk#Lzj+eYpd~;iu7(-GL)N` zxfwh@yL>ets8POijHxtcsmh{)1fhndRsJf&{W<%ln5CW3%5j5#EI!_|uQ&G<20-V6KFLT1oNuqFSV!#=X#Wf{^1Te_(^Xy zC{owa!KCygONnxDbY_sTQuqA#dHu8ZY@d6_;kQ#Im~9>EAjCaz)6&HqW%prwtdGTf zgQj$j`_a^Ts`%S3VK3Hdmyau>?oH+m3p7sFTm`TX3M7Pwd=G6&UUGQl4jjt%0Vw_(Q#u5$76q%me& zK5*@;+T1Wdv2W1Ci_-0u*J2_o;-ZXDUbx4t?*n)V#mGW99S6_5hz4(O{B@hvRT|{) z{aK>v!tAH9)HRR^= zJNmrStXuHOMQQqeQ|?I4RDf-;Xq^#5j_={D*w0Io?2kWLowo0Kk^)EIM7@E8Hd`uG ze?}FO4?mByTbV7*8>(sw*cgiy3fd0+cib^Ifz+i#yO4kGy)#evt`N!YX${GyXY+eI z@z!<>St!DhxReKr1p6D&7Dzl0Wh!hd0tmo#2@qg_L@+-2R$17fIWpx64^o!S8R^Kias@^F&m-&{(jM0#7y#BtMD%}I0M zb9HO+U%IcBu-wI<{y;^*&5m!+%6?GOKeL0o(>o&yR z7FAAWIhlPAh9{Pdhbi#zHj?S;$tk%1VsBRAC>Tp!9h>?dj-s{EbE*7&?*C&j^}$Qs z%vA=(B(T~@^Aoo2r!6gAfEel3%}k-qpY)&3;tW)uxUAbdItUgZNa2v9tdk5r_=~?p z{$rcCOpFsvIjZ1EM3In*4XcWO9v<;oN98?~MvAoyXZH-Ld(i5*+5faOo`#C~mQyb5 zc9Fpt*j_>uOESVMAg+E&RJ4VwG_H0=o3G1ozZ%K8;Sb#qx{A0t3*C@$tFNx){5+=W zq_Cm?ktR8;bPGq~i|+IBxnvKGUyMz^+l2|=zHM_^o{5c*N22dJcTQzGW>lvT6 z^q>4`(zLr9KUeM9zrO6Qpxb_fCIGUTv+0iWKw$8eKhw7tBfm)ry=E%)(F)z}<5sq_ z_p*EW{yQ-=12HYV!R!=|GoiT;Frly*aM*({2?gPDqq^r*ZTXYX@Fl)OvCp&PUsa2V z#^CkT{g4mAHVPDQ(%qK7NA7PRW8hRSdDen{;;Q3uTZ7bhWM$rEOfV%C6vyZ-HdT)2 zWuLOAiyTdj305s%<#^V3E)+?(-#pkVd`ZvF=Y6y1hBL@<|&KIWH6`<7(y{8NzQo%Ldr?HrRTxlP!bgKu#{QPo1Arv5&H}P<`$V59k zco(yBZxyEoj}y4fK@g(nAc~%}exhnHs%EWjTXt3Wqo5bppc?jKtDEkfoMtkuJ{eI| z)0Z{i+8VTzz5LgcFP}Bk$;LEGWHXwpL3i^7vaB6k7X?*jK`g(r4 z{Tw5IiNE<{6>gW_0?a+NlLejCE|E`7(*S^n8sJ^&M{jh|@)J}DqY z^ySsmlm<01yB(H67j*CQrme2tbPYt2*qww1v^&6bmS~Ad7$b$LzJy=0s9SFz#nnj6 z!uk}sP;kby-RYkhN2D9dY%pr_+1!AzqrPuTuMQ){Z`07-W@$bX5n!r**z!fh=T`ER zJ$8X5BDwvUDwvw)-MYjgQ+ObfzTo6RD5p4pI^$(i<(9QgC6m zBDJp&A%5=GqRKFm`u2v~D0(5w>!$pxjn!HSF1Jlp&$z*Y)HMeEmEw9M(>E+jY~?a5 z9H~0E=<4w7@Ti?s-XAE7RQNsZ7x#9He9H8aVEfMRicl z`4Y8B6LI9#$@{4BFEidfYW)n|wCY6VCX)@lEzXY$yN8n|L1i&7R#lGPRw<&_^cykl zy55g@y@VAdIvxBKhW@zDx7*gASeUIv0h>w3aD^lbyp+cg+X??eWC~PD)n~y;PnX#9 zh5CJX_n(p6*|#%TNQ{TOY%ZHp61}QfrKbD`eAyFaC`#95tBt+CN|?8pEJ|rpH`T); z`++D!oGB2Ja89X(N=|VNR9s)uxBD&sISV~XxZZdzF>WzU%bS$(?j5)PV~y;;9R@KZ z7{8BQZ>0THW+v`^Lc!3z3=weFjr3@H%oqQ+FgdSCyEqKjcqDOIy~BNuGy!C)>*FG^ z*YU!Pw-9-^bo;Bgm7uH2u>0sEjG1qZc{6jXq(q9?c0{}P3o^l6PQt@?-45TWWKe&Te=Jj92&{qhGd~OOOdv z)GR?u{htp_T?{x_ zjf#XE&O`4-F0foI?@Vu2keQH6KEcSO>Lp0=a*U1e|MwwGJMZI>!Dn}p`&f59OLDR_ ze37{1hl6lR^186~L6S-v`frDPqGe$9H`7F4c<)S0oK{;9TA$_!Hg3b6kZAJU*l-J! z@9x6GcvVCmVRvEvx2%2r1Ip-JPgDkC&g-FW4OrQH{tok`^9VZaLsD$3!du86I}xdG zfnt_s@)?Fj_q?`kt11AK>L4I9LRrU(*t`4V$M60OhyHG&(t!w%UW_GnCQ@{I($VO| zD0E9pGEfD9N91qz@6ZcgQiq-rHvZa6HwA$Dq9IBKWlDRQ7=D-MgOG8Cx2fTg-jWna zt( z&O_!GA8T3<&X*V3?^HYET26N-kpQEhS~zMa`#Rg93!453l8>MzqjN=d_!-5nP_`wM=*WTU2K6lp57ZN@*D;Ca+d;1 zJqquVz00IdD7CumqVJc@AHqK^)e1e@;11eL#_~CRO?ACUJ%7h>@D|v(*V2=1mwxyn~!p1vfSZ3i6?<|ofbYrb&?0ln4Y8Mw;dXt7T^3O-xv+hmW@g(Z^S z!&VPBNE#wyz}ViTp(otv-}_4R>BiT+=^?EWQ>|?;bcz^UQ zb!({mUzv~4$nl4z@oXw2y@R|yGLbrT5s+KaATZXEhMWx)4&647D3ZGULH3b$n86q+ z5v>{*XP9-WYZh0X1P$bYB998Sb3_<+4XOk;-GT&d$#4{73{Clq4+Ua7Ih*c7>-dGE%aut<0(P|NcLFz6)c!R1FOt|Rae164_X@5BdNt9H@_XylrjY1<|YODjQ@Y{kksyq@cu zPhk0wNpt`=n)d~QY}?LhM0`#aH@(YcT(yJC$|QR7_AU{aCZ<+w%3+GpHoN&`xb)*^ zJyYIXkOyih;;GckPp@fEQKUxx1P&fk=4n~_i9bnfzLWv#{6e!4Ws*z&eA9rAhKwvD zhCZH8J(o>It$8>e>%aHtJ%oV>T4&^d5K@cKjj3{;OHb^jjfR7mrMn&LiIyv>C8nlr z6(FOg(Mw~MC`9O08^Jg7@95TBv9*gwi22qS?GE)jL{*93#~>AWH*GWWEGsmBrle86C{^lw^CR z{n`K^8830J|N4pCHZr8c%cE6qq`y+acQWw=ki;f&ej)}zlk*^5IL>v2Co)1@n+Q?mZDdu!iwi%B&jkd#ur$VwfN9b(dkUxj{=flry3c{%0xNIFw z**zYKRl(2^eRQYjKjL@Q7~zUg*n31o_o?>mP0^@ps;a=TIUOGbt9}42J4I2(D#G1xi0laz%?5~Ud~L3869L+c9)x}a?f312yzFG>stZ? zyIONbUZf}VFb!Hd-BkvKHJ8V>4jw`a+~e5%-b1@Oqf_ayUEuY$t!6C{MIkAxhY0WJ z!l)vqiULg^d#0}~52VgUr~YF-R_mBtHxhv;TxzTf8lwB^O}&pY(ElHjGyNi=$B zu95KjqVwvxvEZ}g?eWf@zeU*I>o6R3=Yu>?m&4gj-x@OnZ0zj|YSY5$zwd}+;JD@@w* zXWb_!nLmSc=}6h0c!G6Z9y6aKUU-}m+oaoP3TF<*6p*|f^~-tnz|+9Ar&q4Kh|kj^ zniZE7Ia8bI4IKSfo`2T>5(ocAz&ewLU^%Y^$2!J{7-7?5lns$0SWcu43Jr&fYHToM zs_g!5Y2gdm*i0M1GW@zNcce!~qLPX~3qni|PYTBX*3Jw0fWx8eM%M)F)?DcbZ0+pz zZtkJnYuDGVNaWp7KPi|}F$Q`(HAndCfBh$xrN#45D=BUGtTfMWa{{~OA|J#qbP#_W z4-}1BH6JbAl2ehyoIpdhP-3Y;pmecW%$k! zW9h`gljifyv~NirY7)*;D)mpU@j+mqZNPpahUd3-dQr{yNK(`}DKiC6fxs4-k$@c~ zD(uiH`%@g^w*M9ueTW}%b9LKjt^c_P+&cSc)?wPOM@t`_jnT^=qX|LsjREadsz2Vi zBK150a%YjKq%spTikv%#(0*jwA5!@~LzxDop9X7Rx0~=q2|y51BA}9tt+UH-PR?jL zxXAiij2FHYOhW(Kfh-~!th5(<7Kea+$DlPto#4Kc}mz>X!mT~YQ8x2mA~qvuQ6{DdnOlhu)#f?GfzHP1pY>L zwiK;><}4j3lC1>ztH@w|ClstjIp@k4`z?M`d%7( z9r)u(wQ50Pj56%WgDmrARhZ7kq21oU<4~NF%>RUQ*Cl z&xUF~bmjEq1WHD(2pr4YS7HtCw5}re@g=-?HE=^%Jf9N^`4a`iS5L-P`47)zBij|H z`wBCDO_cxGQYua3()%%*h_H#17@#}8b2&^3`DNr{Ym3UBn`2(xv(_e2+JmLqOZY{T zbUsDbS$_XnkVXv4?_P&(2)^(Ug|KQOr=8KaGl$~J+tldr%;5qRI95AV3mH$DcZuO& zNx&q{&F_{LTYOg3+}Z+P5w54N*Au8ts3TyugF(WTn~bCqjS^`Rozlp7sJ-Zb@G$;x ze9mm-*=f?sN~Y(q_x$MS5jNfocsq)L(^v#A^?UgQ?VMl8pB@5V9hY|yNf*_Ha`R|O zUd~2Mw}q%lll$@5D>E}cLEevz=31NYB1?J% zH-KO9A6(5}y>Z~ADDz@EBon>Lvq15*dUzEJQ-aud!h6fTIt^Je-em_fjgv7oDC7E}9Z`+dCJ@mDp$yz=`4Q)luQ!_*%>VulWK^-JUR>>Y?^ z0vEltbZ-(8gu5A(ARh*Wza)+o)la4OCKpsYRZXH85F|EnN-?j~F~j8WY4Irrm%1pp zG=r0Ev6FN+D_d~+R7UT1D+^_RL_}Hr{=&~*Qzl!lxrWXb0BVaPf?-p>p6(NF5^lyL zsAc!M?g&w;t<(yLtC2%6Dz=(nIU3jTrwu#IAO@Gx9yNbyjUB@hF;)g7(Gw4_@bIgi z!MHGq0cjcmNPs`78m#X%wokeF{LG&a?*LdB#u*|`7rX^)ix#~2g+_LA&QGXAAZ^rL zFe&E$kO*uECk<7|o0~_p4}Ev%LXWuX0t2-z+hiCh7n|NhfTMqO;?7Oe=}!rZ)7UuG z!e-aRC7_0>aCt9^J`Y&P$zdcA7tuFMEWm-CxpaH&8haW97GBNNph_X$9c_-!LtqD<@a>m+f3m2ibZYVyldZSTDST=6i9IECCA{_oT07e zDQwu6Q5@rn#Q-x#r0%Rs+LyLM4c_-p&(J%W3A>bRfN(LSi)e5{Im$efz*g0`NoYQ% zjV);PzZiZ>xV(!GLeRb-D+ zur&9<(lbw#AgykmeeRZxU6Nz{KJg$WsGJ!r>8bJn>ay7d`q0{gVIDc6%J5<XRg;v3+m z93{}dJEyLX9%|MJ%q_}~!K^L^dq#G17G@W6M(`RSOvvfURdaRQ>Br&x#wnlNL*e9t zl~r0eTLCm}?`bXp@~bD2Sb6N7A`KRDKh_^5*I2k}qo{G~+fnM>WZ#Eu@$XV9Ol79; z{j`>1WDkSJg`>*qY2Wp-_I3hBzIurT$(X7shbIj7h$fT49D+!84+Fx_b8EuOhq-MW z_WH%ay$yp|Pp~1TUV>XhSjr`i+B{ulTp)uqDNeY2+=ACn-RZv%&DHa`g}&H>-Q4U> zvbA+{V=C1cx;hSCZ*Y=MF+T;!F^U`vg~8$?Y;+{WRjEM&fRH(HbIR@kZM8P`hvKN0 zD}Hbz!VK6S>_kWO`PDX@Y-YRN0~S$O>@tuZi} zlA-~VkUoIzzMaL)dwgZ>-pSnwy`a9y6?c%y_xL;No`F|EDe@amAD4^1vJBdfY7D(x z`PhB~oN}*P0a>#@9$yXMeGe#jlFeKBj~R2m1pS3=x_eNJ+gLzo;G9cbpig?=Da#@S zN?s1X-?GQF>~MN>Pi5TO+)|d5&qGC>FS4uiXga-!1bq^x>Uk4F$*jwyKzu@(b5xO$ zbujUY|3Aty-Py}%x9flNrweDjqCjKce!?@4EbDE>S{@E$GNig4d-j4?|1T#U zCz%jRAQ|6eRHUcb1!cnmdF9b>%#^rl&)nzBrD46oCN5G+dIM1qDH0O9caTf>CoklW z849<8EKyJ=+%Q=UM$jvdp@)~G-!ZqVS|6gCWY7CS#wi9jtS>Apmxp{U;3FX!t-81{ zpG!$rD#6v#=sGLwIXwT(ld!kve!km+$a5(?6i8qj+~gt(aKXo~0V-jA`O`Y9cK4Gw zloztmTp+aoBF*Q0u{+;!)fn35lvT0?eGdNYI)~}QnIZ)7v1E^Cn*27#eA}-qKJ4Ed z05=bLet$ooc~xNK-nbnXs=$)#<+Xc+klo;n6v^V6p^Ll>K$Ddo+20nU?_^-4$2HjZ z=?2C>5&FV(Qi?ln>geKuu@Sz#T=SJDXW7<{h`L0iM*LJ0!sh5Zn~Fa)5yBc?be~lC z*>8kVYv`fYc(ZoMrfA0{zkJ8DUgbFh@Bg}r{wo}_Kig!I4_S$qltoZ3y%O&8i%aA<_ z{5^~I3m845?8 z#S=lW?Pe_wzGB7i4ElDNU}olr&c(r$Mz7C@;3kHe;}tKHl)b2}+V^rlQdKr*QVt|3 z8H8b5Pkyl6m#|`@%&zc2_CPgst33;k+E&h92mDL1I{yRsB#pUJ#6e}1G7mKizqm5? z9;`T@HD1qkh~^6-r0OKbWxB0jOFW9lHMDqb{4Pw8Kk2V@%aixLmOrlyy&h}wJe(Ey z>BCA3fu~xce-8wZyeIh^LH>-5IGDku_FxOlTF^j@Vg%`+{wl{f{(C}S5IqyTe)L@4 z)%0o=)K&x5=gVXT9g>9?++_&+1Y+l71U4)id~sJsuQBk#BIiFmQyDJ4yYskb|JRuK zu3eVzv(|h_D330T0Bsb+$pD~g=*r&G9x!DXnX$A=WEC11azYC`tpSjpPYEsdMn4wJ z$1cqEryO{(R@PT!J2#n3fPO5)T~@wL7FkT6K~P|I43g{@Z}AJ)!ywiSuh;M_FrSSM-peGkL_WqxT+KooD~>Q0t?4{MeNC89ns`$-3;TBMxts^Sa+*H zOEq%NxoJ?LsG1Kpp&AVVjsWXsThXN7qyPHpbXBF@|Ih`4<4g!STc&zkBn=WSS?Pcq zU7a|tmS>z>Y)b}R57nVyuS3xx7LuOpb{e|)V1bs}>nTrd`B@n^^z3gO^WUad(iK^v zc%g~9Gk00bJIjE|;RAPyKx2>Ec=;WmRb41#*)x9uH)9ZqoU0-cg~J3R%h0taY4X=8 zFCFN=M+h+eM}}Hga+j0PJlz&eVBed*o@bFe`^O%7SaZ3FE35i&iqYGM7AjI;QlQ=L zG5Ta6l9*#1ZMV9n8L-}dju0mc&aH{dse+6>xc;^6CB9_UMmmEh zIuHKz#-4R$2>fI1(%9(cq1zQdV>BR{fc@34b+e=4jnU)%4<#1~6|(sYxkW4JpqA2S zDkKLZEt7f}q=27ymwEy6`yTR~oz>KdgHDm<8Pu^*iE>>ym_Ahm_{ljh;#w2I=m@Ai zsY&)w<)&D4;p`>TVyM6QTIN^d8q223UiQYt4hdqJ!2Mdib89!k4oiDGz+Bq2I}*vA zoC(PujwZZDR*|ygP!Iai3h=^}CWr<%SM#r?fXeQZ1o7`UcQA_f7N31Q@4=smjmL`u zHC7#GzsD0U<2&{SnkJ8H^_-;pDOn-GQ?dAt3C%`&zliiR3VF8rxoO>N4%XH!fkpX)d57Zv#B+|ZxeTIG(`o4@%k!9 zqBsJU%j%#pvM0YH;wE5hW=ElMC{BD@<9XDk(&oiV+h7SiX!YHy!NFy0IM{1pCy>sub6|UGSZ=u|;ePZD+}+Vb z=e&r>z+!Z+BIHZL;UIk{Rv3l{*3%89%F9 zyUWAUnx3p|d0w_sG{0*Giqbg7z3x?%LdUi+sPihBK9FLY9aOXf?%5)YL;sCYTjo}2 zYvtmWd*Qu`^%8+Sbq$B)hZS_36sNVOB@A>(uO#|m|1cm{vA3kaG*^$-({LJ>h{rT6 zQi|S6_O(o+YL91r|70Y#{`Rh-DMY=9-PDDq0-pBe?G2T}Y}+y8hc zCng<>FjLiAkq8%dfg-R1;Ed=J(A0m)f?GDjAIL@wo4NQ7=hSSj0*V`nMy*xELW8$_ zrLq`Sm8Es7USiUtuS!6FYM(9%zeOr< z0c8(h;z;H?roW!-xZbI75_GjNF|#_YW;ZZ==G$qEEJS(Oin;{tA?F^a8&VQQzE2f@#~w&X z9n);vHvfn(_C#8yO7OQGnljmo-4U%Div*X^-QkBCU`?I_bCv#|NYx)*a7egXDMl+6`>8516b{7w33b!~}SUb3dAK@V|aL#$b$Jg9Es?^UV95Gab7_WK*T{DgNufbFwD zm$~ur@Ni~DMPeD+1#dAfp_0yoPU|N(>~G*^lsL}GL}La@=4Wx2+of_F*|&)bPRHt* z=mi7tkLGcfPrWM;f+X(Z`Kw;GZ=WP8y_C<+9$oKQJhN=;pl5$&^!W#-R-wBY8S}Eq zGvMNSXYSpS+cif1WC=lBs7`I=NW3_qsz8bP%&ny zRve`oBy6dm8nC#&40g4WkhnYc(o!-q&U0EWe|iB)cB*spFymGo)cpz8*;ZRD)EhIQ z!Imj@QQ+M4I$K6_Lo}OSg$O*bIz}3Ly3p2~H68l^?1!g5^;O<8b%K1dgMgF_HdM;f zv^We3s z*RVC5KP!!CZ_f~l6AnF_4%r{A{J7O@ggsh0`RiUP_XiY6l%KwEGyHxcMT=STPMBYp zYa;b+X($WyVV+l0-EB(UYZPQR z1pX-p)=t`$(Nl49N<#HCHec9q$R@qn(-?fKp4JET&$H<8S&_V6bz`(`(9Y$HF09_6 ztAP05?3Gn#iN!oVWQI|F_@G3YR5;}<#z+({&Xt=9d?K)f8#Q|(NAs_rFr;eej*A&$ z;()%Fs3>;sr;3)8RT$Jn!ww%o5ze2MqXVA8l{ls9EkrZ?u&c!i082T|Jo*hzz+|o> z6PdAzZLS7M`Qe+5N}r+dVUhQ1?bdFE$qGUK7JqE__4|^K_cwg392Z0PJb^`tqu|=- zMQh_y_>c%}t+(G1E#6a>rjs+yp3DIf$+5}j9YE0~UwuWTsS~G@ZPs5(jUN;X_tT%hAARjg5$;}O zNIveONAwBJ!L&qiK0d-@jNVfJ(UN6%Vw*Mj#}8vBH%gYf{ha|M{4Rt3=T3|7&DPE7 zVgTWaX=Qou=IrKQhn)MF4gPoES4(xJ@mIH4CM+sz)#W&HloekR*%i~Bj<8jxCiK$4 zjkI0=+=fDfcU(e)PKU4JhvG4Ps^iV(x_j5>xh-tBi`8k6XtZzjQz;pZtJ}1EDu!(W zujP39o1H$CMi2Y)#t06?wDJV6d+1X||M1#kV|^}WXY9X=b;uN#@KcLar7uEB)9S|2 z5T$8lQGwb1EkKwzoxgtd&&sS^)nmFPAXuL{gqs0zEE?AisG!Niz(j|w1~@tuUMNgw z`X1O3-A4F8MLpNB(JV1?Fu@TCp~YvVicD$PtuH?Q-GDh2j899Q0*r}Ivo%#NG{aBN>7zO-D0#cJ$5WSbJ%w2MRX3(^ex zc_E{jlUpCb$>FbEe5lmf*gnxhe`SrsP{xL^F@k997e6wK6xZz#iJQ*J4%2%zS=Q>O zC;by00Uzw5f@Joah*&+qDoKm;Z8yHx!;5X+S5rX^|MuqyZAWALh;(&xe~6tucRH%; znJk~T?UkWc>O{FyM0AGx1ly4Y<|tbqyWV%9k?d-`x6pPqZ5jGJ(pM|LuP#T~eW4?G zWv#Y?oJVz7#08C*_>Zp`ssXovKg1?40PFcl;!L1w3f1?f;IER$Y}|` zYj#O(AYPAn{xfSz!sBQ`G&U@92{UyS2 zQr>%hi11zDT&?{&#aQ_$;GepKgb{TNfPpBi#XbNzYHE&yK7pqv4**<#937Ee2aC$PTgAI#4A( zMLT+aj>VqicCXtf&o5`|Qufz(=Py`pCSt3%&g0E-hc#JR9JC)`Bwh3SN#BN>T-!UQ z+2)_l5O5{bpNV)pFN%e3x`#9CE=~#Z$G;|_b;E&B4esn*ZC-(I8Ul9<9umdN;Yq-Y)oErIU8$g@VUu^Vxb!F#n~G<~((Yq?KvhHmg3 z*;@2xpZs>4UdbJ7>xb^tT&ob6^=`^&I3?p0k_|c0qkK7{Z5CQw^Y4c zx~YCfWPEPi6zHqxP~h}}X43~OE>$oJthyzjeg1XEJJV0eI3(ocPb0d~I8?gF=dZlRAe!=q_l1rRI|IVE}vvZ@%${{_$b7x z47M5U3&TgqeN&^aeiToA`x?rfuCX0mnv5-Id!+R`{i^2XkEH8jd+6C_+RcIdjdrzp z^WPRhAQOL?$ojO6T-}7DWSQi|d$8{*@9c-SFoyS#L}{pAzpHlrkM5*5BMo#e|Gt+X^7Sh)f&v6ivHjdf$mQ=Ujzb%e` znbfeAK4AY>=Wmpe%!mluP(+z`%wk#`*+V>h7V)Ey`%%#P_SDo+;OQ=5hrjrGU;dn% ztfyxxORT`m0-I{STIS^&G&AI29eep(y6&s4p{B#(Q0{obv#SaBCt_hN{>x_xp_`)H zvbRTuOP5?-WqgQq$UPbVy3=TgYxlX$pvE8#km^%zhR?D<1tClypqx=nf3i*W_LL;rc{o9vF` z8qq=_i&g_FRpj&oWXWN%-aCEvQXtGh{UITFC6k?B#f-^p%Y})#nCIZ>Dd9z_KH6&z z1M(p3S`f^`a~AxE~Bsprd4ax6aPJomEl6$@z~mseG%#l@IwE^?3(Z zf>yDUwKOv`BgpvThmpl#sM;c+Z#ycOP(5|?dO<`S2U`e)dF4KjKX4Qq#Q}&9Q5cMa zF2=Ub5x5x0o-is}X#Z4U~{wjAWF8mc{qJ1(w=hA;qkwTIi86aM$B+3Kd{=I)8C zPU2*3xf`dn1{l^)75&rB&`I!`6O2Z%uF08q&`$LLC*F(L;qQ>pqV<+G)q~y z+$Z0YvQz{6quq~xbIFPg(qCl`r5Z`P{=SS4JvJMT8VO#m8+mp(@mfaGsMMA(`0=2+ zZjw(*@OTnTF}Y!kU!j{KTgx;S+>-%2<`y*4;1E)v5jGXqoSwKoy;-7mUHJCqY;5&7 z{y&fo3;@V_2(8bH4rIqo#gUpZ^%BChx&gAxuGL6KbasjO6f^VoBj#NfTU>QK^FRSd z@mTyPi~|+hzasR}FwIL!ODBuJ_PTVYTXZ@)R55g8Y2g^ej5jjf-X4+H;jS7Maf&c{ z2ia`anpqjfz;KSYposJoy`o-IWq1lkv)FJBGuWCjG@(o_r<)#8%WQZI^`lp|dkLzl z)R3ZmIVWH-Ei)3~@v9yq?1Kz$+mUe~N^K~oEqt4*MNbW$5l{x9AD7eRzcPM#!kaJ1 zXj?zk^{0(2(cZ!y;adNu8Yn-r_~^KV$h(I~XYzKtarX>v6F4|s?69Ql=gtpmt=vj! zvaZmn+5M11@;D7@`)9m@re65+@EQ?k0V%f`?P-{IXHsO_PR0tZ_+Q{!%T z?|Y|(22IMU@e|M+owe+(EjE~9Z&g$1&F;{gBwhK>S{d(6ph)xVQ+&MejDXO9V%*yl zoM>c8xl@$3fyRHgl~ov|9sH9D?BV4ML4P`(i(_kLF~N*FKuwA9^c@anD%Ib&9KJA9 ze#7g8LrEity*0LmeN|mtLf0z#yi*_7rdl9ZbVD^`E|K4hKHTs5axn!s^1 zw0XKdVa|^ix4~iKeMmmroI9%P0E+z3q%SWXb?@BMvE+njs-?S4&V6Y(=5RH1*VF=YoA`f zdAAUy!)vWxtefBMJlMybew|%r%hH>3_Ywd5kh@vT5S5@gO8^K3u*>HBVOR5xhQ?5L zFRz@{+0aXtn`5$SbJw}_#cH}K!y%; z&UWv!$JhSL4Vg`n>Liyip<{Y*W76xQT2t-86i$;a-Q~An8XkgQgylZeLbeyi9IIO~c zBr1WDC<#zl*a5%Q^{Rl-T!VKAJX&L}YOQWOQX)|gxrR$@4cDC0;u^25Qli{w@v7F2 zh~h9!)^oB4*&lG3Ws~3MdDCRB0mwL0^{ABPDQ^jDbD3dH9X8K_fvln;rq*krXGzp$ zOk{n3Bh#>U>!N_?uy!4{J2fA;csVt#@;4ryEh*|_O)(+9*Y?zX=8qq4~7t} zN&R)> zUWAvIj3aHmrjHTnHAbHIy8t~X3Ee^YE4W8Bt1VHXw1nra(OP0uR+WyiHK4q zemr>+JL;H4CC2$biq1Qp&9@8Vp;k*PR7+7rt(cA3qA0OaqhjycMUC2OPgJP2rE@+YO&I$NM5?BIh{3sW@1}|$YgBbgnnHmjTUd@w>BPoHi=(8~(9Lf1O%tJMOTt;L{$#WG zMNW<~d$gOsr22!i>FJFnWNqS5wMoIu?6mK7W0uR+vR%zt3s1kzxQ^o9WsMM(%)}6H z{@ZM(>Xr(n6#$B7^-O|FL6Z)cK;0s)z)`5sky2*aC^>d~1pZqeI$eYS15X3W}-At0CU&N<<>>Hl2osrn2r?$>PmkGa>MG zf>J<-3T4u>nDueGJcXs$PT@xgc`8$Se|qu};`^EJ)vaRaWUo+fv&)%`JKcYlG?fshE-^&k6vLfgma!C*75|?SPj@z%4%1zU;!26L zY#meY*n?Fc;j_`ip#6?!g0qW@&7?=$I}KnKb&CR4s?<2dv0Pv#=2V`l5#Cqqbnn

9tgt&Q1<82k;8fl6xk1J`0=#gy6#ZXOl}GJ^2&vStE=$%IaP+e2vJqv&87kiUT#4MAuTz394FkxuYdpNMD&c@n+2Y z^zKWG#*7Dw$L7~H!DqXt|NU(Q{n4rI;z2TGzA70=`45zk6!?Z!F%HR>0P}hbgi=uw z@!&wHVIsD-H4jBzMf7y(J~my-2uHx~4NC}gQ}9?|m46?}*pII`gNDC{KHpdR@YV2E zm9e?WD=3&0L1xiE7y640)h&6V%?k&yzof~8bt7Hw9gxh09~pRVe+V#gKP-a!Sl%~q zNq^Z-H}o|+jmD_2&D)MUcF+3q^z;w)^%4L~{e4#PNi;0jW52=4IC_kc4;K^K97F-E z&3r%_S()yRECdug>B|OR*va}H3^_B*v~4$e=_K@w_id9?kNMsSq1D|@h|Xq8M%yxw zt>p~yePkp-fSf#pE6|F!Ljo5)P02%}*}r}5`C}+vRyGiIfwatBMe>$<#EG&bzDv`Z zIsW#UVO)?hAe#rTcV;GwkQ8)Y>WTa#YF4<>wA8oRX`a_>YE z?YOjm!MXh_z8C80)o&x^+Ge>WN38$x{vxFAyG)&1a z99EZWLr$TnX4-f z{4hps;p}MG1M(wmGoHqSR!v|{6)tnOuC?q*1)6Ef$?vm%j zqCX7{H+8h0FUQK9wmGJ+)lB{iu>8{zzg6iDY>U*g8H9g_h2TH>!HL*?Vrq+7-^MD5 zi14H`L%yU^$edk`It=owkoLKB%Rx?y6|vjLBpIobydwq`-WFNJThZwI=!b}^3*Gl0 zzJKE-eq6TthbVsRE?rk=DQa`0dzpOUTUoyA15}@&5J9E z=H}B1I2={Zn{U$9(_QrRY@N&1D0ir>*mBTm1e41i1`_5zri}w$sUBNLNp{P{dg7-& zZz|#;6hsIvXw!ihOdRYEk&U5^p*Ypm zFPx&`s&5h_-EHJ&l_ECmp@(QP@_3jA#w$QGQ#lLp+`NtudV!U zcEOCm8d^HgDkEGl!zyOgn&t_`k{p~km3E^kl@^73KfYohj$n^?{keaFI9VT%>be;$X~xn9`QiD zQC{B`DTztI>Jakm2Xjpu#r2#gNYiXUv?m^ZaKKqm+cVedeUf;+yJ+5cHn`oaEa5C8 z`^-DY>u*>+UeD$^V?2GV4aKOx5HN(Q(2KqBCfr+KvYM?N5Dq*W!{lId#t#fBEOg>$ z>qwzlel#uV2x3Im14JoZiU!^N5pv9g5h;NKp_9xb?4E1P&Kr}AQrK1VSlzdAtQeIX ziE$7;Ukann@>`wz=-kRL6qzq2>(8H|?O^4Wjlme7hr*Du9tZ?9HIW7M$9<%o3&U01 z{m+BJuhip?ZaQsjHG*TSI5RC{2AeK_n3f`@n9-B>AMsHymf@VWxaGw5OtAl>mQoK|s&jaDo+ z>{a?Fi)T4l4gH~6LYt75?#d{>;a39`r$7Q{A%ld!M!;#iLcV`64AIG;{UEoj^ka}% z&M1}^Pg9_(@-N4DlHx}K!Z7V!HC_MSpMduN_KSJD{>1t0*|`$k4jp3M)gF-gk4ir8 zj%>u;AVCivv1|>gLpcj+-fA$)w``&@C zfx^H2Y79sT>9g~`cdsfR33}F$U%53upM%|~wV$nX3PdStc{l;`Id=NYmwlY-DkNl9 zs#!*u;*-F`VW9IF$4GcQh8Y{Hl$MS1IN?B_9gQ$*cQh2y+1CllkZ?NUE|86G_tHgW zj~s7{xr z5(7_`;y=&$fR%Wc0`%Yn{uR8mh;9(p(83J0BBkW=s-*7VuKu^=2PB3U*l${_qs3{z z5?-pLMRqAxuk}vAwIxybZ4m(p>|ht+Mnwb9P{1{bu~?Qdq&O{x6A~T#?$R=)W5%SHrqAAdu*s< zIccLk4P{$SR6b>Aav1~Ai`GT|>oYsq>wjJZAV&f*RVPn@+4hip93PXCQnPTRn!mer zJwID7t4*OVXqt>1{_CE#nqFn%IJ>ic`t9W9(+HK>St%Pck3b;I(`?q&#!N?5xKl0c zz`n&Q?!oPhd*CM#ay@SysY2Ld;O}+oC0u0O57(0p7Jv`~!B0&Zw=qHMT7CRgMH34b z`2=}aBPNX6u2~ipjaDd3Jn}o0T17k&dV!p^CE#dM=*bU<_x*!N1Oz@iIrATO`|3~s zsF6P*DxVaJ?F)3>@V0ltivo&__&`m?ZW}or5=!)=`Q-)`O%4-yl+k$V6~y2(6-2G) z3H@O*`#?!DL ze||k7*x0_bZ}Op=s&F$J0E0~7s+xa9%WP$iL`YlQy$Ft>{8mRq0eSt^cu*&; zgl93fo?IxwE3(8n9hZNyEM|NY>wAzWA@)njuSv!)HCch6sY&0p9(mo5yQ&Ym#*dC# zjr_)%2V5=>n6J->D^gRnHhXP99T_U@Eur`iH`L1->~pxAJGuF)s#TmNwb#xrD!odB zTgqr_`d`KNCkBPx-@CgA!-NPh-HMwoC1~xun$gf|JSLib&rh}!VUO{gQHuiC;uW1! zJO`QqJjnC^!&|p!filQD`it`>!>y427H2)3-!{t(l#3c1bS_0|Ancv~&V77<88dRF zdhhgH$H)=OQKr%b-^We0;ZL@wr>&qiNDl{0Z_2iZ0LQ26v$Oa8Y-+}dbku2U5y(2{ zw1vj%iU3zcKZ$%C%)#oQ;9rvwh1MU%qF;6K-=0&j#P@bS4QGhTx8t%>L%m-oCt??v zh{FxCljSvRE0xa~t=$5hGSsFeY9>T85M_Jg8RY;87{=IsxPOq=IXb%1ObyT^hw}K) zc^7l9!niw#Ey06qoFXCOe$vQB%))Ju&YcIL=zMRwLLDQL5jxU>3CULWF@|3;nPcBQ z6vErSY^9)(ce1#nRwCcFMTad)M=uxLw1hMeN#htFg5K=Qa}Ws>y;D?q|DLMlo(3`S z{V7AgW#~IXcTQIyfkg8T2b2b*!%G&tzx2MwBxb$JeBniw&~<0tB^&bcnmTAdqUutZ zf;m#A3kbgUJlX`f0$6i@pPNxYtT|sF1!lQtic`2*V{Oh<_j%wno%I~w(4){IUJUkO zrFs1o|2LCqB$C03s^;vSYXXmhV~MepW#P#C;RreYf*~M59;^ln1)(W`MgAvRMQ`JE zk-9c}8`xQYDJhA()<*#WhwCUPTJinLxvre=;d=goj8dE5(Q{V$S$=lKjw0M>4@1`T zHA_^IB?W}me~nnNP*kK(qHXtRd|bKnY1euytEKDe+jmY$?@a7Hr|Ry~4Ad5VG>GTY z6{{29ifkh^0fCwYUw9%d7-(%xp#n5Yg;4i@&7VjatS%}a%D;8mYI?}lUv1s7@Z1rL zlq6xbQqg_A>%6T?Ix$1CtP%$@UfJt7_V{Kbpg$0_-di~8*SQw8k}}?L(d0c!tyHQz z&%(?Sa8+)eoxmHj#qj_|Q=xiaRrjy(h}Bf_cKb)URs}dC*|DHxqEgsgb`}5YQrrmT zYNSeo#-e&jGZ-XJj;7a?SUP`j>VAiG??yJKg)KxztkQ&DQZu5xQ@iOl(IrXZHC?#T z_xpC7vXi%^Q<=F^tNq@)?eDgpWF2Ui?nO+QKR2&vyO6rkgX9Y6OPB|BzIB(8YIR?$ z>Frgntg0`uu^93WK4}gAV^^Tk!Dy_zA|~|dSvU_T0&onneP$&?Bkf->YV!0{63WW@57@idXlxG@ z)27=2E9mCVuou-;Dz_rQ2)?7!(W^@P{fhM{aS|6NTQ#!ph=Nzos{kE`)20XveJiRf1~ZaSSBK9q za6X_>uRpQ0MmWDG@@!?+9lyHjT$*>oeFW}%fvr@S64O?zcPIEsYN1xrs``{8(Acxn z&@cfYA7T)`biUT%Q|q>U(yXd8bzL1t|G}BB>>;v$Wqdqqc2a|kb@A-qN}^15g7Tu@ zihKJpecM3~pU_TRc^g3yrTY9y5p75re*yJp&vpMn5k~1-DDX2S2n8;tRRQ~Qi=#qO zE!3`qne^vTQdXoi^SHJ$-OER>m82j2kX}uUDq$vQo=(7MVA< zPU7!>-iRK!?&EZO522V-QHXeBYft6i4iIG5>RtFa>rcF@f_El+E`qOIuD9nePF|2p zeY#%8=4I!w2&KJRyX?4bLj`$x9UmLJ1fKQ@WZ9{G+wwmy>mRJT+?TipG{P0~MBoft z(aK$Hc^@eKDEP3Wx9R7WpDrC~#Ypy2tF{plihm?xDO~o6t(CyjdebLr4@oIQ_y=mo zXyxp4=xugoZuf+qge*(6A2@N^0QeUBxR`tC1Krk00sU751ub-QvpTN0u< zzq|}qybH0CP;GKVrFh?~(Cz%it%b%lB5j4|(`G_HGw{HX2sbrR_FWk=f-c62qgTW6 zMUEO#y&TA$#KMw_t`L_AW=B$t5)zKRyiU@~6&Fu>j1nfqhK%A6|Ezx2lmYihfnH=L ztyL{QZ^6<}8*%pqfENzbIff|1EwuOuuyuAYlR7vv?KXMnOyjFuYUfqIlP%y~S5nn} z`6E5I`J0$vbLB0+f+l*}6g5yhF}p;Tm91`WlKXp#K0Tgim|#j8Vunt7$@9C#ze8o3 z8%aZ~;1~#B?z^^JGTj$@q%J+j^ibqhQM#v1yP&?Hj~+b*B#0MuW|`9>*ok*^rllIx z9N6l*F9Q}y8g|cF1#ZRHi*1;q&1fwY>-BE$&5`U4J!`<}d%}E6rO;z$KUM=**+X3| z)l!TOcL}#kn2Z(C8lOw%UNu}@X2=C(>Mgl_ZZ5BtUVEo{ESXR0(uU0*AH96+1i=6m?CcosopDS z%1*b#|H8AkcjQ{~{}jSE7h$!Seqo9^oKGapIw0FRpwPuL-Xk4+L_4ME_a zRV|mBzkg4b>sM*i7rV5bjpxi?6_>im+-xph`3G-L>gP@3DIU_D=3VyQoZ?1W_p5HY z2|S!zs|8h#*dxofnfZCGo;m*jbI1Dy)G$BR)csPRs?Mn}UPp=l0uaD$z{pBHf z6!vN|_>P8zQ4~0@-Pe8n#pm^ATk*UTKgVf*eor%Iq&3G z45gwwDB-XBZiD3DD%SDuJD*Ys+_Dv?4^^NFTx2zCl#!LaPt5Vg+F{eiFy1gk9)l)} zfumQaKWVG}3j;dj&nJSk5i)PFBX$QH*63_zh25cFRWp?X+ecwo`7K|=ot=LH9$VVw z!knwDtydi-KmoPVe$D#gVA;JnDoQs=c`cg3x+76! zqAGnjPsoR<$%#+)_t<`g2>8T5_T`o4niT5NB;UY7hFdzsr5m3JE{CV&-GYvBIfHVu zk2Gzl-<|Si=?m(rXKgdz=U$ii=4$GFv(tLBw-|8Jfi*rEwkvGONI>*iPJ7lTve+0! zRnE2tTy3r@aP9S%S2;TooC*Kpx$nc@&N#c=RD%JnBj@UBKxyrJfoUxWM2i(puVm?A zh(}Vh%1XYFG8c8~?=i;XaG9e`Tb4yCp~>ryLohn_U)15gxsFhq+?tQ&er9y3dY__A zrtEEi71`+spR?Vu0io7^Emcb0qlGKd14Uh6tJGC{zPA-(J^1oZDAa8pjrx<35!MfK zF&4N_Yrgk*oYJ_}WQ$*B=yVm;!YIHk^)?;wtq?INO%jhJVXK%7QZYnQ17b|~^Ecof7mRG(c7i{$ghQeGdo41i{CVxQQdk68V~yC=b(YIt z#I{SkY>?}r(9P0?i?n3mUSrmXY_Pjoa`?Ti7@ZWq$yW&QAI(LQ^1+@ zl*st!S8+0|*%g@BxyNX6*l6B7N|Go0ZQ|8`i4>rU`fL!FUQ#Mjqg{I|VRs-@P zGwID>!Fk6T4wXv{PE6u^U7*C)J3l;RPKDuvjyo>e4}`84$zDFH(Wc`GC(LiW4tBlR z!56V>-mC{-Ctmlv+@$s<+73Dxdc*%%8StmmTHH4w8)@IIUh>%*9-Sx3YM zeUy=c`a`3A8y-4Cvw|b;^z3YEJ%!-Mq?YVf!lh`XO!TgL>z#$|oqxRyPuv&l)sixs zoPlCTk@CI>8~?+Ds=%|UxJe*&HT`H~V|r(2CucT~{ipPZrp8FEu%g~kAmK6urD{`z zo1A&JR>Obd{46hrh1ukRJe;6yAmRKbcE1M)F!cv0;2{HF$A~)ly}yuPgSSon>8U zGJe}AR%Wy&+u(kalPH@a6IVM{_}*5Xed0EhbXgKa1TLB|ng+m!3!OD*ZrFif{J!FL78dd3h(clgeq|nR``Dz^C4%Y z>yMU8h?DSsI*`q#t3N9v8Z0?IJm-+7ozp8Tg|qdulb654E)*6$hZDWM%x?PAb8jx! zN-i#f*9ezbTixJ`so?FrOYWOL>!bhH_kehvNp*z{`MO6=Zh|y#T+LJi{`Pbn<2ic5 z!@1v#P7I9ze(9x7JL(-fyfwaZ=6e74Dg~`2BbjS8JZ<1!J#%2QW6byHh>~Z z!ft!7qY&+@<+GL?0(t$~sNSjmMP|T-ew)}~B~Q!+HEU%W&ifjV)g+BA_(|mY&45DG z(5!o%=qXaA{F5z??T)S(nH*uxx~ZFvG=|}ch!$}Y?ZB3V85;R;@)=e!>;#fbQd(N>UuME-cetOlohAc!5*kIu(}*|^ZP=ut8k^4e0E${;&a#NTV~6zGd!g% zGBU{s{lfQmtkWqLE-Fh?fhI_n$}m@IEx=V*%hoVWdVla;yhVcP)a6Xob^jMeKF;W~ zlo4kpCk>56R#sW1?+h#N#1$~bv*aI_rHB-E<%`&i*kTY&cVtL}0B;wDlm*0aknX!Yl>#fYSl|;}E%KFIMOiiLx%A?g5v#1< z7mlX32lBN}_gh9{lQS!Zs-pybJ9fAvR2lRMq)=iS@{i*WAmYe#o}Lm77zJ3;iz8%G!tP$+s7wN6HF1t-Dc zyk#uX4W8CK1kKnY5&5aiKZbu3dBj#?>;Uvt!OstmYUkCFua_A*WpCDQ_Sf2dT!Q_# z4i7K8_041&0**FQUsWZ>vR*FbkSq5pCUvW|^n1QcSEM0$msoF3=_lVxGd_q^ z96tlE=sYSM>ev~!%bRa)X`S?{^zJ z&egbHx=p`@V*xFX&Zj4KD}8B5USd_KNQ$Quw~NeyTiU8T_`O=}GRM?T6W)Ic05emu zg@a=(i_mm~hz)dhM1Rrf~?5R>&G>M&!=e6EK$zE@aw4ZcJ$X@@!Hxq&{fsi%4dX|mXb8}vZ z(#kjQpqnv_3n0Ox#0yV6gQ24j4dPNv$w;_~&bsM>;xpz!i-Kgynoh*It~0063fJru zkuaDcNU87+&S5sl^*W%gA#iIa5jZSl&sGTY?d~UkYa}XQFp>$zcv34Jv%e|tWy)gq z-V$7cMX_bAt=LXJ`$#TupFg36H4-c>aELaVn9(_ zw-Nr;sO`AXF3*2w6IXuG+&tdd=|mtVHfe2=$aSvIarBox1@K5p@%yXkxxuGP9VK}S zpFCS9H!}zHHS`{bs(|WjOqs0W@m*9@p4t+g0wk}X(G*39a%pzP_~N7nLi4CkQPJV> z^TnIV_WgA3l|46HfAzoLoq!%C>LislBJXOtsC=jWaHtBar;(LCr5D$Q#pu{Dlxrri z`mK?;|LC5U*6%<4NNXGiE_#>dFHxi>TRN62&2!t(jQ{W2Z6luWUpA(@}VL10zui@!a%6|G~i8fqS$;s*S{<-o~5}>`|(3@xG;m$U<|aC(xK2 zU(`E0X_iobIwqI(b(GA!1RnCj>~umG4F2$PlTU~bn>b=O(suce$^3e`Z*MR0=8u2y zUY7Y(=+!wXD8J$tR9~*Ld7is-7(MrsiT9Uw@d+iJ6CyHHr}R2S%- z)AfGr6gj>CzA=O$82F(I~pS1$bn|<-hC{U@nePd4S@g~e;7-lP9 ziT#%&bwbLxY~8c0LZhXWw2QM4oym>!KEF0&kNN96Dvks5kZ`nH80sNZHzc%kCQ_gR zB32hcUk!ImN8tPo{K~*QRh;Q^X^xGA4}<&G(R+3&NZ4T9tCA*f^vvaY;WZ&$@!Jgyik!!l{WA=6T_jPc=M(^+h6 zMN!F{4t^6LaS>I@rMlSY-9diuC0LmRcAD{ZllT`YEvR1hsy1ij0{oz#eE=}pZdd3b zEM*Vv+0xdt7pRA&Y^x1lc0nT{kzeBK@DoS!jybCl@D0lbx7E$`#Z%;N2ji^wd>FZM zBd+sTooO%gc)rGIsG8r$B~F!D)v-o}Ds*IcqzFAYR~wJ+xAlD{ z1q8Yjjv631pRu#SJe{20*;Y8sju(`v?4F(V4exxwlgVW>v*zTmn70OLBh)!+lVM70 zt#4RbwxU>wruLGPhKK1Xb@{r4N9YhF`JOY}U&%y_@!&Z)LL=#B=} zZ+Y_+CHukb2^(frdu%Pjfd^CMo?yUcX*y*D45xMO&?ul#zv{5~;>VGiLD#ooLR*uv zS0mTZD4!F&9qputBO`mCbo0sSA3H+e?kn?)o-K1ffpAxG^MDOU^Ok^y#k$AE;i}-! zjXodGZMSw6B;t!H6wmxD>HBiPw@fZS#%j#iCY!J{XZ|ZrW9t~$Bd1JALAVOD<=ph* z`A+-!*##k(LH;S?an6X~>XV3%E!%=hr8+1Y8lIkFEEEpRAlPYNezMVSaxFkR2+vzC z2QkDHT>e-yZ}vUtv23fwFEj@yK7oz*O+($)n0d))Zlx<))BuSXp+KcKFXn`L33kL< z9fITwEFU5qY{7#I{{Np`^@2%4XjS6{!cTG~K2y=s6}T%DqSXX^b>n$xDepn7#jQF@ zMa?T8(iI{jN$s8Lyu4`Md%G->uVq_7?okW+O7J)xIE;DDKc!09@M25uXGno5Lv1)3 zakyp^0Tx-mhTx4d_fEel1$jqZ-c<$Hz(s0cuNPryThp{9AIC!iqV4`++rvZhffk2% z8V{mnF`b?_tgY;?u4>(jXjWEM?&Wrx8cG|O1q9%O{q^6Ylq0gx*6tt@@eALYHnoR<@PRBqmO zv{%xbmpwQ3F8#ORLmYR=vPL4$@)k9t&Xdr59P?Ih5GlT28?%DTYlQ$X~7u||8KgoAvY&4 z8*iEy+HogKwX~@t-B+oLKb%C4j%Tvk48y3!n_7+Fw496d0h7(;dYf~$8R^!gnvhC&u+J*8?l36+wTaEWrMy4 z4lw5=%1~fEr^EqrIo0RaFxt=IXm=u>jIq487$Tc8=aTVlQ8D$`leJOW0LO*ph2Q$z zcTncgfELu?nFNECa-qHD-P_-{TW=AF?>{OGqu$6s()Om5ijk4z%?I(PGR7;Z@ndT7 zJq!x2Nz-><+}29tG{`cOWp{28AG!~=S^jgl;2RScg-r^njfP>VO8ix zcTjhb#L+KvYD~@g5oLaPxz&D%b#ZdpX0sGIlrNLxkNGBdwY&cNc-ZHnC*E>x%t4FW zTJkO|vrgvhj|kf5XcI>WI$3PJ7{1^dA8yWTKck$YPQQ1&aJo-FGBUh)IjUK%16V7wje(sq*%D9dZH#n<}n7xV4stXCMJ;JsM?iwk8tyTC=lHUX%YTaITO z8*zy&FJ@$=FaJukM-uZ0hkj9dKqEq`rc)6voJc~OBBN=qnj{VpVR}kQ>2#N3cetcj zWbbQ+!BUgyhgfw{&NRPdVmEfG4{G-uI{4XdcZA=O`{gftakkN?8e`AU5`4Xn(W3w* zO}}e1I&Mv&=#$KjOmt+)0vl~u02$PZxxGu+x=A15!^bq+JMRpgfEy?VCI3C3%>?Uk z+ow81K<`z=dYC50REutH-~nSi2u`B5)qO8CIfjQ_1>RWi?Q=4$tQt6el#^`47l!gp zl2FHjK13%wr&}_H@e)iH>yZw3EShk**4Ayd35M8v=TN=RR0VZcz-dWzju> z!9L)*#DX9_9T9&3jT-C8$ZU(}z)==3s@D}IBg+_w6{~QzG4OEEf%N0K6xt^ z=#1xvJFcfUd_{niQeB-Gr-hB>M#m`^8Gk3-J3u)(qE2`jI`{oY&}j{J}fPSU9`HV^mQR{CY3>_ zoq4sM2kkeqmo8_J>tmOjW6Z1ca|^le>1Kg@`wNwCXh?3u6Ox{6v+fjvp|U9vY_vQU zIe#05U(Fw`2SD_G;Le4WVwjj;7s&tO3Xe{HL!JA1Ld!=}HLgf}t3l=|;!sBH0sE=S zhp!b0cCmm6vTn5=$dS6_SjLo{jASPvXeK1y|997rbWI6hL`)n7>gs})uD%AkPD^oX zF|>F^q9^v(>li|CA2Z$(|q4^dADlS@6AA58*!H;bP! zVal>O`*#_h`7W(w^7E*q?iikdXsavqPlm=bPdJb?Q2_I+7cDRT{p$i8KB4->4!slE z^PC%#JB52Pvp6q~ch=<2IbiGB*xR@{BoDr9OgC$&#XZr|C{D>~-EJ)>XARs<(%GNE zl}pyIetBv{M8VpUyt$$&)9QWmVMrJY7#gTqS#ol7UIbm9j*b>q+Oy2H-Gn^kENKQ^u2gRAS6?0; zZy#1AEc7y<#}pO=&-lzY$VZP}AgpcSJk?a7eQ7(0{2>y7(UJA2nYOnT#&BrY0@FpD zx(HPc;{7Y)XZsP~O}iM*+vr2NXSL;ahZSQ)JplN#lS61$F|d&=K9KEhn6~}Tly_fR z>5h!bgJ_`l(&W0&uA$b$qoS$`kg8*z*+N>}Q9L1_&)D>ZW;ancg*>((9$l@U#iddC z5;N9~>R)feJvx`d^vAeX*+LB5_y_v$N`?u(-VIa{@+ziVi|}q^h$pdp(beYxR8)Ts zFHMNlt4zxE%K=Iu@XDsP?{38w;|ljD_02an0oe}N0H0oZ0b{cA0tP-_bwxPt61&*E z>%*R*th~VUX<(%MH}>pl14Af&Ykit8{n-3sd20k9zx3qBtV_5bT+d5-J+>l3iAP3yi2oD~MZLv{`nuQTd^MO>ec$T}D8ucXPPzIfvgw%>tZ`FY*afvHckxx=|v11ZBaEa(K zsdT*@!`ujPa`fKbN>t$DBVdyxYllUx=f0!*l&6s@`aiG7Z()q;^G}~r!^lV_Ni+vl z>D1w%>6yC&6&z$;{6Z)nm{iIP-K5X@Ma9q5vEjSXqDlMY#VMtu4z3GEQVnK^2hogC zw4q=lhD^RpI)9mPEEYS~SiDsOWJrpsk54%1rlySgG-k{<1+#*4m=@04FBi+IMU7tE zEFT1)NiDXWtqP^-K4=+H=6tS+ppXdfHW}0y#pTVl0crUz!1%A!8kJ;#YD-7j;xAWj zCW9|uwePDhQd7x8k2a^;laK2L|B!ocpRHX>-1Oc&PC|81I|ci!?gG=!@eTa2<+sb- zFlqpaERi*$RqCi;m6tON?SWqyQkF=3dJWICd#pfD}=d`KcVcg=S4te6$p0iW0y zdpo`i%6CeWr@&&6yl|J=XgV-w3d6a$Q28Fdjwu^fY@fBIU%B(n$;Ouqv=L}5_)sxP zR$8*@WH%_c?mpyBQnF$W?yPTPdf}yN5;D0c5u(&|v$VH!oQ5MKp-n=&NiWRIENsm% zzI3okk5x3(V}k=dI#U?&YrdEds1|CaEa!F3B_HX&8hNNdMr;9vGwOp*W&F2V6Unve z7ODk%t9NP5`K(QPOs0C1zo;|&3|y&LXljvg7(5YhoUJ$e;e>mP98)J`d{`BV`Pz*z zh4PP>6u$O+Ws`We)~XYU(mO&RQt5r?@t~(Hnj}@aw-)@dwbU{`Rq+XfNc$oN1v)yO zrs>N;FRt)k-j6{O<(U(HO}KGj@y-MP-_`c^0x4#7Ug{)Jh*XncH}^>R8fc<-S8qD}d>VcLBy?Rw)`Df568G0xTQ@ZcA_C4aOPXg~gd zC1*Q7j%Zy7gsEgIU81j*pN7n#UX$Ls4Uw>X_utRPjz)%Lb{^WAk38B(6DJ9%_X_A& zcb?P6fhxUA4mh=#R~1pPni+%H<5u)~WJhB@Pfzr?V=oRTFnNZPD$QmcQ`O`MVNNAW=@HLNHhsP2oVeaFpe{L@Y6MPH*&e zVE8)7;qNw^SjiG#1F_&x9S46q?$ces1V!{=0ZJPw;9T*uTU8>Um4R#xZu*n=t3^bGVe_9rXLk zd|b&H83=*UPdym^Eb*+HODfKQf{s2VPaUj6t0VGcdFkzL$JGpEBHj=bO0qovsHx@qGG%oh2eB9+WkDG0f)xp(ZYTvx^`ffw%3xy?YWB&Cv7uYC1-fe4l}GOQ!a3`r}bVOPN$3uz(QAf3OC?7fZA8046(7Xz8?&)Bk3dJdE&IHWg`l~PAuPK(ZY z9kBgYz!DQ!8aiOTMRmI}T`GWa6XsJ|1nZHaZ{db>t~%%%XHhQIQrRHxkff<5bHd{a zyuiew>rrKlL`$6) zcKmET6wwL8zbZRpNJ8I!vXE?jT_W8c=aj_w-R5rScy@ z>`I&GY~j1P$X@HKA&P0LN>E?y+v^z~&S)RP7*4m9zd5+sGjBQB>E%fkZ=Bp5!@M%T-e<}S zUU3IF<)v9K6Kj@ki9m?iOUU_C=_!1)${EV9v+wxjWRklkk=FUD0~}K{W#B3hJ=6Fx?W$G=Gz}!FB3-g^NY}RwMuzMykS+P zu!tAKlNC2PTXL|@Ip9Ql?hth?wU#TbfCG|$A#5u8_FJeRuj6D+U!~s@Vl^S|K$Pbb z4y3(@U&zQz&(F4AESdSap;YW`;*yYV9F}Rc^>z2kMvELB;g3U`{jPRmS`lnuQ&;Ps+PdR&vj!zvR%7phOpHhBxqzbcvfMmpEX5o}{B1YiJg=2xlH zXUhjwt;b92qgVda!^8jIGk>8ySZ$)Js#hCy#d9m;euV9?M%{3NuY z06NufV3L>C-m2wb^6kWl>Fflcu3q$6^9jWie3`N3yc^kp-PDCKHn({W=XdG0x+7Oj@{Ck*t^eI_}}-f6a)W4adrp^tg;cTx>kJ;xe`9pO~-o+6cS@e@~7@#K-Z;K zK2mg@j+mX2>m{H}>kX4Su2@cw{CzL{Sd;tt{ovDUa>1S>Vxo_a_6NOy-&t_d;a~g~ z>om(X5KH1PT!Hg_@Wc=x;WgC}!QbrtI2eIqGk*H#8C?M)q*0%8V!}-2lo2)3z|3r&e!Ckz^V7(yVH%g0$cxn&7u=`PmuKNIS2BarOk@m{i2dTaPJVm2~;J)1b#3y!Hu z^Xch%>{c!p5zmn19ndc zPp-Ug{+T~YSsDcaQ^AcpMEFnB?__viv9{o*2ZY*apQv+URuHwpjiCM=uca~ z*ETH$=Ag82sxuNG4TYy>%wWcn*ukU)t7Z5ewotZYB&F?b;@8!0`E3-dzfS|HjMHARRtWq3SO36UCIsR8X2jqi_2GZksY$)+SkY>l9?@g?>#fK z$tpzI*@SCcBjgIX$hh_;d;dPa|DAh|&%NjK9|KM(npy<;J9~x1V3!=lOv1x!2c+ zuT4H3@A)jHwbNH|)aSz#$k}8|8E7&lYAm0%Xg>1Rc&lcTs5KF&G1}6lt!Kz$1_^`H zNId7EOw!PNNvFa}=bdipfgqNTJ36H0`}X1}L_XWR@yA^%%f0q_SOmY71YEFoe$ z_Y$fCO27bhg1wBXq;}hW($-dmUMv4f+^9ZzJ3^8&@&Xr6PtBzBA47u;cyCX>{d1U* ztslfn4hboGJ***fxm#((a(!{IB{o`X?Sv;y$V6aI*z$y+%M4`j7de&M8D`u9M?(z-TZ(QXwZWq zXn3NLn~Y24CN~O_%Qt8D`mZow1J6Z)N~c+)+0kdd`(54p)An~Em(aQ&#W!COzloQC zF!4HG>dV~hWw1g6k*f8-3$~z-^>s?Qqb=Q+wz=fDvvYpI%uRtCKL31~(tc#c@%;Id z(fv{BmNa24NI@Eo>%3d$EM?v8Aphd)^@rhd6`RlNYnq|I4xiukO6uqt3VBDOIt^2- z`g*$=I_w~Ed*^HcjILl$IYv-(8Wi9ld>W(a4x(?v@qK&B{ORgZZ>)X6viMU-tF45& zTk+>Ql{=fZ(vmVV6@!Csx8=1A|Er8_pPj+qX7eI~!c_h|=izvVwh(AL@S8toRcQM% z_mkz7w3xW@4v~1dI~p)9Trprlp^fA*x3KyOQ|1g+xKZeseto6c9!TukTg2CW1N`x7 zFDH+7u1ZAPPfokLRVN07pKCT_n%?9QZhT3e-{L9R^WL%v*Szgp`0MW9ogEW!=7GPafG0@~9h&L9T` zxwnBqf8l9X!CmJKn}t*Xj2YtSwA@X!ELsl_WHDWuhid;WEF`CXP9^P8enoFN6JuAF znu_G9jMx3`;Xt@$u5MulGP`xdQX9tXUHq;6uc#1|XQeyx#r0v};V9Kz{%6NOhFd&Y zTHZi{H4;8_k(DIngU3s2j0O)i^rMy;tz@00bB3Es_d)hg;XH9aYP1x&?vdYsvU{@# zBnxC1@LKL~ZCz|FA1p7QC_oPIuiO2~s*EZRh=- z8I@2hPK-)lZXG@21XiOog8pp39FM<`hgqfG%x}IIVdeM37iy)jwKSygvT@s|7}ytB z(Qx@roIWCKVnQgzlC6git6wo`FWrc{3+#YyBDdQw0clcR#78Wb2v9Oj*F^N6b2Hv-1;p76 z>(;f+HqDGANEO80&=d&Sc~R@Pu5(}sJT+sZj;}_Q@d>2j5))XSx*G$=MqZmpAfQKA zl&Vw^w+vu4%00&OQCdj!-j4D96 z>j0gHP!v7DOIlrxeDyyUxGDi=i?2^7JFcm&7M(6}^WWcHqKR$CRm2uL2R6hCYv_La zX5<}J8n_^>FF|)VFO$?Y4=6Qar2bbZCMh{PGb8c4Lw71ZRmEjdT%zaw^eg6+Uxtw1kvFI}ebVA~6=1Tzzr07?B627K z8#o{rv=!Ozin7Tj$j_I~c&@ehdJR0uSQ4#Lz*q*l&9ZmPjrCJYM`yP6Fu*5YK znjy{1UcPz%hPIho_sGad$5n3Ne*M>gy$|iD!&Nr<3MNu_w7qHWG|>k;x(uSUXuzG} z>Va(;ri*@CXAoBQB<7ss;FOs!~Y5&sFZ!FH=a^S^_PL}f9Ac>Ib$#JNZ`YN^nWv%U(TPM z8p)guWG@XYUu;PUVB0sxH5UO(RWv{e`Oi~_YwI02pQqhL>>Oh*fP$Ut(4%eaJs4Vl(be_fIO&PvOUX&H({6hWtw zTs3^KeKY=X+%PB%C4se1vmOdFBq&mczEH3Ol9${C&Kxcg?mN2}n~u9=UUH@1y~JtW z7}Idq2)u@GfJ__QP{Fu0fCXUyDn5^^w{!Iw<$sc+ogs+Za?xcE?u8b4Oh3h*9c<;W zJ2*JFJAz0RCPmz3+`P42EJVd*A7~zH4USnbmjJ&C>NwkAx5>x>dAC3F6r@l#+YA^`QNNTF&7$85 zvqR|YreBYX8Wa`nkNCd&gsQZx{GKs7Sa?5?%V9Pr=fQtv8VQ;Jy0X2qQ(J(2`4y7t z>)Rq1_G1++HW~{s9%GT6l&FD*N}01YlI&^ay}hCr>&H7e`)n}E;2=6KG|-l)aHAm3 z(v08_1K@@taV2(_5l#U{L@ktu+sPTg)_jgn7N5}-@~UQse7`{&D<#nUk%PWyz|C2H z-V^lxVk{W0NM6Ed{a8=%04zJ<4m21^N=i=6q$)1vwgAT2={Vf#4| zpiF9bBo2{MBS&ZWU-UOa{y~?Xp6;JIagr_PQ3dK&@$p@fQ@5Wdg&=0EL{MwZM_bEd zT~`VEF*ofv1NV+vjvxJ*G88AaG&s$036OWX`=HV-_4EpV!7Zl_`?#%91BKt#_0kFP=* z+PS;=xt$;7YClazZErt>1Se(OWVCB(xY1>3lf)Ykk?NXhsSjcuoU~oZ-FH-G!Qk9J z^~YqaDk>Oe4_Sf5o;#WPzt)MC_UCkyAkx$^Afm9@!6jyWHLgpPZB$(D({ypbG_6A zFO-f?56SAt2mR#5)Y`tpWtv*;!#Zn)rHLnTq-MGvb2%w)1{429fkz9H+yaEri$ z3VBV*f7V5H2`F?9L%Gc=V2C2nt?fTQg;nj;P`1YH9u7{|sM%k}QwRRs+U!Bc8a(E; zIXucx_cmC<`T2S1CUuw9(N!oJKGI^+9nS-t4^-j2oN43T3neJLH!v~co!iHJt{FmbT;t@-}OFY*RIpA~_<#-m21gY7MEl<2%LUP&(VPzn@Zy zj>i~rVa+KopBR|)n@;m^#fz6TgTYT^O>_;*RxXCTKiIqP?@!b`u=KtQa+tdDcj+7LYZ=Kr&MS6lz08b^A|FGj=T-Dmz zwTCf>CE_DoclOAbc#gl*y#RihQaScn%}Id${tZOLLN3Tn+P2ax&at3oC`_AYLW4(( zhFu6_V8w;CM9sWvuDhOLFkwDBI;u3n_6m+ttU{s;2PRTdupg+j2PbNE>}uY;^$|_= z-Vs9@d}!DVx=Ro5r5(cED$ID~VQ|e)ombv-hJRUN{Iq@h&&&((Z=3@}8zN50Ps$SV z9)&JC?=mtsv#2btmY@+}IJ6M-IW1w3@gsIywh7{Pw$xJEH<+wYrx2+Gn|U9YLL^e($ckLUXrsq4IHnwvsW_Qv=D95&tWc zdAub)N>7ievT|~EcA{pmM*{xPFG@s|=gy{_s9u7Lot{}yA4L#TF%867TJn6i=(we- z)*IJ&X_xP-ZgWT3$S4J>Y~#OqR5`unXYHAYws#=Xdo!6sI{{p&qSszpzUK|&VBz4G zdqXH5Hr8+(3?rzx+FM^Yr83t{m?`3;qb(>2tx5+{faGx?lAw(2)gUXg7|@d}`?N`A z9$GWCja@1kFKcN@mRk%QHkQS@$iYHB?up7mcxAmIkAqrP5}VolQ@ZY-o@!e;t|Odm zsp*1gKR^&v-x@Vybno9{UMdQlt|y%d{CHAodj|&WtH1a|5D|Pjo0ELhAYi>6!>!e2 zxB|K&?Lx%NTSZD~E~82aKbOl}GhZ6lbb=xpp7PK{;&)cU6#L_7crX5H#0#}eBii931RLh} z;fQaaS=(b;IfJ5!dURS->c*Cv$HBtb=lh4D0_8{>e=o1~_^H=G8ELghEW+~R4|{2& zN_w{M&=3(|1xtHF4?_LNEDtM$b_%;0^tIFG&$3K-Q>Fc&SYg)w$kw7Lr)CXtDrCU$_ zQcP7Dljtt3!=mG5{}1y$<^hVhQ01zTFFBHaDIuqSVH@YKnv!S4$v{P8Q5myUv|w$s zD7J`#G_xCslNzK)X8Wcg2ryxsJ<{f)^Ev~qaQ&Ae7Cn~9Q}~On`^f%3sgEz89Pp3Ut`#9fXr2z6Hrfx~O{Fn|@su=3FcdoEnzG%f;MLSA ziY-aoe`R57Xa79vFfy1iI;&X(GelDFU=&{}$4K8=|8yEZ-R{3Rva{1LyyYg*_U_Wh zW$~kd?;@vcF@<6%$W#`j6%u|dt<5&R%N>29BdiK>J6_~fFd?{8+yLPttC+)}Y+ld@ z=E}@yHpQ8cb13(Onc%=JfgSu6P!G}avdW}+`aTL7X#h4pZhftZz z@YqvC>lSIt93ThWe)smHko9@n5Ls3l)Ij-QMwEcdG>%WGV0eg;IvFf?0BosO|BR9&XdUcA2~6UIS2Z;hzh?MVPYrW1GtV*c2*uE-lC)g1z0#7 z6cuIgdHGrq1V!?&uvh=Y!1#qJ?v7E(W7WV0*=o$8Q6s*@oPBvp#3}OAL~eVT`2o&~ zFC#cQWIrKu?g_M)5e%}xc!~0@3URI+xf$7{FqEW{gHlCUpG17FQ|c7%2kYXpST5G{ z1J3rgYiq+(Wqa^sRt@^pwCu~7dO&{*SIl2IBnPX)h6LdiEG9&JG(P#ew3DndT`0G? zvnO_e>$&VVh_puEQt0JeuNifelmC&5o@K@4Mktl^MQGr4pUlY>mCVJ$QW4pU7D->^ zG;s0L3u~td0ZlPOykZbB$Y5QS*`l;`6f)I@l$ZZ4x15wj8AFBR_QTmNDl=H6B_v@$ zs5c{hC}%AkTw)4MQ9;w|GapV@CSnV{R#)Al*%#*==h|HfJ9CUbENbrgeNo~te+()_ zp?HbSy?>7Pz$mP zNF{h~+%S`RCO(ayX%L}e6Jvpf>$8#;$tlDoy^8X7oU$*z)8cg5gFXBxJ0wPV`I)!2 z^+6){hr3ku2xLVDmo=TV*LB+)#+<|;7pLoF7Qa~$+|23p-uim9DHpZ;F4B&(gf!E# zY1kgj?O{yFC4qXUcNrEA84kwIJDHsZn@0C5u!a39?pqt@$`K`+0=n6XybyP2Tjs&D&IEz&)hAKxlp&rb@yXc zOS@;vpsZjKl!6|y*x-LL__AG!>7|<6!O8MSZVT0;KgV63{+^pS2U62u-xgnBwk&xr z$tqC8{bqa7Fj5hW4C|Rv>nc*qW||KVQdYi6_2@B6?w2?~oAMmCxKO^MU(sA<6#cet z+5#+yR2Ehwn8l@9EB*IW@-?V@-ObI*JJ8y*u^dyO=MOY%Nde=y@=Xa8pnx*6hV?NP zQUO{-6tYrSr+C5O33VjnKA*sTa)yVeClI>SsB9G!xt)?ZO~ycSXw1^epa%TqQ|0xD zyy!ZBxfdg#QtQVvFu8CsrH#n<)?5!w7&tkQvf<3)gFYMyExy2U^#d6J_$+MAxDx00 zx9C)s)@zfAFR8eZCgAmJpAHa{30zBW-p>18Z;STdEG(Za;Vu_TKu`C-3n+Bu84CX;L_26i+SvyN9HYsWZc?J%|fG&wti$pB*uT zOg$9;W=f1$0Zh$sgg^JpTgxx|dGOAxbP_P)BN0T}Yg)H%+}P51+|6>myiu>}J8QI5 zu8YL<<7T8rt6t$pT`FL3D7j*3b^ou=KQrkOsKR+VMUhOpGVD0HHajPtQdVZSCYq1* zHdhxaq(K7Xe2J;=IBdL9dV0TY_irp#Z@W##=VT#8Q#xSN+KD$DKs{Go1=^3!>{tSJ z#{&1R&zuPR;V8R7d1O|XyY4%+Xvb_)F|J!&!QV6%-ovd34)fkcoa{aQZ0}QlAM>BQ zr|e_vsbY~cAY(V_Y>-oSX{Siq1*;JV#Lf z35@=eLgu$XhVH&e+x1-YTg1zeqjacnu13^a?WiMzqDDw4728SAVpF;KOQ`6~=;Ypp*VbB6!uTDLJ#OGq&@|oO&=$$_p^Lv1qy9^F zuZ<`2qSKOVPq%!p7kzOS#xiu4?x(wh(*rhodUQW#ojNAnpM?lw?mC)UWn2E=1NLRi z2AV2MRMh*}({-ne`-mry;R(v3RC!qr3g=K{k&h~~F(DFlW(WCDXv?_i`MG>GPtty6 zZRT7CMRYpgCQDUNkY)cja+_^rG~p%oCL&Go=^Xy`WCf%AU@tmSgO*GNs+esFdcP7* zwYLrAmz}P&0q8Ejy`*kh<~KRwiMICdy<*_jcg=W;%3+r6Nf6^pv>xLP(`~Lna8UG= zf-q@GakXKE`=4pI;t5v4hoiR-etLGhNnIHQo`02b-Jb!Kxu6BZ=Cq+f(CAu5x-n60*NW2M|qKab?H{Lz>+~=2)F0;n6Jzh0Uc?yk= z3~{!+zku4a_J_q{YQeCJ#T~zvsQ) zs@i^&T|`746b%Q(RolotG+#R#c}vR?|01pamMkMjup*L@m93S6T!klXMSqjDK!eTc z`<0h+k8%)Lwg6g4Y_WFy^W9@xDNY*_hgkK#dJ%XKX{__X5|KSxzE`~243!ExSRQ{fZ+|5m^{qu{I4+4MDg=eH>*X_p zK(=0eGo;T{3In>8epGqVgeyH)K=izUu*!baf`km5zPA#Lo@wNA40FER03xX@m*F*V z>66_QD4Iu+?c#vj>8N{mx2t=$ktpN0`)i%}?WlvJZ)CPH2&Olr!zDp7NTO8UG1 z`N&w>e=nW>`0bqRt+)9eYmO#Z*G?=L#|XSvIySb8dslUgXG0R@kv_Lbf%Zc#lx(=l zPH9@Z+j^(fQ5%(XTyX0#JS>Sf3@TsDXgvQZ8UjgT@HX^^CYc7K*n?d2!k_3f6{Y;e zp0kqX=jPdR)@!33MU!foEuPdubB;hmAqH^5#faN^>}xOc`toqls)7E>Czim2F)|`B zcw1fSODqu_eRk|uYg@w}xLKM1>yVrpe%8szVTm@;=VU*FhJn~O&fiqOg7!)Y)Q{`i zv2@p%0LHn&ziEaL{+d?+U?5o8)L+A=epswKv-SqKyGwnCxW#6G>~eG-GzkAEbDDU~V9ot$ z{`AnPQjvT$?CxB4aI0nF9<(XK#hqTN>ao?M^Fac7?izn{hnk(IetT z?mn8i%!B30_@sg=ht%;9MF_u_DhSvWaB~A+$v-CcA)pt5k%LpPW-KC8UimZ^97|xjxsO zZM^Id&ePCh50UL;{6lqh;7jJ~D^HZZI=~HE=h4bpDSBUDZP!kio5$~mnkKL*m(`0z z3DWdJL#e<(T}KdW3Gcr`Uaai9rgGZD*T}MBu9y%N5JwOy<|b5#x-X1n62%HEX{L1| zxYG0gv~$PhKqc&j3-b55C1rXR!cC(W=Dxiex=}!!;lsuc%v8~cTjY z+lqrdfrkQsa^(85!(KBm}g|bsi zvf!kxI4Ylx=;RT4zI~a6h1V#ZL!%2lFPWYE*26!TJdD6wSv6&G-zgzgGlm4MMFy<- znkFF5p!}(PXcHeoT)m-93<6`IH;EOHK3J_D<$05mAC4-`RzYSKL@IF~`TVE{)QuIkurL6&NdiKeyH`K% zHYh2HimN5d4b!`OKn1kLBJ^CWn6}ztbrV%Z+Abx6^_o zF}&sPgJ8O+m&Tx2TAysF zXZf+1T>Cq8wt=8*qnTg&IaYDIjX6;@p^NdEj7)ZJ{(oiES|B&$SWtkoVJ6=wsVtv_cLNU`DNyDJFreCl~mU5?;Y4@(nqx-G$$mko;=2KT#bu%@PepW z1ze!chcjJ3661Hn++`7mRi2|m3e=(Ptk+29p&rL=M)^2|V*N*jAuQ_2r_0TsREC@D zZdoRAb!E{Au||wFts+PPicYt)L~TX{f#4;N&ZNWM8#V$)xrXOuF*T$;!sF8mTl5Ge zVjEyz?OF3~ORWbQKCHqa#zsIv#2kLFi=$A1hx)~&g`IDHhV~$==kegUK}&6IQSlL; zqnwP&Dykm_l2vg}>UE6Q2LhV{s6@FanveaDDZUw^;_PnTje=E1_Y}#cH7P++A<0qi z*(35j_P8_=1-jOs08it*Sqx+?BA`9vP8Ahf={06yw$%S*_cQZb=Xcc`AF%irNR!jk zLSH}q{E;kZW>k7bdsQT9IP#oBLG31^;y-@4g4wvCC62MuvjKyhQz{L5WU^X2+G-IB6seOi!0Gz6Z;PLJd*OJ$bo`z;c$kon#KP?&E2C(!Za zHQxv98U7NuKy`EmcARcL!fB}h@nD|yZ`CrKCLS^Lr5>O+s3E`c`0*wU3g;9io_E5$ zjUJ!y;V@At2??W0L(*WCtVHJ2+J#hz4qcNA+kLy%{h`0pgs&Z^6D_AQ{ibSq+kj($1)=0hQe+SYn*l&mfgzELPm@E^UmJ|oC8xFp0YX+7^j-S6LJe_M$=XXVUph~MmfpTS5SHj%QE)JeB7G7_a zkWoSx5LNWfGLEu`W)djAAtQFYOfOpb$Jf)T=4}E7k=T^TLy$b8l+i3BYyh@SiErt3 zX>X}zHUrYzckH&eXOCNtL-*l`Fw-w3jM~WObT@-pyQIpMq$!hFF`wfXEl6GzGlJMo zKp^iVPOKkfarwm0Gmu*lQJuVYOXktb5uMq)h_Jn4wFR!qG}PIa-_eL9v7)AbZs9@f z2NUn3uF#h2DS%LZHo8v-)zPJu{qDZGbkIc7S#Cn3Z56xhIS~7UO1}8F2mpPtByv}r zT~AG9N>BS3A9@q(kMKmVk#(U1skw@Z#hDsQ4yu~?JGWtq)ZAgZmY>;qzZlEsQ<_4_tIxQYl${Z0SSN)<;P9)+f&t_kWCdm+n{S&tXf z`KQJ@^Y-2Xl;PV*Q)dP@$6ptQ;NTohZgrgxijg6jx?sq=!Uyor3htt}a!9-x;Gicr zVxq*J%ng0*@IH!nx-ibW7sc*;wiozMlVLxto`Rj_184Q^L`W1nsa2bGuu62!J0f_g z2b}1vM~o&JrM=yMGh*LcXVODYvF2f7KMh;Kym}4Yl%(OKR6wTE{W5_ikht{veDmv+ z`eQ9{NPR^3LQaL$DF~AR?9#t#7jH&s_Y2;Gdl9L{dYNzTLWgzYXgOM;w}nPl_w{Bo zybj3Y$mmJs&=%@;6-xgFgQuDt7jjw!2Gz)aRi@Fr7uuA1ad9^BIy*04m2(0ngfZn) zh%_i5`TCU5Iy>u(=}}KOzVbudi$Z6W!z>>}gaSaK-JD5>H`bfwIi4=$IL|dg&dcsu zlPcK@Z~ZUn*y$Q%GM@|ZUM;*BMNhFDF_|OZKps##;lDQ;=o-yt2h+-5jU1BgE80DH zU1cB0p_1ZaK_^PAHke`(BiW5D(zT5=R$|oGbos8L|RyOHrx&Mxa z8#&9RM&s-8Mub;+?s5=JmJ~Kc*dYrLb-f;1(wod^m|a)Nd|PC+?KUf{o?BdY3=7*; z2#&hB+C~zjX0>T=yR0ZUB32+9%#N*Twu!^>s_(ZN@U4Ota^=~+c%sTXKReV@<=wvZ zplzS+8%!mqHaJ}Pu^0g_zVJtA!+}yaxi49o5Er$v^6y|-+S<*BkjpOM;o%`)P+JQ? z8YhYsbKHSzI%H&$6?&F&wBRVlpl>(>LKqYR0WaC5l6Vnoh2t~97`gO8KD~-TzVzm* zJA@(hC|I%bR(L^u#r2kx14KfU~|-;IAiCOfq62b zh>F5}E9CQN_$T1MDDc|rD7?$mTrgLhc&n%~<=nFtrk*k+{?ckX_nbC*+?(NYQgJ%k z`u9n}w$nk0?sKhlhT!l9hh!M~OFGb3b*Dmhajegc{;v-_Sgrut&dEN?gk(!mKohb` zZ_|ZAvAsy6Y#2|{ewPqtwzPVLH7J=KADJt>wt8~rc56_>u$}BjW4<)J97`wT!r4p zX4DOiA;v$o*qK;&J1Yz&3F1rAUYO3A_m)UyME(>miB1k;U!S<8F~X)gUf$3X0!0KU zs3c464^8ORBa>m8$w?|-0HL_}1LFhy{&E=pPRDtB|rdcvl{ zeHQ1fUpFJM`BzI#`F0TAh_sh!c5YfCKgV}7&ebLvRCp|{UYrUJ*OJ4e<>QX&8?qQbg$M`na^HLjqqwXYB zw7ZD;Wr`6+PSeYW5xD4joCN=P*J-zrP=*M-!lH_~h#HIGcM zyS3NXGclGa++H4`2EDw^E@x`*Vj^0p2Zle1341=mq#Y+DL=TmR1hm_??JIuNufQ?R zZ+o0w2K-4?Y*$jw;AZhp&e;%(>|)W*DpET9rVgHIaL==fdi#awAtA^6jmt~oVZJY+d5+)v14OaQhyZ%RB~1N9 zRfui!Tza>i0DwHeYbdU3wr0yM#$F;U=eFMvC>-QkY$Cy8qk%Y z{-%9+?YlnvHEwjoz*LS`_Ze84u^+1Jh80%b9lbiBz$&I$)ZpS%%ja3<+V?Iiw;yiW zn|9I8eSha4c)VaCbvsV3Kx#A@W*O(L+4&JJiJcr|YHRc~ty2nuhTsgz2X4r<3ea$n zkBBQQcbYo{kuc7ImrzJ93(6I8Iz;S`e$R9N36Wa#J^u!DR`6KxJMus153sUh$EF-r zw#q|%3SxU~<;CBphMXHGYro%0W+!l`=nz^_13O0xTkQ#K6lJ@$`IlQ{GP}#ROoQ%SQq=sF4zu|K=SK#R9Eyb`>zGas7TrOyyI}g0v95)qHuN8;ucl3wu=;Rt>|06h zd6C1_x3fuvU~d;(Ge9b~9k&s0E65d~3d@888IB$hDi{{JPee&a3K<1QX`+8OSm{j$ zQ3m-E-0dn|!Q#}4g&j074iw=S--1=eo6w7mWaJHez&F|O7(NV6iT)7ms-cjH2p3Qf*CzK%gms-qkfR!K% zUGzf_ue#oA(T7-Etgox+O=f3zw}n2dG@kgqUKdleA*@=rXV?PmKYw2u?Rxg#>(d$L z`4;KS97Ai*<45-?J-G?0oF&{;AhV+XuC_Egc8`oi_t{?q29HM%OtNyG^^XgWs#-e~ zCQrMuhrQAtc4?xM1D4P)bZ{mL3~;6LvLbEF;p>!q4^+9_qTWz(;Q^slCzY;Wk{Mo* zx`%*6qi~x4Y2GbL5QR2%wo$!v>}#S+CTYY8(iguEl0)7a>!nZ*R_8Jwp-v*hFi*Jo zUh-jckK@AgFRW!eJR~kRUS9KJCnsO99OVTZypTQ|(0q0%*>Tw7K8J7iCR$7HXZAhv z_o_DR(F|Q2l5A`7+!eS!Tyg3+*e13f{3|!n__RdqIGs!MJK@lj+UuDP-Scd}Zl&DYu?VE+$#NeSw}h0 zLW{4Y_D{?`2wQ7y)I#e`QXYj_3HGdE0vjpMO;&z-^@H?|+2AbKZZUZ zv8)2I4by@0)zbAG%9NO_xrsSD`n(E(R>0+Po^Ytq!w=@Z#hT{14}vT)$2}CIC>q zYXiI7Ejd}pkOU#u_rjkj-_JxCITgM+(&VbK=^>Q~A|$0@KwK%7dPblFx*1Go*3n2U zpD!V|(ZqYjP;Uy8Aw}GJXoA$$!1vPXF8mSazBP4jQeBkD)OFd-Mr;Ii_CWM2KQ#~}vc{b4>T`OX61b;?^-SpS+VzHC z0K5!fKo|8oehmT%J$ZDgN5rL12Z<(`i|JGW0y>-$gz zA}fI{eRRyaD)=ucvJ+}rZJ$%rP+!qJP{XI8c&T`a=1sEZ&BKa=sMm3MnhYrRYG_ng zkNb;XI>iJJi4y1!?|kw_sT*XZFv1j9>+jQ;#4~8qIis&F19(8Jqxt<}^+Gfc56FA= zzTHD(*$|Le?~7!rycAj4U^pmPHO*U7F2?$5byHnOD4}REsMRyCkbe2E3Ja{Kp0am+ z#|#k*NjZ7Hc^s+zY(`jJyO^7sd#_ADL25Qip=9|XA*vJFglL#&$xVC-Vc*Hf^$dcS z$gwqB0tY6xcTGR{OX$HoVP0=N@Dx04wF2!YW_16dHvyVVRJ3shCuU8{Ny1UY9BeP1 z9|M5h*S3hj!&Y+42D7XPRMmm^2G_W|6=S}SVd()4>g+?+r0 z9Y3kkMnY-~m^Nypxzy%<}b~$yvikU~g@a_AM5OO$a8yi1GgPNssUAORZ}>hm9PzXjv9`Y z1L54wzeKMqVW!*2sE#}vt!u@>%1J-(lLK{dP^w_X^bW!X$A<*|Q7eZro?-QDRlVh# z)jxmL%W5Gl(K8MD*(hQWbF>SvRL}I&IjL_@@qHig;)Mz9 z19wi1k8?B$qk;QrR2Q8ul(HpR&ieaq+$@ZvjyN^j`_%`O1LmJCtIh}XlCf}SJYdBl z6`tUr=5qZtYsI_+HI)3UPaS4QR%@XUC?f?Ul@QVjU6mT6a-+~R*O-uMrqz9}TY|>b zC!Ad_Dg~wz0Mwj>^h@^4{cbT&+>;Ua*{T-OU^YPhfK&b`eN~-!xkI>Kh<$xBuGBF_ z^mvr>2e`v18XoQrC7EU1l#1oKzgY~$87xpDSV@)Gu(fz4>$+(H3C<80g8^hj!eMB! zt820%k;wAEv*nUcV6(L9rTZtH1JP!N=eIG8v%mXWj}2QKGfB)R);C6S_fsZLPyE^q z`Nm1(-LaN!uSD=k2!3t$)m0|*k)(PN)%`0B2w_r6l7!vYRawkQ1IGfD$EfI@x(H+8 zzv4?H6K5s&?K(E44Hf!&jU^etX>{tw`I@Cpe#e;O_CQ~k_5#_s@P?=)>x=c5$$Z&rnCw|er`99eHbb5?@b08-bz9pxw&SRx|YxzASXhBI7 z)EVSUe0Gy=+^5lYLrOi??eWnr=4o>R*UJXXQ!Ob#!lxju6}4!uxkq>;cC4V7=h-X| zXg-Joz<7Ci&Pa3|mQoYb_Chu|$J$#uUvre(yd>4tgf$W-F42B*Frk^Bm*dtv_xx+C z@3A9jFRyQYKjr#wzOO3o)Jxm6KN|@g+8Jq4PX=xjOcC!X>WezeO3>-*sVDP-K+AD^ zdU~(DmlrVxBTb%feCyshf3K|-k`S9Y#81_~;d2}0bL=uKGOEPZXpy(%YM2yP=(!Rt zyqAu!s2G#4*`+l$bx69K5g@o1gf9!sA!3ZQsZ^hMg=~7oALaFF_Hhgb9cO0@Dt2T94!qFPuUa1;vP_g7>|6Te!gXH zg!D67^J%}kyJ+rrQo@jL9p^6j43zQM%G(6RDqD?-bz%JnJtWByb$6&#PgdZxv0J5n zL*MosfTsK>8sdU$t0oQ0!XjCrH0YdBD?Tr~Y6nBYU+7JslWpE_(>ESxqXB*|XBw;n z0kR3oxUZrk@{s7%sj9Z4aa93H7Sh4t%Iz`d&-LPx&lFAL&YR2r@jbi~OXa^7T~ySj z(YQ?6GwU?}XBO{Y|)jdrP|Ki3Oi!~3eyHI$>zOQr(x>3wQTR*tz7N$5&C+n zx1xgjyWSdGlpQY*pbB6r<;~>5HGGK<9F<1xN6y*(6Y@Q>bWvK$ou={pF&d4c2YF$s z)w_8x9+I6exx?0-!G2LVy=o(oJ$!V0Bz^Ylh0M_} zoxTr=OiMG2!TpRR%gber+T=U{IZ;{DuN2v$lDF(Vj+5Yj6BZO7u=rSobE7$Tm=`0L zNsu%udimA(zwwJu;&o4vrqotF0z+bL!!@8Sb@k|>Bfa9k)ojnZRJV+bo-Ge~`8W#_ z=?0jc@Z*?wJ)-jxLO7O(TaHd1DGDED?TnW*OVNgOxv=4v72fHkHp#gOnmoJPbI+N^<65sci#TDk2Dj;P9l)_y?!+w}6acN<(%!RPt}j{Vo{Xb7+-HoRn^W`Ry_ zD<6wA8dI#$I*krROy+;rPIi}f06cn_jB2OBkeDbEkAN?CTu;B2l9D)??>P3}bhtS1 zC6=g72s>@?Mr1NA;hETEgTCntVKP#^^F|32CAzEvFian1Qlfu6ytWZYuCk8ElbB@z zGD;7L6*IIX!2lvhZZ^bhmm8}VDXc#4dzd8Je#{lFC90>LDyKZcru{}r8bEkv@8A9L zO-B0JWlx{Xzlx3{Y_7(X@OHTky5R6Y@=4J%f|~Lcm4s%%S{q*&OfSdP`Wd+{U27vM z$o@%zM20C zr?ab8%^UIi76PpYm^n$${lXMp>Aayn0wFsTkNYf;riD@v+5sYD%ucP3sGgv;iz|Mi zRr8>XAZ%=M|Kn8(Xun%nRON*Cp2IyS$Nd#sX(PT56)WZF%KIQV*50+w|JowJxvskQ zDI~6O1~VZ+eH#RFqrSHtnhfCsSziW7|Jl0SW4TtD*yt`&?ewn5<`W3l?G!WrIO(vg z*>l%-K?z= zHfqqfqp>aD`ZP`T#Z>#bM}>Mij8|LXjd=c7dLed!=j1n-FYgd-^nlo#LKm9V#d--;S4w;$m~b6Tqr=cF8nqgP$TI6Fk0s}&Irdh`B_rkGARBQLc>^-U{kg&RHWm25ist|Q@2g3*kVARU&rJ^P3(irN~r-@G6q{BkJ zjGL*`RDu6O1zJxoJ1ro2)b$Q6hEMm?RCJzhd?=&I6=VQQT29U4?wh-BRyxGz6iVu7z)o#e~|jS;&{2W1O9 zfWN9}(Nuh|vXa^b^~dkA7EG>QP5&*@eAI_NFq#i+flK5dtnZKw4UQYpf~F4ZWIMeT ziokMudY$O3)n#Mjsn9N22zu)265A-na)HX|>I5Hl9HvO;c$v0<%WTA71vK)JP|V?# zOc!z~*nr2U%vPuoL9N2$%_@7aY`rheZsmQ1*#w*hMgmEm;NFod>36>jiQ9KY8hsL_ zU4+*T4NOO78f=)X;ZQMW${S#FwN{Vql?DgjwyU?_|G#i;y}tZqefRDoIg9x#+rxn|9?XckL zIFh9PJ}~p$2KEOlJC`~RUliidl?^sC4bjz;e5#dC1v*?FyL6SP$+HsTNLOVTrEaEGT}?dr%)j_;Hh$zmo8F-;BBH4k~2;OB%62h(K@hQfBpGaCPD*A z89eD88)b?9CM#D6g1fBbIM}|Q`(FS*2f+9!f#l>UV3ln5}! z0$C>zhohnQJW;}0Go39L%Q{oSS%2@rG*PT++PZFvJo(@gKXmQ2?{SW0*`ZLRc`6i= zcZrf^UKT~Ru~B#*;-{jnn^jdewZ41zzR{-J&HBA=qfNJyU)>sQY;*y{-FxTf(~1KN z#5n>0L%`VY34w7I+QwQ>MUfV{IvuZ$4@XK0>qxhHdOl603glbeBv}^qJe>PD{9&92 zVhG%mi^Ix9#wnfWoF{37VDrt++rZM{bd@>pg%TDpgLl2Ylo7=-* z|DXSTG#t&QGeks034#Mh4jgz{iagtlQ^nKMd0p$xVJAuDyWMhj!Te_|2!(;da@7PD zq=4bDOGH8dBJ{eMcb)?$AE%B%AZTm*!|ut0*>cf5{OAtRg*zasDg%#g4CJh7jPrp} z@SXw_{lTC=92jG&`67`tTefGDdE2y$#e#9z+T7IIcDozKXlr#M5YuEfU3~JBPk!)& z@3YotsilRjXN}gK{wN~LqlhMMg;-!j zffyNU5Cun+D$!aq4p~+Z`LYb&dr!oHOC^&uwb~LRB_#rCqdifl)3LTC??;3F6HmO) zSPO{WQGYN@(hOJ#6o5mTq~6+?@~EoivZ@}Oj1e(O(%wez$sc(1%0v63(SU_ut#>ZQ zOOb~_F((^a8-ZQ4k+>*2T?A}(yE*KA_7{G(zxBv+vFH!_fAQyEzH#IF#zsHppFt?= zDKM7i9r6}AaA2w^rBcx^5*V8=SJT-!aEx0_ZCay^kRoQGRhj{UH3k5)vRKWhR~~sR z%gWVq`O&AIJUctD>J|~wG~M6dy>sVo%zhw3ue-rHT5p_n-aBAnIz8tAWs!5n`x_gN zf8g<-{ty4Ilb6%;Sw!OkNg@~Xrqj&>g$rG2BohY~7_l&4w&V!_ zjr9xw0!QFkn)S-;=&+nGn&+N-Wj31+2BYtM=hY-p^Z7yw5##Uk*;FR^`E>gAuYYs7 zVr^p%4!5&3T`cP)QCp)P=X^LCAWA6(5gF^PC-T%ZdX2yXUR8#(kmVV24jc{+Hmgc6 z7tQ6%+ee2(WBqJabKnmh51U3ONpkC*TYvP2e=;157~@0%RrJ2#VliXHP4P)yT_yAN|-zmWx%ICg+pM3*Y>^G|h~)EJmgRCnE9=Q6@=h zoi#=SK$ITGd2n4<&Qaw3GsdiSaXt;4uhUn7@eqWN&bpo5y~Cr+jM0Zb{6v4FbNkjk zL~za>A0JwyZ{55JoCl&J?+BslrWOeE#ca7)oKMeXqP9jOV65NY__<&Bng9GN|8b++ z)7l6jAlk7m;3~lRa-}DeRnwRvSDbUAkmV}R)moMqmCNs15d({#*eDdW&c`TOoLMop zR-#4-bJb|;JYH;}7-#c&qqTb%))`IILAkbyA@+>@`Wm31b`%bU0F*G;>*xjA37fBm&*wzoEVWiF6!ZFVmmjt&nt2g3~j zVU07|L^~=1NQr#y6ktl?&i2O6)`s`N6B~_sd7cFpe&B=0$A?4m-e`9=UhM63C35dP zDEQjfzCJ!X*V;5~n`X(;;bF8pE1AUXbzFEMpm(lqTE>_Z!jpH_W@%QGrEXh5K+dhP z2tp-^wXUivq8N`4&Uqpq$ZOq(Kuz83@9*#JZAJxxcXn@Ydo&z)?}>bDHMCk=YrS>f zdhtam`OePPQy>4>PyEC`IzHa3tF@@%T|ms*Y{)J+scS~oIy}w-`~SyE)!(XpiID&R N002ovPDHLkV1i~bjZ^>t literal 0 HcmV?d00001 diff --git a/src/components/discovery/index.ts b/src/components/discovery/index.ts new file mode 100644 index 00000000..8639b064 --- /dev/null +++ b/src/components/discovery/index.ts @@ -0,0 +1 @@ +export { name, apply } from '../../discovery/index.js' diff --git a/src/components/discovery/locale/en.json b/src/components/discovery/locale/en.json new file mode 100644 index 00000000..cf67c049 --- /dev/null +++ b/src/components/discovery/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Advanced tools on demand", + "description": "Discover allowed advanced file tools for the next model step. Always-loaded choices remain available when the discovery entry is disabled." + } +} diff --git a/src/components/discovery/locale/zh.json b/src/components/discovery/locale/zh.json new file mode 100644 index 00000000..b9226855 --- /dev/null +++ b/src/components/discovery/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "按需高级工具", + "description": "按需发现允许的高级文件工具,下一模型步骤可用。关闭发现入口时,始终加载的选择仍可用。" + } +} diff --git a/src/components/discovery/package.json b/src/components/discovery/package.json new file mode 100644 index 00000000..95eeea65 --- /dev/null +++ b/src/components/discovery/package.json @@ -0,0 +1,6 @@ +{ + "name": "dsh-ops-discovery", + "description": "Scope-owned progressive discovery of reviewed advanced OPS tools.", + "icon": "./icon.png", + "type": "module" +} diff --git a/src/config.js b/src/config.js index 01cf5c6e..5471b61d 100644 --- a/src/config.js +++ b/src/config.js @@ -208,3 +208,4 @@ export function resolveHidingConfig(raw = {}) { /** Independent PTC row configuration uses the same central validation entry point. */ export { parsePtcConfig as resolvePtcConfig } from './ptc/config.js' +export { resolveDiscoveryConfig } from './discovery/config.js' diff --git a/src/discovery/config.ts b/src/discovery/config.ts new file mode 100644 index 00000000..aba4e641 --- /dev/null +++ b/src/discovery/config.ts @@ -0,0 +1,45 @@ +import { ConfigError } from '../config.js' +import { DISCOVERY_TERMS } from '../capabilities/discovery.js' + +export type DiscoverableName = keyof typeof DISCOVERY_TERMS +export interface DiscoveryConfig { + enabled: boolean + maxResults: number + allowedTools: DiscoverableName[] + alwaysLoaded: DiscoverableName[] + binaryPath?: string + required: boolean + denyCredentialReads: boolean +} +export const DISCOVERABLE_TOOLS = Object.freeze(Object.keys(DISCOVERY_TERMS) as DiscoverableName[]) +export function resolveDiscoveryConfig(raw: unknown = {}): DiscoveryConfig { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new ConfigError('discovery config must be an object') + const input = raw as Record + for (const key of Object.keys(input)) { + if (!['enabled', 'maxResults', 'allowedTools', 'alwaysLoaded', 'binaryPath', 'required', 'denyCredentialReads'].includes(key)) { + throw new ConfigError(`unknown discovery key: ${key}`) + } + } + const boolean = (key: string, fallback: boolean): boolean => { + if (input[key] === undefined) return fallback + if (typeof input[key] !== 'boolean') throw new ConfigError(`${key} must be boolean`) + return input[key] + } + const names = (key: string, fallback: readonly DiscoverableName[]): DiscoverableName[] => { + const list = input[key] === undefined ? fallback : input[key] + if (!Array.isArray(list)) throw new ConfigError(`${key} must be an array of implemented capability names`) + for (const name of list) if (typeof name !== 'string' || !Object.hasOwn(DISCOVERY_TERMS, name)) { + throw new ConfigError(`${key} contains an unavailable capability: ${JSON.stringify(name)}`) + } + return [...new Set(list)] as DiscoverableName[] + } + const maxResults = input.maxResults === undefined ? 3 : input.maxResults + if (typeof maxResults !== 'number' || !Number.isInteger(maxResults) || maxResults < 1 || maxResults > 5) throw new ConfigError('maxResults must be an integer from 1 to 5') + if (input.binaryPath !== undefined && (typeof input.binaryPath !== 'string' || !input.binaryPath.trim())) throw new ConfigError('binaryPath must be a nonempty string') + const allowedTools = names('allowedTools', DISCOVERABLE_TOOLS) + const alwaysLoaded = names('alwaysLoaded', []) + for (const tool of alwaysLoaded) if (!allowedTools.includes(tool)) throw new ConfigError(`alwaysLoaded capability ${tool} must be in allowedTools`) + return { enabled: boolean('enabled', true), maxResults, allowedTools, alwaysLoaded, + binaryPath: input.binaryPath as string | undefined, required: boolean('required', false), + denyCredentialReads: boolean('denyCredentialReads', true) } +} diff --git a/src/discovery/index.ts b/src/discovery/index.ts new file mode 100644 index 00000000..cb3bba98 --- /dev/null +++ b/src/discovery/index.ts @@ -0,0 +1,177 @@ +import type { Context, Fiber } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import { resolveDiscoveryConfig } from '../config.js' +import { NativeFileTools } from '../file/runtime.js' +import { definition } from '../file/contract.js' +import { failure, resolveWorker } from '../file/worker.js' +import { capability } from '../capabilities/registry.js' +import { DISCOVERY_DESCRIPTION, DISCOVERY_PARAMETERS, DISCOVERY_OUTPUT } from '../capabilities/discovery.js' +import { publicToolName, renderToolingPolicy } from '../policy.js' +import { DISCOVERABLE_TOOLS } from './config.js' +import type { DiscoverableName } from './config.js' +import { parseRequest, search } from './search.js' + +interface Entry { + ctx?: Context + fiber?: Fiber + runtime: NativeFileTools + selected: Set + pending: Set + disposers: (() => void)[] + owned: Set + ready: Promise +} +const DISCOVER = publicToolName('discover') +const advanced = DISCOVERABLE_TOOLS.map(publicToolName) +export const name = 'dsh-ops-discovery' + +export function apply(root: Context, raw: unknown): void { + const config = resolveDiscoveryConfig(raw) + let worker: string + try { worker = resolveWorker(config) } + catch (error) { + if (config.required) throw error + root.logger.warn('dsh-ops: discovery worker unavailable; no tools published.') + return + } + root.inject(['tools', 'systemPrompt', 'sandboxPolicy', 'subprocess'], ctx => { + const tools = ctx.get('tools'), prompt = ctx.get('systemPrompt'), policyService = ctx.get('sandboxPolicy') + if (!tools || !prompt || !policyService) return + const entries = new Map() + let stopped = false, mutating = false, epoch = 0, chain = Promise.resolve() + const clear = (entry: Entry) => { + entry.owned.clear() + for (const dispose of entry.disposers.splice(0)) { + try { dispose() } catch { ctx.logger.warn('dsh-ops: discovery registration cleanup failed.') } + } + } + const permitted = (agent: Agent, rawName?: DiscoverableName): boolean => { + try { + const policy = policyService.resolve({ session: agent.session }) + return ['read-only', 'workspace-write', 'danger-full-access'].includes(policy.mode) + && (!rawName || !capability(rawName)?.mutates || policy.mode !== 'read-only') + } catch { return false } + } + const native = async (agent: Agent): Promise => { + const assembly = await prompt.assemble({ scope: agent }) + return !assembly.sections.some(section => section.name === 'tools:ptc-only' && section.text.trim()) + } + const own = (agent: Agent, entry: Entry, tool: ToolDefinition): void => { + if (!entry.ctx) return + const execute = tool.execute + entry.disposers.push(entry.ctx.tools.register({ ...tool, execute: async (args, exec) => { + if (stopped || entries.get(agent) !== entry || exec.agent !== agent || !entry.owned.has(tool.name)) { + return failure('POLICY_DENIED', 'Discovery capability belongs to a different or inactive scope.') + } + return execute(args, exec) + } })) + entry.owned.add(tool.name) + } + const reconcile = (): Promise => { + if (stopped || mutating) return chain + const version = ++epoch + chain = chain.then(async () => { + if (stopped || version !== epoch) return + const ready: [Agent, Entry][] = [] + for (const [agent, entry] of entries) if (entry.ctx && permitted(agent) && await native(agent).catch(() => false)) ready.push([agent, entry]) + if (stopped || version !== epoch) return + mutating = true + try { + for (const [agent, entry] of entries) { + const isReady = ready.some(([candidate]) => candidate === agent) + const wanted = isReady ? [...new Set([...config.alwaysLoaded, ...entry.selected])].filter(tool => permitted(agent, tool)) : [] + const desired = new Set([...(isReady && config.enabled ? [DISCOVER] : []), ...wanted.map(publicToolName)]) + const inherited = tools.schemas(agent).map(tool => tool.name).filter(tool => (advanced.includes(tool) || tool === DISCOVER) && !entry.owned.has(tool)) + // Registry changes from other components must not republish an unchanged + // surface, or independent lifecycle listeners can amplify one another. + if (desired.size === entry.owned.size && [...desired].every(tool => entry.owned.has(tool)) && !inherited.length) continue + clear(entry) + if (isReady && config.enabled) own(agent, entry, { + name: DISCOVER, description: DISCOVERY_DESCRIPTION, parameters: DISCOVERY_PARAMETERS, + output: { schema: DISCOVERY_OUTPUT, render: (_args, value) => [{ type: 'text', text: typeof value === 'object' && value && 'message' in value ? String(value.message) : JSON.stringify(value) }] }, + execute: async args => { + const request = parseRequest(args) + if (typeof request === 'string') return failure('INVALID_ARGUMENT', request) + const matches = search(request.query, config.allowedTools.filter(tool => permitted(agent, tool)), Math.min(request.maxResults, config.maxResults)) + const activated = matches.filter(tool => !entry.selected.has(tool) && !config.alwaysLoaded.includes(tool)) + for (const tool of activated) entry.pending.add(tool) + return { status: 'completed', activated: activated.map(publicToolName), + alreadyActive: matches.filter(tool => !activated.includes(tool)).map(publicToolName), + message: matches.length ? `Selected ${matches.map(publicToolName).join(', ')}. Newly selected tools become available on the next model step; no file action was executed.` : 'No allowed, implemented capability matched. Refine the query; unavailable formats cannot be activated.' } + }, + }) + for (const tool of wanted) { + own(agent, entry, definition(tool, (args, exec) => entry.runtime.call(tool, args, exec))) + } + } + // A child must not inherit its parent's activation or an old global advanced surface. + for (const [agent, entry] of entries) if (entry.ctx) { + const deny = tools.schemas(agent).map(tool => tool.name) + .filter(tool => (advanced.includes(tool) || tool === DISCOVER) && !entry.owned.has(tool)) + if (deny.length) entry.disposers.push(entry.ctx.tools.restrict({ deny })) + } + } finally { mutating = false } + }).catch(() => { + mutating = true + try { for (const entry of entries.values()) clear(entry) } finally { mutating = false } + ctx.logger.warn('dsh-ops: discovery publication unavailable; tools remain withdrawn.') + }) + return chain + } + const attach = (agent: Agent): Promise => { + if (stopped || entries.has(agent) || !agent.ctx) return entries.get(agent)?.ready ?? Promise.resolve() + const entry: Entry = { runtime: new NativeFileTools({ ctx, config: { ...config, workerPath: worker }, report: undefined }), + selected: new Set(), pending: new Set(), disposers: [], owned: new Set(), ready: Promise.resolve() } + entries.set(agent, entry) + entry.fiber = agent.ctx.plugin({ inject: ['tools'], apply(scoped) { entry.ctx = scoped } }) + entry.ready = Promise.resolve(entry.fiber).then(async () => { await entry.runtime.start([]); await reconcile() }) + return entry.ready + } + ctx.on('agent/created', async ({ agent }) => { await attach(agent); return undefined }) + ctx.on('agent/pre-step', async (payload, next) => { + await attach(payload.agent) + const entry = entries.get(payload.agent) + if (entry && !payload.signal.aborted) { + for (const tool of entry.pending) if (permitted(payload.agent, tool)) entry.selected.add(tool) + entry.pending.clear() + await reconcile() + } + return next() + }) + ctx.on('agent/disposed', async ({ agent }) => { + const entry = entries.get(agent) + if (!entry) return + entries.delete(agent); mutating = true + try { clear(entry) } finally { mutating = false } + await entry.runtime.stop(); await entry.fiber?.dispose(); await reconcile() + }) + ctx.on('tools/change', () => { void reconcile() }) + ctx.on('session/event', (session, event) => { + if (event.type !== 'sandbox/mode') return + mutating = true + try { + for (const [agent, entry] of entries) if (agent.session === session) { + entry.pending.clear(); entry.runtime.cancel('Sandbox authority changed'); clear(entry) + } + } finally { mutating = false } + void reconcile() + }) + ctx.effect(() => prompt.section({ + name: 'dsh-ops:discovery-tools', order: 3253, interpolate: false, + text: context => { + const entry = entries.get(context.scope as Agent) + return entry ? renderToolingPolicy({ published: entry.owned, shellComponent: false }) : '' + }, + }), 'dsh-ops: discovered capability guidance') + ctx.effect(() => async () => { + stopped = true; ++epoch; mutating = true + for (const entry of entries.values()) clear(entry) + await Promise.allSettled([...entries.values()].map(async entry => { await entry.runtime.stop(); await entry.fiber?.dispose() })) + entries.clear(); await chain + }, 'dsh-ops: discovery lifecycle') + for (const agent of ctx.get('agents')?.list() ?? []) void attach(agent) + }) +} diff --git a/src/discovery/search.ts b/src/discovery/search.ts new file mode 100644 index 00000000..0944c4f2 --- /dev/null +++ b/src/discovery/search.ts @@ -0,0 +1,24 @@ +import { DISCOVERY_TERMS } from '../capabilities/discovery.js' +import { publicToolName } from '../policy.js' +import type { DiscoverableName } from './config.js' + +export interface DiscoveryRequest { query: string, maxResults: number } +export function parseRequest(raw: unknown): DiscoveryRequest | string { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return 'Expected query and optional max_results.' + const args = raw as Record + for (const key of Object.keys(args)) if (!['query', 'max_results'].includes(key)) return `Unknown discovery argument: ${key}` + if (typeof args.query !== 'string' || !args.query.trim() || args.query.length > 512) return 'query must contain 1-512 characters.' + const maxResults = args.max_results === undefined ? 3 : args.max_results + if (typeof maxResults !== 'number' || !Number.isInteger(maxResults) || maxResults < 1 || maxResults > 5) return 'max_results must be an integer from 1 to 5.' + return { query: args.query.trim().toLowerCase(), maxResults } +} +export function search(query: string, pool: readonly DiscoverableName[], maximum: number): DiscoverableName[] { + if (query.startsWith('select:')) return pool.filter(name => query.slice(7).trim() === publicToolName(name)).slice(0, maximum) + const terms = [...new Set(query.match(/[a-z0-9_]+/g) ?? [])] + return pool.map((name, order) => { + const words = `${name} ${DISCOVERY_TERMS[name]}`.split(/[\s_]+/) + const score = terms.reduce((sum, term) => sum + (words.includes(term) ? 1 : 0), 0) + return { name, order, score } + }).filter(item => item.score > 0).sort((a, b) => b.score - a.score || a.order - b.order) + .slice(0, maximum).map(item => item.name) +} diff --git a/src/file/runtime.js b/src/file/runtime.js index ac0d52eb..fdbccd0d 100644 --- a/src/file/runtime.js +++ b/src/file/runtime.js @@ -18,18 +18,18 @@ export class NativeFileTools { this.stopped = false } - async start() { + async start(names = NATIVE_FILE_TOOLS) { this.file = this.config.workerPath if (!this.file) { if (this.config.required) throw new Error('Native file worker path is unavailable.') return [] } - const definitions = NATIVE_FILE_TOOLS.map(rawName => { + const definitions = names.map(rawName => { const execute = (args, exec) => this.call(rawName, args, exec) return rawName === 'result' ? resultDefinition(execute) : definition(rawName, execute) }) this.unregisterProvider = await registerNativeProvider(this.ctx, definitions, tool => this.ctx.tools.register(tool)) - return NATIVE_FILE_TOOLS.map(publicToolName) + return names.map(publicToolName) } async call(tool, args, exec) { @@ -52,6 +52,10 @@ export class NativeFileTools { } } + cancel(reason) { + for (const controller of this.active.keys()) controller.abort(new Error(reason)) + } + async stop() { this.stopped = true safeDisposeAll([this.unregisterProvider].filter(Boolean), error => this.report('dsh-ops: Native pager teardown failed: ' + error, 'warn'), 'result provider') diff --git a/test/capabilities.test.mjs b/test/capabilities.test.mjs index a9458052..3556a728 100644 --- a/test/capabilities.test.mjs +++ b/test/capabilities.test.mjs @@ -9,7 +9,7 @@ import { BASH_TOOL_NAME } from '../lib/shells/constants.js' import { assert, test, report } from './lib/harness.mjs' await test('the catalogue covers every owned file, PTC and shell tool exactly once', () => { - assert.equal(CAPABILITIES.length, 17) + assert.equal(CAPABILITIES.length, 18) assert.equal(new Set(CAPABILITIES.map(item => item.rawName)).size, CAPABILITIES.length) assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many']) assert.deepEqual(FILE_MUTATIONS, ['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) @@ -25,7 +25,7 @@ await test('the catalogue covers every owned file, PTC and shell tool exactly on assert.equal(entry.description, descriptions[entry.rawName]) } } - for (const component of ['file', 'ptc', 'shell']) assert.deepEqual(capabilitiesFor(component), CAPABILITIES.filter(item => item.component === component)) + for (const component of ['file', 'ptc', 'shell', 'discovery']) assert.deepEqual(capabilitiesFor(component), CAPABILITIES.filter(item => item.component === component)) for (const name of ['unknown', 'toString', '__proto__']) assert.equal(capability(name), undefined) }) diff --git a/test/discovery.test.mjs b/test/discovery.test.mjs new file mode 100644 index 00000000..1cb19df0 --- /dev/null +++ b/test/discovery.test.mjs @@ -0,0 +1,65 @@ +import { getEncoding } from 'js-tiktoken' +import { resolveDiscoveryConfig } from '../lib/config.js' +import { DISCOVERABLE_TOOLS } from '../lib/discovery/config.js' +import { parseRequest, search } from '../lib/discovery/search.js' +import { DISCOVERY_PARAMETERS, DISCOVERY_DESCRIPTION } from '../lib/capabilities/discovery.js' +import { publicToolName } from '../lib/policy.js' +import { assert, test, report, withTempDir, waitFor } from './lib/harness.mjs' +import { NativeFileTools } from '../lib/file/runtime.js' +import { discoveryMountChecks } from './lib/discovery.mjs' + +await test('discovery config validates its own keys and copies bounded operator selections', () => { + const config = resolveDiscoveryConfig() + assert.equal(config.enabled, true) + assert.equal(config.maxResults, 3) + assert.deepEqual(config.allowedTools, DISCOVERABLE_TOOLS) + assert.deepEqual(config.alwaysLoaded, []) + const input = { allowedTools: ['stat', 'stat'], alwaysLoaded: ['stat'] } + const selected = resolveDiscoveryConfig(input) + input.allowedTools.push('diff') + assert.deepEqual(selected.allowedTools, ['stat']) + for (const raw of [null, [], false, { enabled: 1 }, { maxResults: 0 }, { maxResults: 6 }, { maxResults: 1.5 }, { maxResults: null }, { allowedTools: ['pdf_read'] }, { alwaysLoaded: ['stat'], allowedTools: [] }]) assert.throws(() => resolveDiscoveryConfig(raw)) + assert.throws(() => resolveDiscoveryConfig({ allowedTool: [] }), /unknown discovery key: allowedTool/) +}) +await test('discovery arguments enforce max_results 1-5 and reject hidden action payloads', () => { + for (const max_results of [1, 2, 3, 4, 5]) assert.deepEqual(parseRequest({ query: 'STAT', max_results }), { query: 'stat', maxResults: max_results }) + for (const raw of [null, {}, { query: '' }, { query: 'a'.repeat(513) }, { query: 'stat', path: 'file' }, ...[0, 6, 1.5, null, '3'].map(max_results => ({ query: 'stat', max_results }))]) assert.equal(typeof parseRequest(raw), 'string') + assert.deepEqual(search('select:ops_stat', DISCOVERABLE_TOOLS, 5), ['stat']) + assert.deepEqual(search('metadata revision hash', DISCOVERABLE_TOOLS, 1), ['stat']) + assert.deepEqual(search('select:ops_pdf_read', DISCOVERABLE_TOOLS, 5), []) +}) +await test('BM-03 discovery-entry overhead is <=400 pinned cl100k_base tokens without abbreviating contracts', () => { + const wire = JSON.stringify([{ name: publicToolName('discover'), description: DISCOVERY_DESCRIPTION, parameters: DISCOVERY_PARAMETERS }]) + const tokens = getEncoding('cl100k_base').encode(wire).length + console.log(` discovery schema: ${tokens} cl100k_base tokens, ${Buffer.byteLength(wire)} UTF-8 bytes; js-tiktoken 1.0.21; compact JSON name/description/parameters; not provider billing or the whole OPS surface`) + assert.ok(tokens <= 400) +}) +await test('scoped file cancellation aborts active workers and disposal waits for their completion', () => withTempDir('discovery-cancel', async dir => { + let spawned, complete + const done = new Promise(resolve => { complete = resolve }) + const ctx = { get(name) { + if (name === 'sandboxPolicy') return { resolve: () => ({ mode: 'danger-full-access', workspaceRoot: dir }) } + if (name === 'subprocess') return { spawn(options) { + spawned = options + return { done, collected: { stdout: { readFrom: () => ({ text: '{}', lossy: false }) }, stderr: { readFrom: () => ({ text: '', lossy: false }) } } } + } } + } } + const runtime = new NativeFileTools({ ctx, config: { workerPath: 'fixture', denyCredentialReads: false } }) + await runtime.start([]) + const call = runtime.call('stat', { path: '.' }, { agent: { session: { id: 'cancel-test', header: { cwd: dir } } } }) + await waitFor(() => spawned) + runtime.cancel('Authority changed') + assert.equal(spawned.signal.aborted, true) + let stopped = false + const stopping = runtime.stop().then(() => { stopped = true }) + await new Promise(resolve => setImmediate(resolve)) + assert.equal(stopped, false, 'dispose must await worker completion, not AbortController values') + complete({ exitCode: 1, signal: 'SIGTERM' }) + assert.equal((await call).error.code, 'RESULT_UNKNOWN') + await stopping + assert.equal(stopped, true) + assert.equal(runtime.active.size, 0) + assert.equal((await runtime.call('stat', { path: '.' }, {})).status, 'rejected') +})) +await discoveryMountChecks() +report('discovery') diff --git a/test/lib/discovery.mjs b/test/lib/discovery.mjs new file mode 100644 index 00000000..d998b5de --- /dev/null +++ b/test/lib/discovery.mjs @@ -0,0 +1,180 @@ +import * as ptc from '../../lib/ptc/index.js' +/** Real Cordis discovery composition; no registry method replacement. */ +import fs from 'node:fs' +import path from 'node:path' +import { createScope } from '@deepseek-ai/dsh-scope' +import Sandbox from '@deepseek-ai/dsh-sandbox-local' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import * as discovery from '../../lib/components/discovery/index.js' +import { publicToolName } from '../../lib/policy.js' +import { assert, test, waitFor, withTempDir } from './harness.mjs' +import { bootHost, registryHasOwnRegister, scopedToolNames, shutdown } from './host.mjs' + +const DISCOVER = publicToolName('discover') +async function boot(config = {}) { + const { ctx } = await bootHost() + await ctx.plugin(Subprocess) + await ctx.plugin(Sandbox, {}) + await ctx.plugin(inner => inner.provide('sandboxPolicy', { + resolve: ({ session }) => ({ mode: session.mode, workspaceRoot: session.header.cwd, sessionId: session.id }), + })) + const fiber = await ctx.plugin(discovery, { required: true, ...config }) + await new Promise(resolve => setImmediate(resolve)) + return { ctx, fiber } +} +async function mint(ctx, id, cwd, parent, mode = 'danger-full-access') { + const agent = { id, session: { id, mode, header: { cwd } } } + await (parent?.ctx ?? ctx).plugin({ inject: ['tools'], apply(inner) { + agent.ctx = createScope(inner, agent, parent ? { parent } : undefined).ctx + } }) + await ctx.serial('agent/created', { agent, source: 'startup' }) + return agent +} +const assemble = (ctx, agent) => ctx.get('systemPrompt').assemble({ scope: agent }) +const nextStep = (ctx, agent) => ctx.waterfall('agent/pre-step', { agent, messages: [], turn: 1, step: 2, signal: new AbortController().signal }, async () => ({ kind: 'continue' })) +const call = (ctx, agent, name, args = {}) => ctx.get('tools').execute({ agent, name: publicToolName(name), arguments: args, callId: 'discovery-test', signal: new AbortController().signal }) + +export async function discoveryMountChecks() { + await test('BM-04 discovery queues activation until the next model step without performing the action', () => withTempDir('discovery', async dir => { + const { ctx } = await boot() + try { + fs.writeFileSync(path.join(dir, 'file.txt'), 'unchanged') + const agent = await mint(ctx, 'discover-next-step', dir) + const first = await assemble(ctx, agent) + assert.deepEqual(first.tools.map(tool => tool.name), [DISCOVER]) + assert.equal(registryHasOwnRegister(ctx), false) + assert.deepEqual(ctx.get('tools').schemas(), []) + const found = await call(ctx, agent, 'discover', { query: 'select:ops_stat' }) + assert.equal(found.isError, false) + assert.deepEqual(found.value.activated, [publicToolName('stat')]) + assert.equal(scopedToolNames(ctx, agent).includes(publicToolName('stat')), false) + assert.equal((await call(ctx, agent, 'stat', { path: 'file.txt' })).isError, true) + assert.deepEqual((await assemble(ctx, agent)).tools.map(tool => tool.name), [DISCOVER], 'diagnostic/current-step assembly cannot activate') + assert.equal(fs.readFileSync(path.join(dir, 'file.txt'), 'utf8'), 'unchanged') + await nextStep(ctx, agent) + assert.deepEqual((await assemble(ctx, agent)).tools.map(tool => tool.name).sort(), [DISCOVER, publicToolName('stat')].sort()) + assert.deepEqual(first.tools.map(tool => tool.name), [DISCOVER], 'previous assembly is immutable with respect to later registration') + const result = await call(ctx, agent, 'stat', { path: 'file.txt' }) + assert.equal(result.isError, false) + assert.equal(result.value.bytes, 9) + assert.equal(result.value.status, 'completed') + } finally { await shutdown(ctx) } + })) + await test('activation stays in its owning scope, including across parent-child inheritance', () => withTempDir('discovery', async dir => { + const { ctx } = await boot() + try { + const parent = await mint(ctx, 'discovery-parent', dir) + const sibling = await mint(ctx, 'discovery-sibling', dir) + const child = await mint(ctx, 'discovery-child', dir, parent) + await call(ctx, parent, 'discover', { query: 'select:ops_stat' }) + await nextStep(ctx, parent) + for (const other of [sibling, child]) { + assert.equal(scopedToolNames(ctx, other).includes(publicToolName('stat')), false) + assert.equal((await call(ctx, other, 'stat', { path: 'any' })).isError, true) + } + const cached = ctx.get('tools').get(publicToolName('stat'), parent) + const foreign = await cached.execute({ path: 'any' }, { agent: child, signal: new AbortController().signal }) + assert.equal(foreign.status, 'rejected') + await call(ctx, child, 'discover', { query: 'select:ops_diff' }) + await nextStep(ctx, child) + assert.equal(scopedToolNames(ctx, parent).includes(publicToolName('diff')), false) + assert.equal(scopedToolNames(ctx, child).includes(publicToolName('diff')), true) + } finally { await shutdown(ctx) } + })) + await test('allowed pool and per-call limits cannot activate unavailable or unselected capabilities', () => withTempDir('discovery', async dir => { + const { ctx } = await boot({ allowedTools: ['stat', 'diff', 'copy'], maxResults: 1 }) + try { + const agent = await mint(ctx, 'discovery-pool', dir) + for (const query of ['select:ops_pdf_read', 'select:ops_remove', 'nonsense']) { + const result = await call(ctx, agent, 'discover', { query }) + assert.deepEqual(result.value.activated, []) + } + const result = await call(ctx, agent, 'discover', { query: 'file content metadata', max_results: 5 }) + assert.equal(result.value.activated.length, 1) + await nextStep(ctx, agent) + assert.equal(scopedToolNames(ctx, agent).length, 2) + } finally { await shutdown(ctx) } + })) + await test('disabling only the entry preserves always-loaded choices; unloading removes all ownership', () => withTempDir('discovery', async dir => { + const { ctx, fiber } = await boot({ enabled: false, allowedTools: ['stat'], alwaysLoaded: ['stat'] }) + try { + const agent = await mint(ctx, 'discovery-always', dir) + assert.deepEqual(scopedToolNames(ctx, agent), [publicToolName('stat')]) + const stale = ctx.get('tools').get(publicToolName('stat'), agent) + await fiber.dispose() + assert.deepEqual(scopedToolNames(ctx, agent), []) + const value = await stale.execute({ path: '.' }, { agent, signal: new AbortController().signal }) + assert.equal(value.status, 'rejected') + } finally { await shutdown(ctx) } + })) + await test('read-only scopes cannot discover mutations and live authority changes withdraw them', () => withTempDir('discovery', async dir => { + const { ctx } = await boot() + try { + const readOnly = await mint(ctx, 'discovery-read-only', dir, undefined, 'read-only') + const writer = await mint(ctx, 'discovery-writer', dir) + assert.deepEqual((await call(ctx, readOnly, 'discover', { query: 'select:ops_write_file' })).value.activated, []) + await call(ctx, readOnly, 'discover', { query: 'select:ops_stat' }) + await call(ctx, writer, 'discover', { query: 'select:ops_write_file' }) + await nextStep(ctx, writer) + const stale = ctx.get('tools').get(publicToolName('write_file'), writer) + assert.ok(stale) + writer.session.mode = 'read-only' + await ctx.serial('session/event', writer.session, { type: 'sandbox/mode', data: { mode: 'read-only' } }) + await waitFor(() => scopedToolNames(ctx, writer).includes(DISCOVER)) + assert.equal(scopedToolNames(ctx, writer).includes(publicToolName('write_file')), false) + const result = await stale.execute({ path: 'new.txt', content: 'blocked', expectedRevision: 'absent' }, { agent: writer, signal: new AbortController().signal }) + assert.equal(result.status, 'rejected') + assert.equal(fs.existsSync(path.join(dir, 'new.txt')), false) + assert.ok(scopedToolNames(ctx, readOnly).includes(DISCOVER)) + await nextStep(ctx, readOnly) + assert.ok(scopedToolNames(ctx, readOnly).includes(publicToolName('stat')), 'another session change must not discard this pending activation') + } finally { await shutdown(ctx) } + })) + await test('agent disposal discards both pending and active capabilities', () => withTempDir('discovery', async dir => { + const { ctx } = await boot() + try { + const agent = await mint(ctx, 'discovery-disposed', dir) + await call(ctx, agent, 'discover', { query: 'select:ops_stat' }) + await nextStep(ctx, agent) + await call(ctx, agent, 'discover', { query: 'select:ops_diff' }) + await ctx.serial('agent/disposed', { agent }) + assert.deepEqual(scopedToolNames(ctx, agent), []) + } finally { await shutdown(ctx) } + })) + await test('discovery and PTC publication converge without rebuilding unchanged scoped tools', () => withTempDir('discovery', async dir => { + const { ctx } = await boot() + try { + ctx.provide('ptcRuntime', { language: 'typescript' }) + await ctx.plugin(ptc, { required: true }) + const native = await mint(ctx, 'discovery-coexist-native', dir) + const code = await mint(ctx, 'discovery-coexist-ptc', dir) + const lift = code.ctx.tools.presentAs('ptc') + await waitFor(() => scopedToolNames(ctx, code).includes(publicToolName('batch')) && scopedToolNames(ctx, native).includes(DISCOVER)) + await call(ctx, native, 'discover', { query: 'select:ops_stat' }) + await nextStep(ctx, native) + await waitFor(() => scopedToolNames(ctx, code).includes(publicToolName('batch'))) + const stable = ctx.get('tools').get(DISCOVER, native) + for (let i = 0; i < 3; i++) await assemble(ctx, code) + assert.equal(ctx.get('tools').get(DISCOVER, native), stable) + assert.equal(scopedToolNames(ctx, code).includes(publicToolName('stat')), false) + lift() + } finally { await shutdown(ctx) } + })) + await test('pure PTC presentation withdraws Native discovery and advanced registrations', () => withTempDir('discovery', async dir => { + const { ctx } = await boot() + try { + const agent = await mint(ctx, 'discovery-mode', dir) + await call(ctx, agent, 'discover', { query: 'select:ops_stat' }) + await nextStep(ctx, agent) + ctx.provide('ptcRuntime', { language: 'typescript' }) + const lift = agent.ctx.tools.presentAs('ptc') + await waitFor(() => !scopedToolNames(ctx, agent).includes(DISCOVER)) + const result = await assemble(ctx, agent) + assert.deepEqual(result.tools.map(tool => tool.name), ['run_code']) + assert.ok(result.sections.every(section => !section.text.includes(publicToolName('stat')))) + lift() + await waitFor(() => scopedToolNames(ctx, agent).includes(DISCOVER)) + assert.ok(scopedToolNames(ctx, agent).includes(publicToolName('stat'))) + } finally { await shutdown(ctx) } + })) +} diff --git a/test/mount.test.mjs b/test/mount.test.mjs index 6944d2cc..af081a52 100644 --- a/test/mount.test.mjs +++ b/test/mount.test.mjs @@ -1,3 +1,4 @@ +import { discoveryMountChecks } from './lib/discovery.mjs' /** Real Cordis/tool/prompt composition with the released independent components. */ import fs from 'node:fs' import path from 'node:path' @@ -29,6 +30,7 @@ try { worker = resolveWorker({}) } catch (error) { skipSuite('mount', error.message.split('\n')[0]) } console.log(` worker: ${worker}`) +await discoveryMountChecks() /** The row configs earlier bundles wrote into every existing profile (`cordis.patch.yml` before update 2). */ const LEGACY_ROWS = Object.freeze({ file: { promptPolicy: true, enableShellTools: false, required: false, toolCallTimeoutMs: 300000 }, From f01dc126c05066f0878b174d5b5164751450a3b5 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:02:25 +0800 Subject: [PATCH 031/118] fix(discovery): withdraw Native tools before PTC assembly --- src/discovery/index.ts | 45 +++++++++++++++++++++++++++++++----------- test/lib/discovery.mjs | 30 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/discovery/index.ts b/src/discovery/index.ts index cb3bba98..47c8d4ac 100644 --- a/src/discovery/index.ts +++ b/src/discovery/index.ts @@ -1,7 +1,7 @@ import type { Context, Fiber } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-system-prompt' +import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-sandbox-policy' import { resolveDiscoveryConfig } from '../config.js' import { NativeFileTools } from '../file/runtime.js' @@ -41,6 +41,7 @@ export function apply(root: Context, raw: unknown): void { const tools = ctx.get('tools'), prompt = ctx.get('systemPrompt'), policyService = ctx.get('sandboxPolicy') if (!tools || !prompt || !policyService) return const entries = new Map() + const ownedDefinitions = new WeakSet() let stopped = false, mutating = false, epoch = 0, chain = Promise.resolve() const clear = (entry: Entry) => { entry.owned.clear() @@ -55,21 +56,30 @@ export function apply(root: Context, raw: unknown): void { && (!rawName || !capability(rawName)?.mutates || policy.mode !== 'read-only') } catch { return false } } - const native = async (agent: Agent): Promise => { - const assembly = await prompt.assemble({ scope: agent }) - return !assembly.sections.some(section => section.name === 'tools:ptc-only' && section.text.trim()) - } + const ptcOnly = (assembly: PromptAssembly): boolean => assembly.sections.some(section => section.name === 'tools:ptc-only' && Boolean(section.text.trim())) + const native = async (agent: Agent): Promise => !ptcOnly(await prompt.assemble({ scope: agent })) const own = (agent: Agent, entry: Entry, tool: ToolDefinition): void => { if (!entry.ctx) return const execute = tool.execute - entry.disposers.push(entry.ctx.tools.register({ ...tool, execute: async (args, exec) => { - if (stopped || entries.get(agent) !== entry || exec.agent !== agent || !entry.owned.has(tool.name)) { + const registration: ToolDefinition = { ...tool, execute: async (args, exec) => { + const available = () => !stopped && entries.get(agent) === entry && exec.agent === agent && entry.owned.has(tool.name) + if (!available() || !await native(agent).catch(() => false) || !available()) { return failure('POLICY_DENIED', 'Discovery capability belongs to a different or inactive scope.') } return execute(args, exec) - } })) + } } + ownedDefinitions.add(registration) + entry.disposers.push(entry.ctx.tools.register(registration)) entry.owned.add(tool.name) } + const inheritedNative = (agent: Agent, entry: Entry): string[] => tools.schemas(agent).map(tool => tool.name).filter(name => { + if ((!advanced.includes(name) && name !== DISCOVER) || entry.owned.has(name)) return false + const registered = tools.get(name, agent) + // A same-scope PTC adapter may own the same kernel name. Never mask it. + // Only ancestor discovery registrations or the legacy global Native row + // are inherited Native capabilities that this lifecycle may suppress. + return Boolean(registered && (ownedDefinitions.has(registered) || registered === tools.get(name))) + }) const reconcile = (): Promise => { if (stopped || mutating) return chain const version = ++epoch @@ -84,7 +94,7 @@ export function apply(root: Context, raw: unknown): void { const isReady = ready.some(([candidate]) => candidate === agent) const wanted = isReady ? [...new Set([...config.alwaysLoaded, ...entry.selected])].filter(tool => permitted(agent, tool)) : [] const desired = new Set([...(isReady && config.enabled ? [DISCOVER] : []), ...wanted.map(publicToolName)]) - const inherited = tools.schemas(agent).map(tool => tool.name).filter(tool => (advanced.includes(tool) || tool === DISCOVER) && !entry.owned.has(tool)) + const inherited = inheritedNative(agent, entry) // Registry changes from other components must not republish an unchanged // surface, or independent lifecycle listeners can amplify one another. if (desired.size === entry.owned.size && [...desired].every(tool => entry.owned.has(tool)) && !inherited.length) continue @@ -109,8 +119,7 @@ export function apply(root: Context, raw: unknown): void { } // A child must not inherit its parent's activation or an old global advanced surface. for (const [agent, entry] of entries) if (entry.ctx) { - const deny = tools.schemas(agent).map(tool => tool.name) - .filter(tool => (advanced.includes(tool) || tool === DISCOVER) && !entry.owned.has(tool)) + const deny = inheritedNative(agent, entry) if (deny.length) entry.disposers.push(entry.ctx.tools.restrict({ deny })) } } finally { mutating = false } @@ -149,6 +158,20 @@ export function apply(root: Context, raw: unknown): void { await entry.runtime.stop(); await entry.fiber?.dispose(); await reconcile() }) ctx.on('tools/change', () => { void reconcile() }) + ctx.on('system-prompt/assemble', async (_assembly, context, next) => { + const entry = entries.get(context.scope as Agent) + // Snapshot ownership before yielding: another assembly may withdraw it while + // this request still contains an already-rendered Native SDK section. + const heldNative = Boolean(entry?.owned.size) + const result = await next() + if (!entry || !heldNative || !ptcOnly(result)) return result + mutating = true + try { entry.runtime.cancel('Native presentation withdrawn'); clear(entry) } + finally { mutating = false } + // Rebuild through the public host service after removing only our tools. + // This cannot activate pending requests or rewrite another provider's text. + return prompt.assemble(context) + }) ctx.on('session/event', (session, event) => { if (event.type !== 'sandbox/mode') return mutating = true diff --git a/test/lib/discovery.mjs b/test/lib/discovery.mjs index d998b5de..05ec90f2 100644 --- a/test/lib/discovery.mjs +++ b/test/lib/discovery.mjs @@ -6,6 +6,7 @@ import { createScope } from '@deepseek-ai/dsh-scope' import Sandbox from '@deepseek-ai/dsh-sandbox-local' import Subprocess from '@deepseek-ai/dsh-subprocess-local' import * as discovery from '../../lib/components/discovery/index.js' +import { definition } from '../../lib/file/contract.js' import { publicToolName } from '../../lib/policy.js' import { assert, test, waitFor, withTempDir } from './harness.mjs' import { bootHost, registryHasOwnRegister, scopedToolNames, shutdown } from './host.mjs' @@ -160,6 +161,27 @@ export async function discoveryMountChecks() { lift() } finally { await shutdown(ctx) } })) + await test('a separate PTC adapter keeps ownership of its same-scope kernel definitions', () => withTempDir('discovery', async dir => { + const { ctx } = await boot() + try { + ctx.provide('ptcRuntime', { language: 'typescript' }) + const native = await mint(ctx, 'discovery-sdk-native', dir) + const code = await mint(ctx, 'discovery-sdk-code', dir) + code.ctx.tools.presentAs('ptc') + const adapter = definition('stat', async () => ({ status: 'completed', owner: 'ptc-adapter' })) + const dispose = code.ctx.tools.register(adapter) + const assembly = await assemble(ctx, code) + assert.deepEqual(assembly.tools.map(tool => tool.name), ['run_code']) + assert.equal(ctx.get('tools').get(publicToolName('stat'), code), adapter) + assert.ok(assembly.sections.some(section => section.text.includes(publicToolName('stat')))) + assert.equal(scopedToolNames(ctx, code).includes(DISCOVER), false) + assert.ok(scopedToolNames(ctx, native).includes(DISCOVER)) + await call(ctx, native, 'discover', { query: 'select:ops_stat' }) + await nextStep(ctx, native) + assert.equal(ctx.get('tools').get(publicToolName('stat'), code), adapter) + dispose() + } finally { await shutdown(ctx) } + })) await test('pure PTC presentation withdraws Native discovery and advanced registrations', () => withTempDir('discovery', async dir => { const { ctx } = await boot() try { @@ -167,7 +189,13 @@ export async function discoveryMountChecks() { await call(ctx, agent, 'discover', { query: 'select:ops_stat' }) await nextStep(ctx, agent) ctx.provide('ptcRuntime', { language: 'typescript' }) + const cached = ctx.get('tools').get(publicToolName('stat'), agent) const lift = agent.ctx.tools.presentAs('ptc') + const immediate = assemble(ctx, agent) + const cachedCall = cached.execute({ path: '.' }, { agent, signal: new AbortController().signal }) + const immediateResult = await immediate + assert.ok(immediateResult.sections.every(section => !section.text.includes(publicToolName('stat'))), 'immediate PTC assembly must not expose a stale Native SDK') + assert.equal((await cachedCall).status, 'rejected', 'cached Native definitions cannot execute during withdrawal') await waitFor(() => !scopedToolNames(ctx, agent).includes(DISCOVER)) const result = await assemble(ctx, agent) assert.deepEqual(result.tools.map(tool => tool.name), ['run_code']) @@ -178,3 +206,5 @@ export async function discoveryMountChecks() { } finally { await shutdown(ctx) } })) } + +export { boot as bootDiscoveryHost, mint as mintDiscoveryAgent, nextStep as discoveryNextStep } From 4c96b0b041b09b8b681372b47a3ede0ce35fd201 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:59:17 +0800 Subject: [PATCH 032/118] fix(discovery): coordinate scoped Native and PTC ownership --- docs/CHANGELOG.md | 4 +- docs/discovery.md | 10 +- ...45\345\205\267\345\217\221\347\216\260.md" | 10 +- src/capabilities/discovery.ts | 3 + src/discovery/index.ts | 81 ++++++---- src/file/result-provider.ts | 57 +++++-- src/file/runtime.js | 10 +- src/presentation.ts | 14 ++ src/ptc/index.ts | 37 +++-- src/ptc/ownership.ts | 38 +---- src/scoped-ownership.ts | 40 +++++ test/discovery-composition.test.mjs | 148 ++++++++++++++++++ test/discovery.test.mjs | 4 +- test/lib/discovery.mjs | 4 +- 14 files changed, 345 insertions(+), 115 deletions(-) create mode 100644 src/presentation.ts create mode 100644 src/scoped-ownership.ts create mode 100644 test/discovery-composition.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 185a9b3b..b3cfaae6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -65,9 +65,11 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm the lock and audit namespace and reports partial cleanup honestly. - Preserve Windows private-temp capability isolation; real confined create/reopen/readonly and foreign-session denial assertions cover the protected cache ACL. -### Discover advanced tools in the requesting Native scope (#261) +### Discover advanced tools in the requesting Native scope (#261, 8/16) - Add `dsh-ops/discovery` and `ops_discover`: deterministic allowed-pool selection, a 1-5 per-call cap, and always-loaded choices. Publish pending capabilities only at the next model-step boundary, with scope isolation, permission withdrawal and owned cleanup. +- Coordinate overlapping discovery rows and exact Native ownership with progressive PTC publication. Preserve unrelated external registrations and same-scope file handoff; include current batch reads/edits and the Native reference pager. +- 协调重叠发现行、Native 精确归属和渐进式 PTC 发布,保留外部注册与同作用域文件交接;纳入现有批量读写及 Native 引用分页器。 - Add pinned schema token measurement and real Cordis/native-worker lifecycle checks. Discovery alone measures 120 cl100k_base tokens; this is not a whole-OPS or PDF acceptance claim. - 新增按需发现组件与 `ops_discover`:允许池、单次 1-5 项上限和始终加载选择;仅在下一模型步骤挂载,并保证作用域隔离、权限变更撤销及卸载清理。 - 新增固定分词器的 schema 计量和真实 Cordis/原生 worker 生命周期测试;发现入口为 120 cl100k_base tokens,不代表全体 OPS 或 PDF 验收通过。 diff --git a/docs/discovery.md b/docs/discovery.md index b689b964..f0d39659 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -11,7 +11,7 @@ The component is exported now. The seven-row bundle installation and 0.3 profile config: enabled: true maxResults: 3 - allowedTools: [glob, grep, replace, write_file, edit_file, diff, stat, copy, move, remove] + allowedTools: [read_many, edit_many, result, glob, grep, replace, write_file, edit_file, diff, stat, copy, move, remove] alwaysLoaded: [] ``` @@ -32,10 +32,14 @@ Only this plugin's scoped `ctx.tools.register`, `restrict`, prompt sections and ## Measurement boundaries -The deterministic BM-03 entry check uses `js-tiktoken@1.0.21` with `cl100k_base`, encoding compact JSON of the pinned host `ToolSchema` fields (`name`, `description`, `parameters`). The discovery entry is **120 tokens / 552 UTF-8 bytes**. This is a reproducible schema measurement, not provider billing, and excludes tool-message framing, prompt guidance and other OPS components. The tokenizer is a test-only dependency published on 2025-08-09; its locked `base64-js` dependency is also test-only. +The deterministic entry measurement uses `js-tiktoken@1.0.21` with `cl100k_base`, encoding compact JSON of the pinned host `ToolSchema` fields (`name`, `description`, `parameters`). The discovery entry is **120 tokens / 552 UTF-8 bytes**. This is a reproducible schema measurement, not provider billing, and excludes tool-message framing, prompt guidance and other OPS components. The tokenizer is a test-only dependency published on 2025-08-09; its locked `base64-js` dependency is also test-only. -The unchanged eleven-file-tool surface plus discovery measures **2232 tokens / 10376 bytes** by the same method; this slice does not claim that whole surface passes 400. The next slice reduces the resident file set and records its own complete file/discovery baseline. Shell/PTC or operator always-loaded choices must be measured separately, not silently excluded from a global claim. +The unchanged fourteen-tool Native file surface plus discovery measures **3183 tokens / 14942 bytes** by the same method; this slice does not claim that whole surface passes 400. The next slice reduces the resident file set and records its own complete file/discovery baseline. Shell/PTC or operator always-loaded choices must be measured separately, not silently excluded from a global claim. BM-04 runs through the real Cordis tool/prompt services and native worker: an implemented `stat` capability is absent before discovery, remains absent through current-step assembly, appears after the next pre-step boundary, and executes successfully. PDF is unimplemented, so this is the lifecycle gate, not a claim that PDF acceptance passed. The browser configuration multi-select is separate work: plain configuration validation does not automatically generate a host selector. Its UI must use the pinned host's browser plugin configuration APIs. + +Overlapping discovery rows use the nearest owning scope; the last row mounted in the same scope wins until it is unloaded. File rows may mount before or after discovery. Only exact OPS-owned Native registrations are yielded or masked; unrelated external registrations remain visible. PTC Native shadows retain their exact origin, and discovered activations cannot be inherited by another agent. Native `result` is page/release only and may be discovered in read-only mode; the worker still rejects forbidden release actions. `read_many` and `edit_many` retain their existing per-file completeness and CAS contracts. + +BM-03 remains a full default Native-surface gate, including Windows Bash when integrated. The entry-only measurement above does not satisfy that gate. diff --git "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" index 9c7ccba2..9d44de5c 100644 --- "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" +++ "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" @@ -11,7 +11,7 @@ English: [Advanced tools on demand](discovery.md). config: enabled: true maxResults: 3 - allowedTools: [glob, grep, replace, write_file, edit_file, diff, stat, copy, move, remove] + allowedTools: [read_many, edit_many, result, glob, grep, replace, write_file, edit_file, diff, stat, copy, move, remove] alwaysLoaded: [] ``` @@ -32,10 +32,14 @@ English: [Advanced tools on demand](discovery.md). ## 测量边界 -确定性的 BM-03 入口检查固定 `js-tiktoken@1.0.21` 和 `cl100k_base`,编码固定宿主 `ToolSchema` 字段(`name`、`description`、`parameters`)的紧凑 JSON。发现入口为 **120 tokens / 552 UTF-8 字节**。这是可复现的 schema 测量,不是供应商账单;不含消息封装、提示词指引和其它 OPS 组件。分词器是测试依赖,发布于 2025-08-09;锁定的 `base64-js` 也仅供测试使用。 +确定性的入口测量固定 `js-tiktoken@1.0.21` 和 `cl100k_base`,编码固定宿主 `ToolSchema` 字段(`name`、`description`、`parameters`)的紧凑 JSON。发现入口为 **120 tokens / 552 UTF-8 字节**。这是可复现的 schema 测量,不是供应商账单;不含消息封装、提示词指引和其它 OPS 组件。分词器是测试依赖,发布于 2025-08-09;锁定的 `base64-js` 也仅供测试使用。 -未精简的十一项文件工具加发现入口,同口径为 **2232 tokens / 10376 字节**;本切片不宣称整体通过 400 上限。下一切片精简常驻文件集合并记录完整文件/发现基线。Shell、PTC 或用户始终加载的选择必须单独计量,不能在全局节省声明中悄悄排除。 +未精简的十四项 Native 文件工具加发现入口,同口径为 **3183 tokens / 14942 字节**;本切片不宣称整体通过 400 上限。下一切片精简常驻文件集合并记录完整文件/发现基线。Shell、PTC 或用户始终加载的选择必须单独计量,不能在全局节省声明中悄悄排除。 BM-04 使用真实 Cordis 工具/提示词服务及原生 worker:已实现的 `stat` 在发现前不可见,在当前步骤组装时仍不可见,下一 pre-step 边界后出现并能执行。PDF 尚未实现,因此这是生命周期门禁,并不代表 PDF 验收已通过。 浏览器配置多选界面属于独立工作:普通配置校验不会自动生成宿主选择器,界面必须使用固定宿主版本的浏览器插件配置 API。 + +重叠的发现行由最近作用域拥有;同一作用域最后挂载的行生效,卸载后恢复前一行。文件行可在发现组件之前或之后挂载。仅让出或屏蔽确切属于 OPS 的 Native 注册,外部注册保持可见。PTC 的 Native 投影保留确切来源,发现后的激活项不能被其它 agent 继承。Native `result` 仅含 page/release,可在只读模式发现;worker 仍会拒绝不允许的 release。`read_many` 和 `edit_many` 保留原有逐文件完整性和 CAS 契约。 + +BM-03 仍是完整默认 Native 工具面的门禁,整合 Windows Bash 后也必须计入。上述仅入口测量不代表该门禁通过。 diff --git a/src/capabilities/discovery.ts b/src/capabilities/discovery.ts index 9c36f8a9..c298db2e 100644 --- a/src/capabilities/discovery.ts +++ b/src/capabilities/discovery.ts @@ -3,6 +3,9 @@ import type { ParameterSchema } from './types.js' /** Reviewed, currently implemented advanced operations. Future formats are absent. */ export const DISCOVERY_TERMS = Object.freeze({ + read_many: 'read inspect multiple files bounded line windows', + edit_many: 'edit patch multiple files atomic per file revision cas', + result: 'result reference page release stored output continuation', glob: 'glob find search file paths patterns', grep: 'grep search content regex matching text count summary', replace: 'batch literal text replacement across files', diff --git a/src/discovery/index.ts b/src/discovery/index.ts index 47c8d4ac..e4bfd045 100644 --- a/src/discovery/index.ts +++ b/src/discovery/index.ts @@ -5,7 +5,10 @@ import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-sandbox-policy' import { resolveDiscoveryConfig } from '../config.js' import { NativeFileTools } from '../file/runtime.js' -import { definition } from '../file/contract.js' +import { nativeProviders, registerNativeProvider, claimNativeSelection } from '../file/result-provider.js' +import { ownScopes } from '../scoped-ownership.js' +import { isNativePresentation } from '../presentation.js' +import { mountResultLifecycle } from '../kernel/result-lifecycle.js' import { failure, resolveWorker } from '../file/worker.js' import { capability } from '../capabilities/registry.js' import { DISCOVERY_DESCRIPTION, DISCOVERY_PARAMETERS, DISCOVERY_OUTPUT } from '../capabilities/discovery.js' @@ -23,6 +26,7 @@ interface Entry { disposers: (() => void)[] owned: Set ready: Promise + releaseSelection?: () => void } const DISCOVER = publicToolName('discover') const advanced = DISCOVERABLE_TOOLS.map(publicToolName) @@ -37,11 +41,12 @@ export function apply(root: Context, raw: unknown): void { root.logger.warn('dsh-ops: discovery worker unavailable; no tools published.') return } - root.inject(['tools', 'systemPrompt', 'sandboxPolicy', 'subprocess'], ctx => { + root.inject(['tools', 'systemPrompt', 'sandboxPolicy', 'subprocess'], async ctx => { const tools = ctx.get('tools'), prompt = ctx.get('systemPrompt'), policyService = ctx.get('sandboxPolicy') if (!tools || !prompt || !policyService) return const entries = new Map() - const ownedDefinitions = new WeakSet() + mountResultLifecycle(ctx) + let ownership: Awaited> | undefined let stopped = false, mutating = false, epoch = 0, chain = Promise.resolve() const clear = (entry: Entry) => { entry.owned.clear() @@ -53,44 +58,41 @@ export function apply(root: Context, raw: unknown): void { try { const policy = policyService.resolve({ session: agent.session }) return ['read-only', 'workspace-write', 'danger-full-access'].includes(policy.mode) - && (!rawName || !capability(rawName)?.mutates || policy.mode !== 'read-only') + && (!rawName || rawName === 'result' || !capability(rawName)?.mutates || policy.mode !== 'read-only') } catch { return false } } const ptcOnly = (assembly: PromptAssembly): boolean => assembly.sections.some(section => section.name === 'tools:ptc-only' && Boolean(section.text.trim())) - const native = async (agent: Agent): Promise => !ptcOnly(await prompt.assemble({ scope: agent })) - const own = (agent: Agent, entry: Entry, tool: ToolDefinition): void => { + const native = (agent: Agent): Promise => isNativePresentation(prompt, agent) + const own = async (agent: Agent, entry: Entry, tool: ToolDefinition): Promise => { if (!entry.ctx) return const execute = tool.execute const registration: ToolDefinition = { ...tool, execute: async (args, exec) => { - const available = () => !stopped && entries.get(agent) === entry && exec.agent === agent && entry.owned.has(tool.name) - if (!available() || !await native(agent).catch(() => false) || !available()) { + await chain + const version = epoch + const available = () => ownership?.owns(agent) && !stopped && entries.get(agent) === entry && exec.agent === agent && entry.owned.has(tool.name) + if (!available() || !await native(agent).catch(() => false) || !available() || version !== epoch) { return failure('POLICY_DENIED', 'Discovery capability belongs to a different or inactive scope.') } return execute(args, exec) } } - ownedDefinitions.add(registration) - entry.disposers.push(entry.ctx.tools.register(registration)) + entry.disposers.push(await registerNativeProvider(entry.ctx, [registration], tool => entry.ctx!.tools.register(tool), { inheritable: false, priority: 10 })) entry.owned.add(tool.name) } - const inheritedNative = (agent: Agent, entry: Entry): string[] => tools.schemas(agent).map(tool => tool.name).filter(name => { - if ((!advanced.includes(name) && name !== DISCOVER) || entry.owned.has(name)) return false - const registered = tools.get(name, agent) - // A same-scope PTC adapter may own the same kernel name. Never mask it. - // Only ancestor discovery registrations or the legacy global Native row - // are inherited Native capabilities that this lifecycle may suppress. - return Boolean(registered && (ownedDefinitions.has(registered) || registered === tools.get(name))) - }) + const inheritedNative = (agent: Agent, entry: Entry): string[] => nativeProviders(ctx, agent) + .map(provider => provider.definition.name).filter(name => (advanced.includes(name) || name === DISCOVER) && !entry.owned.has(name)) const reconcile = (): Promise => { if (stopped || mutating) return chain const version = ++epoch chain = chain.then(async () => { if (stopped || version !== epoch) return const ready: [Agent, Entry][] = [] - for (const [agent, entry] of entries) if (entry.ctx && permitted(agent) && await native(agent).catch(() => false)) ready.push([agent, entry]) + for (const [agent, entry] of entries) if (entry.ctx && ownership?.owns(agent) && permitted(agent) && await native(agent).catch(() => false)) ready.push([agent, entry]) if (stopped || version !== epoch) return - mutating = true + mutating = true; ownership?.begin() try { for (const [agent, entry] of entries) { + if (!ownership?.owns(agent)) { clear(entry); entry.releaseSelection?.(); entry.releaseSelection = undefined; continue } + entry.releaseSelection ??= claimNativeSelection(ctx, agent, new Set(advanced)) const isReady = ready.some(([candidate]) => candidate === agent) const wanted = isReady ? [...new Set([...config.alwaysLoaded, ...entry.selected])].filter(tool => permitted(agent, tool)) : [] const desired = new Set([...(isReady && config.enabled ? [DISCOVER] : []), ...wanted.map(publicToolName)]) @@ -99,7 +101,7 @@ export function apply(root: Context, raw: unknown): void { // surface, or independent lifecycle listeners can amplify one another. if (desired.size === entry.owned.size && [...desired].every(tool => entry.owned.has(tool)) && !inherited.length) continue clear(entry) - if (isReady && config.enabled) own(agent, entry, { + if (isReady && config.enabled) await own(agent, entry, { name: DISCOVER, description: DISCOVERY_DESCRIPTION, parameters: DISCOVERY_PARAMETERS, output: { schema: DISCOVERY_OUTPUT, render: (_args, value) => [{ type: 'text', text: typeof value === 'object' && value && 'message' in value ? String(value.message) : JSON.stringify(value) }] }, execute: async args => { @@ -114,15 +116,16 @@ export function apply(root: Context, raw: unknown): void { }, }) for (const tool of wanted) { - own(agent, entry, definition(tool, (args, exec) => entry.runtime.call(tool, args, exec))) + await own(agent, entry, entry.runtime.definition(tool)) } } // A child must not inherit its parent's activation or an old global advanced surface. - for (const [agent, entry] of entries) if (entry.ctx) { + for (const [agent, entry] of entries) if (entry.ctx && ownership?.owns(agent)) { const deny = inheritedNative(agent, entry) if (deny.length) entry.disposers.push(entry.ctx.tools.restrict({ deny })) } - } finally { mutating = false } + } finally { ownership?.end(); mutating = false } + ownership?.changedBelow() }).catch(() => { mutating = true try { for (const entry of entries.values()) clear(entry) } finally { mutating = false } @@ -134,7 +137,7 @@ export function apply(root: Context, raw: unknown): void { if (stopped || entries.has(agent) || !agent.ctx) return entries.get(agent)?.ready ?? Promise.resolve() const entry: Entry = { runtime: new NativeFileTools({ ctx, config: { ...config, workerPath: worker }, report: undefined }), selected: new Set(), pending: new Set(), disposers: [], owned: new Set(), ready: Promise.resolve() } - entries.set(agent, entry) + entries.set(agent, entry); ownership?.track(agent) entry.fiber = agent.ctx.plugin({ inject: ['tools'], apply(scoped) { entry.ctx = scoped } }) entry.ready = Promise.resolve(entry.fiber).then(async () => { await entry.runtime.start([]); await reconcile() }) return entry.ready @@ -143,7 +146,7 @@ export function apply(root: Context, raw: unknown): void { ctx.on('agent/pre-step', async (payload, next) => { await attach(payload.agent) const entry = entries.get(payload.agent) - if (entry && !payload.signal.aborted) { + if (entry && ownership?.owns(payload.agent) && !payload.signal.aborted && await native(payload.agent).catch(() => false)) { for (const tool of entry.pending) if (permitted(payload.agent, tool)) entry.selected.add(tool) entry.pending.clear() await reconcile() @@ -153,11 +156,11 @@ export function apply(root: Context, raw: unknown): void { ctx.on('agent/disposed', async ({ agent }) => { const entry = entries.get(agent) if (!entry) return - entries.delete(agent); mutating = true - try { clear(entry) } finally { mutating = false } + entries.delete(agent); ownership?.forget(agent); mutating = true + try { clear(entry); entry.releaseSelection?.() } finally { mutating = false } await entry.runtime.stop(); await entry.fiber?.dispose(); await reconcile() }) - ctx.on('tools/change', () => { void reconcile() }) + ctx.on('tools/change', () => { if (!ownership?.publishing()) void reconcile() }) ctx.on('system-prompt/assemble', async (_assembly, context, next) => { const entry = entries.get(context.scope as Agent) // Snapshot ownership before yielding: another assembly may withdraw it while @@ -182,8 +185,18 @@ export function apply(root: Context, raw: unknown): void { } finally { mutating = false } void reconcile() }) + ownership = await ownScopes(ctx, 'discovery', () => { + mutating = true + try { + for (const [agent, entry] of entries) if (!ownership?.owns(agent)) { + entry.pending.clear(); entry.runtime.cancel('Discovery row ownership changed'); clear(entry) + entry.releaseSelection?.(); entry.releaseSelection = undefined + } + } finally { mutating = false } + void reconcile() + }) ctx.effect(() => prompt.section({ - name: 'dsh-ops:discovery-tools', order: 3253, interpolate: false, + name: ownership.sectionName('dsh-ops:discovery-tools'), order: 3253, interpolate: false, text: context => { const entry = entries.get(context.scope as Agent) return entry ? renderToolingPolicy({ published: entry.owned, shellComponent: false }) : '' @@ -191,10 +204,12 @@ export function apply(root: Context, raw: unknown): void { }), 'dsh-ops: discovered capability guidance') ctx.effect(() => async () => { stopped = true; ++epoch; mutating = true - for (const entry of entries.values()) clear(entry) + for (const entry of entries.values()) { clear(entry); entry.releaseSelection?.() } await Promise.allSettled([...entries.values()].map(async entry => { await entry.runtime.stop(); await entry.fiber?.dispose() })) - entries.clear(); await chain + entries.clear(); ownership?.dispose(); await chain }, 'dsh-ops: discovery lifecycle') - for (const agent of ctx.get('agents')?.list() ?? []) void attach(agent) + for (const agent of entries.keys()) ownership.track(agent) + ownership.refresh() + for (const agent of new Set([...(ctx.get('agents')?.list() ?? []), ...ownership.agents()])) void attach(agent) }) } diff --git a/src/file/result-provider.ts b/src/file/result-provider.ts index c18ec81a..208dcf20 100644 --- a/src/file/result-provider.ts +++ b/src/file/result-provider.ts @@ -2,21 +2,33 @@ import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import type { Context } from '@deepseek-ai/cordis' interface Host { root?: object; emit?(name: 'tools/change'): unknown; get(name: 'tools'): { get(name: string, scope?: unknown): ToolDefinition | undefined } | undefined } -interface Provider { definition: ToolDefinition; scope: unknown; active: boolean; publish(visible: boolean): void } -interface State { providers: Set; ptc: Set } +export interface NativeProvider { definition: ToolDefinition; scope: unknown; active: boolean; inheritable: boolean; priority: number } +interface Provider extends NativeProvider { publish(visible: boolean): void } +interface State { providers: Set; shadows: Set; ptc: Set; selections: Set<{ scope: unknown; names: ReadonlySet }> } const owners = new WeakMap() function state(ctx: Host): State { const key = ctx.root ?? ctx let entry = owners.get(key) - if (!entry) { entry = { providers: new Set(), ptc: new Set() }; owners.set(key, entry) } + if (!entry) { entry = { providers: new Set(), shadows: new Set(), ptc: new Set(), selections: new Set() }; owners.set(key, entry) } return entry } -export async function registerNativeProvider(ctx: Host, definitions: ToolDefinition[], register: (tool: ToolDefinition) => (() => void)): Promise<() => void> { +function refresh(owner: State, scope: unknown): void { + const providers = [...owner.providers].filter(provider => provider.scope === scope) + const chosen = new Map() + if (!owner.ptc.has(scope)) for (const provider of providers) { + if (!selectionAllows(owner, scope, provider)) continue + if ((chosen.get(provider.definition.name)?.priority ?? -Infinity) <= provider.priority) chosen.set(provider.definition.name, provider) + } + // Withdraw the previous owned registration before publishing its replacement in the same layer. + for (const provider of providers) if (chosen.get(provider.definition.name) !== provider) provider.publish(false) + for (const provider of chosen.values()) provider.publish(true) +} +export async function registerNativeProvider(ctx: Host, definitions: ToolDefinition[], register: (tool: ToolDefinition) => (() => void), options: { inheritable?: boolean; priority?: number } = {}): Promise<() => void> { const { scopeOf } = await import('@deepseek-ai/dsh-scope') const owner = state(ctx), scope = scopeOf(ctx as Context) const providers = definitions.map(definition => { let dispose: (() => void) | undefined - const provider: Provider = { definition, scope, active: true, publish(visible) { + const provider: Provider = { definition, scope, active: true, inheritable: options.inheritable ?? true, priority: options.priority ?? 0, publish(visible) { if (visible && provider.active && !dispose) dispose = register(definition) else if (!visible && dispose) { const remove = dispose; dispose = undefined; remove() } } } @@ -29,26 +41,39 @@ export async function registerNativeProvider(ctx: Host, definitions: ToolDefinit provider.active = false; owner.providers.delete(provider) try { provider.publish(false) } catch (error) { errors.push(error) } } + try { refresh(owner, scope) } catch (error) { errors.push(error) } ctx.emit?.('tools/change') if (errors.length) throw new AggregateError(errors, 'Native provider teardown failed') } - try { if (!owner.ptc.has(scope)) for (const provider of providers) provider.publish(true) } - catch (error) { stop(); throw error } + try { refresh(owner, scope) } catch (error) { stop(); throw error } ctx.emit?.('tools/change') return stop } +function selectionAllows(owner: State, scope: unknown, provider: NativeProvider): boolean { + return ![...owner.selections].some(selection => selection.scope === scope && selection.names.has(provider.definition.name) + && (provider.scope !== scope || provider.priority < 10)) +} +/** Discovery controls only owned advanced Native providers in its exact layer. */ +export function claimNativeSelection(ctx: Host, scope: unknown, names: ReadonlySet): () => void { + const owner = state(ctx), selection = { scope, names } + owner.selections.add(selection); refresh(owner, scope) + return () => { owner.selections.delete(selection); refresh(owner, scope) } +} /** Yield only this plugin's registrations in the exact layer being claimed. */ export function claimPtcSurface(ctx: Host, scope: unknown): () => void { const owner = state(ctx) - owner.ptc.add(scope) - for (const provider of owner.providers) if (provider.scope === scope) provider.publish(false) - return () => { - owner.ptc.delete(scope) - for (const provider of owner.providers) if (provider.scope === scope) provider.publish(true) - } + owner.ptc.add(scope); refresh(owner, scope) + return () => { owner.ptc.delete(scope); refresh(owner, scope) } +} +/** Keep an exact origin for Native shadows; ancestor discovery activations are never inheritable. */ +export function trackNativeShadow(ctx: Host, scope: unknown, definition: ToolDefinition, source: NativeProvider): () => void { + const owner = state(ctx), shadow: NativeProvider = { definition, scope, inheritable: source.inheritable, priority: source.priority, get active() { return source.active } } + owner.shadows.add(shadow) + return () => { owner.shadows.delete(shadow) } } /** Capture before publishing PTC: external restrictions and sibling scopes remain authoritative. */ -export function nativeProviders(ctx: Host, scope: unknown): Provider[] { - const tools = ctx.get('tools') - return [...state(ctx).providers].filter(provider => provider.active && tools?.get(provider.definition.name, scope) === provider.definition) +export function nativeProviders(ctx: Host, scope: unknown, respectSelections = false): NativeProvider[] { + const tools = ctx.get('tools'), owner = state(ctx) + return [...owner.providers, ...owner.shadows].filter(provider => provider.active && tools?.get(provider.definition.name, scope) === provider.definition + && (!respectSelections || selectionAllows(owner, scope, provider))) } diff --git a/src/file/runtime.js b/src/file/runtime.js index fdbccd0d..afc03a60 100644 --- a/src/file/runtime.js +++ b/src/file/runtime.js @@ -24,14 +24,16 @@ export class NativeFileTools { if (this.config.required) throw new Error('Native file worker path is unavailable.') return [] } - const definitions = names.map(rawName => { - const execute = (args, exec) => this.call(rawName, args, exec) - return rawName === 'result' ? resultDefinition(execute) : definition(rawName, execute) - }) + const definitions = names.map(rawName => this.definition(rawName)) this.unregisterProvider = await registerNativeProvider(this.ctx, definitions, tool => this.ctx.tools.register(tool)) return names.map(publicToolName) } + definition(rawName) { + const execute = (args, exec) => this.call(rawName, args, exec) + return rawName === 'result' ? resultDefinition(execute) : definition(rawName, execute) + } + async call(tool, args, exec) { if (this.stopped) return failure('POLICY_DENIED', 'Native file component is disposed.') const controller = new AbortController() diff --git a/src/presentation.ts b/src/presentation.ts new file mode 100644 index 00000000..8a24a53a --- /dev/null +++ b/src/presentation.ts @@ -0,0 +1,14 @@ +/** Inspect public presentation before publishing a replacement. This result is + * only a boolean; the inspected assembly never becomes a provider request. */ +import type { AssembleContext, SystemPrompt } from '@deepseek-ai/dsh-system-prompt' + +const probes = new WeakSet() +export function isPresentationProbe(context: AssembleContext): boolean { return probes.has(context) } +export async function isNativePresentation(prompt: Pick, scope: NonNullable): Promise { + const context: AssembleContext = { scope } + probes.add(context) + try { + const assembly = await prompt.assemble(context) + return !assembly.sections.some(section => section.name === 'tools:ptc-only' && Boolean(section.text.trim())) + } finally { probes.delete(context) } +} diff --git a/src/ptc/index.ts b/src/ptc/index.ts index 2d40fd9e..6e7d431e 100644 --- a/src/ptc/index.ts +++ b/src/ptc/index.ts @@ -11,7 +11,7 @@ import { publicToolName } from '../policy.js' import { resolveWorker, failure } from './worker.js' import { mountWorkspacePreparation } from '../workspace-preparation.js' import { mountResultLifecycle } from '../kernel/result-lifecycle.js' -import { nativeProviders, claimPtcSurface } from '../file/result-provider.js' +import { nativeProviders, claimPtcSurface, trackNativeShadow } from '../file/result-provider.js' import { resolvePtcConfig } from '../config.js' import { PTC_RESIDENT_TOOLS } from './config.js' import type { ApiName } from './config.js' @@ -22,6 +22,7 @@ export { resolvePtcConfig } from '../config.js' export const name = 'dsh-ops-ptc' const apiTools = [...FILE_TOOLS, ...PTC_TOOLS] const publishedNames = [...apiTools, 'sdk'].map(publicToolName) +const ptcDefinitions = new WeakSet() interface Entry { ctx?: Context; fiber?: Fiber; ready: Promise; disposers: (() => void)[] selected: Set; pending: Set; owned: Map; ptc: boolean @@ -95,7 +96,7 @@ export function apply(root: Context, rawConfig: unknown): void { try { for (const entry of entries.values()) clear(entry) // Snapshot exact Native visibility only after all our masks/overrides have lifted. - const native = new Map([...entries].map(([agent, entry]) => [agent, nativeProviders(entry.ctx ?? ctx, agent)])) + const native = new Map([...entries].map(([agent, entry]) => [agent, nativeProviders(entry.ctx ?? ctx, agent, true)])) for (const [agent, entry] of entries) { if (!entry.ctx || !ownership?.owns(agent)) continue entry.ptc = ready.has(agent) @@ -105,13 +106,14 @@ export function apply(root: Context, rawConfig: unknown): void { const wanted = new Set([...PTC_RESIDENT_TOOLS, ...config.alwaysLoaded, ...entry.selected]) const definitions = [...wanted].map(tool => definition(tool, execute(agent, entry, tool))) definitions.push(sdkDefinition(config.allowedTools, execute(agent, entry, 'sdk'))) - for (const def of definitions) { entry.disposers.push(entry.ctx.tools.register(def)); entry.owned.set(def.name, def) } + for (const def of definitions) { ptcDefinitions.add(def); entry.disposers.push(entry.ctx.tools.register(def)); entry.owned.set(def.name, def) } entry.disposers.push(releaseNative) } catch (error) { releaseNative(); throw error } } for (const [agent, entry] of entries) { if (!entry.ctx || !ownership?.owns(agent)) continue if (!entry.ptc) for (const provider of native.get(agent) ?? []) { + if (!provider.inheritable && provider.scope !== agent) continue const def = provider.definition if (provider.scope === agent || tools.get(def.name, agent) === def) continue const shadow: ToolDefinition = { ...def, execute: async (args, exec) => { @@ -120,10 +122,15 @@ export function apply(root: Context, rawConfig: unknown): void { || stopped || !provider.active || version !== epoch) return failure('POLICY_DENIED', 'Native projection or owning scope is unavailable.') return def.execute(args, exec) } } - entry.disposers.push(entry.ctx.tools.register(shadow)); entry.owned.set(def.name, shadow) + entry.disposers.push(entry.ctx.tools.register(shadow), trackNativeShadow(entry.ctx, agent, shadow, provider)); entry.owned.set(def.name, shadow) } const nativeNames = new Set((native.get(agent) ?? []).map(provider => provider.definition.name)) - const deny = publishedNames.filter(name => !entry.owned.has(name) && (entry.ptc || !nativeNames.has(name)) && tools.get(name, agent)) + const ownedNative = new Set(nativeProviders(ctx, agent).map(provider => provider.definition)) + const deny = publishedNames.filter(name => { + const visible = tools.get(name, agent) + return visible && !entry.owned.has(name) && (entry.ptc || !nativeNames.has(name)) + && (ptcDefinitions.has(visible) || entry.ptc && ownedNative.has(visible)) + }) if (deny.length) entry.disposers.push(entry.ctx.tools.restrict({ deny })) } lastSnapshot = snapshot() @@ -176,7 +183,16 @@ export function apply(root: Context, rawConfig: unknown): void { for (const [agent, entry] of entries) if (agent.session === session) { entry.pending.clear(); abortOwner(agent, 'Sandbox authority changed') } void reconcile(true) }) - ctx.effect(() => prompt.section({ name: PTC_SECTION, order: 3252, interpolate: false, text: context => { + ownership = await ownPtcScopes(ctx, () => { + mutating = true + try { + for (const [agent, entry] of entries) if (!ownership?.owns(agent)) { + entry.ptc = false; entry.pending.clear(); abortOwner(agent, 'PTC row ownership changed'); clear(entry) + } + } finally { mutating = false } + void reconcile(true) + }) + ctx.effect(() => prompt.section({ name: ownership.sectionName(PTC_SECTION), order: 3252, interpolate: false, text: context => { const entry = entries.get(context.scope as Agent) return entry?.ptc && entry.owned.has(publicToolName('sdk')) ? 'OPS APIs return typed Outcome values. Inspect status before data; preserve partial coverage and continuation. Use ' + publicToolName('sdk') + ' to request advanced APIs by raw name; they become callable on the next model step. Process results locally in run_code and return only needed summaries. References do not grant authority.' : '' @@ -189,15 +205,6 @@ export function apply(root: Context, rawConfig: unknown): void { await Promise.allSettled([...entries.values()].map(entry => entry.fiber?.dispose())) entries.clear(); ownership?.dispose(); await chain }, 'dsh-ops: PTC lifecycle') - ownership = await ownPtcScopes(ctx, () => { - mutating = true - try { - for (const [agent, entry] of entries) if (!ownership?.owns(agent)) { - entry.ptc = false; entry.pending.clear(); abortOwner(agent, 'PTC row ownership changed'); clear(entry) - } - } finally { mutating = false } - void reconcile(true) - }) for (const agent of entries.keys()) ownership.track(agent) ownership.refresh() for (const agent of new Set([...(ctx.get('agents')?.list() ?? []), ...ownership.agents()])) void attach(agent) diff --git a/src/ptc/ownership.ts b/src/ptc/ownership.ts index c578c882..f46352ea 100644 --- a/src/ptc/ownership.ts +++ b/src/ptc/ownership.ts @@ -1,37 +1,3 @@ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -interface Row { scope?: object; changed(): void } -interface State { rows: Row[]; agents: Set; publishing: number } -const roots = new WeakMap() -/** The nearest plugin row owns the SDK; overlapping rows never double-register one layer. */ -export async function ownPtcScopes(ctx: Context, changed: () => void) { - const { scopeOf, scopeChainOf } = await import('@deepseek-ai/dsh-scope') - const key = ctx.root ?? ctx - let state = roots.get(key) - if (!state) { state = { rows: [], agents: new Set(), publishing: 0 }; roots.set(key, state) } - const shared = state, row: Row = { scope: scopeOf(ctx), changed } - shared.rows.push(row) - const notify = () => { - ++shared.publishing - try { for (const owner of shared.rows) owner.changed() } finally { --shared.publishing } - } - const owns = (agent: Agent): boolean => { - for (const scope of [...scopeChainOf(agent), undefined]) { - const matching = shared.rows.filter(candidate => candidate.scope === scope) - // Last mounted row wins only within the same explicit scope; unload restores the previous row. - if (matching.length) return matching.at(-1) === row - } - return false - } - // Call refresh after the caller has installed its ownership handle. - return { owns, refresh: notify, publishing: () => shared.publishing > 0, - begin: () => { ++shared.publishing }, end: () => { --shared.publishing }, - changedBelow: () => { - for (const owner of shared.rows) if (owner !== row && owner.scope && owner.scope !== row.scope - && (!row.scope || scopeChainOf(owner.scope).includes(row.scope))) owner.changed() - }, agents: () => [...shared.agents], - track: (agent: Agent) => { shared.agents.add(agent) }, - forget: (agent: Agent) => { shared.agents.delete(agent) }, - dispose: () => { shared.rows.splice(shared.rows.indexOf(row), 1); notify(); if (!shared.rows.length) roots.delete(key) }, - } -} +import { ownScopes } from '../scoped-ownership.js' +export const ownPtcScopes = (ctx: Context, changed: () => void) => ownScopes(ctx, 'ptc', changed) diff --git a/src/scoped-ownership.ts b/src/scoped-ownership.ts new file mode 100644 index 00000000..e0ebf848 --- /dev/null +++ b/src/scoped-ownership.ts @@ -0,0 +1,40 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +interface Row { scope?: object; changed(): void } +interface State { rows: Row[]; agents: Set; publishing: number; sequence: number } +const roots = new WeakMap>() +/** The nearest plugin row owns the SDK; overlapping rows never double-register one layer. */ +export async function ownScopes(ctx: Context, family: string, changed: () => void) { + const { scopeOf, scopeChainOf } = await import('@deepseek-ai/dsh-scope') + const key = ctx.root ?? ctx + let families = roots.get(key) + if (!families) { families = new Map(); roots.set(key, families) } + let state = families.get(family) + if (!state) { state = { rows: [], agents: new Set(), publishing: 0, sequence: 0 }; families.set(family, state) } + const shared = state, row: Row = { scope: scopeOf(ctx), changed } + shared.rows.push(row) + const ordinal = ++shared.sequence + const notify = () => { + ++shared.publishing + try { for (const owner of shared.rows) owner.changed() } finally { --shared.publishing } + } + const owns = (agent: Agent): boolean => { + for (const scope of [...scopeChainOf(agent), undefined]) { + const matching = shared.rows.filter(candidate => candidate.scope === scope) + // Last mounted row wins only within the same explicit scope; unload restores the previous row. + if (matching.length) return matching.at(-1) === row + } + return false + } + // Call refresh after the caller has installed its ownership handle. + return { owns, sectionName: (base: string) => ordinal === 1 ? base : base + ':' + ordinal, refresh: notify, publishing: () => shared.publishing > 0, + begin: () => { ++shared.publishing }, end: () => { --shared.publishing }, + changedBelow: () => { + for (const owner of shared.rows) if (owner !== row && owner.scope && owner.scope !== row.scope + && (!row.scope || scopeChainOf(owner.scope).includes(row.scope))) owner.changed() + }, agents: () => [...shared.agents], + track: (agent: Agent) => { shared.agents.add(agent) }, + forget: (agent: Agent) => { shared.agents.delete(agent) }, + dispose: () => { shared.rows.splice(shared.rows.indexOf(row), 1); notify(); if (!shared.rows.length) families.delete(family) }, + } +} diff --git a/test/discovery-composition.test.mjs b/test/discovery-composition.test.mjs new file mode 100644 index 00000000..1dd3d2a4 --- /dev/null +++ b/test/discovery-composition.test.mjs @@ -0,0 +1,148 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createScope } from '@deepseek-ai/dsh-scope' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import Sandbox from '@deepseek-ai/dsh-sandbox-local' +import FileSystem from '@deepseek-ai/dsh-fs-local' +import NodePtc from '@deepseek-ai/dsh-ptc-runtime-node' +import * as discovery from '../lib/discovery/index.js' +import * as ptc from '../lib/ptc/index.js' +import { applyComponent } from '../lib/index.js' +import { definition } from '../lib/file/contract.js' +import { assert, test, report, waitFor, withTempDir } from './lib/harness.mjs' +import { bootHost, shutdown } from './lib/host.mjs' +import { WORKER, requireWorker } from './lib/boundary.mjs' +requireWorker('discovery-composition') +let sequence = 0 +const names = (ctx, agent) => ctx.get('tools').schemas(agent).map(tool => tool.name).filter(name => name.startsWith('ops_')).sort() +const step = (ctx, agent) => ctx.waterfall('agent/pre-step', { agent, messages: [], turn: 1, step: ++sequence, signal: new AbortController().signal }, async () => ({ kind: 'continue' })) +const call = (ctx, agent, name, args) => ctx.get('tools').execute({ agent, name, arguments: args, callId: 'combined-' + ++sequence, signal: new AbortController().signal }) +async function setup(dir, discoveryFirst = true) { + const { ctx } = await bootHost() + const agents = []; ctx.provide('agents', { list: () => agents }) + await ctx.plugin(Subprocess); await ctx.plugin(Sandbox); await ctx.plugin(FileSystem); await ctx.plugin(NodePtc) + await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: ({ session } = {}) => ({ mode: session?.mode ?? 'workspace-write', workspaceRoot: dir, sessionId: session?.id }) })) + const config = { binaryPath: WORKER, required: true } + const mountDiscovery = (target = ctx, extra = {}) => target.plugin({ apply(inner) { discovery.apply(inner, { ...config, ...extra }) } }) + const mountFile = (target = ctx) => target.plugin({ apply(inner) { applyComponent(inner, config, 'file') } }) + let discoverRow + if (discoveryFirst) discoverRow = await mountDiscovery() + const codeRow = await ctx.plugin(ptc, config) + if (discoveryFirst === false) discoverRow = await mountDiscovery() + async function mint(mode = 'native', parent) { + const id = 'combined-' + ++sequence, agent = { id, session: { id, mode: 'workspace-write', header: { cwd: dir }, append() {}, read() { return [] } } } + agent.ctx = createScope(parent?.ctx ?? ctx, agent, parent ? { parent } : undefined).ctx + await agent.ctx.plugin({ inject: ['tools'], apply(inner) { inner.tools.presentAs(mode) } }) + agents.push(agent) + await ctx.serial('agent/created', { agent }) + return agent + } + return { ctx, mint, mountFile, mountDiscovery, discoverRow, codeRow } +} +for (const discoveryFirst of [true, false]) await test('combined Native/PTC/discovery preserves next-step snapshots and late file rows: ' + (discoveryFirst ? 'discovery first' : 'PTC first'), () => withTempDir('discovery-combined', async dir => { + const { ctx, mint, mountFile, discoverRow } = await setup(dir, discoveryFirst) + try { + fs.writeFileSync(path.join(dir, 'sample.txt'), 'small\n') + const native = await mint(), code = await mint('ptc'), child = await mint('native', code), sibling = await mint('both') + const fileRow = await mountFile(native.ctx) + await waitFor(() => names(ctx, native).includes('ops_inspect_local_file')) + for (const scope of [native, child, sibling]) assert.equal(names(ctx, scope).includes('ops_stat'), false) + const selection = await call(ctx, native, 'ops_discover', { query: 'select:ops_stat' }) + assert.equal(selection.value.status, 'completed', JSON.stringify(selection)) + const before = await ctx.get('systemPrompt').assemble({ scope: native }) + assert.equal(before.tools.some(tool => tool.name === 'ops_stat'), false) + await step(ctx, native) + const stat = await call(ctx, native, 'ops_stat', { path: 'sample.txt' }) + assert.equal(stat.value.bytes, 6, JSON.stringify(stat)) + for (const scope of [child, sibling, code]) assert.equal(names(ctx, scope).includes('ops_stat'), false) + const firstCode = await call(ctx, code, 'run_code', { description: 'Check progressive loading', code: "const selected=await tools.ops_sdk({tools:['stat']});let unavailable=false;try{await tools.ops_stat({path:'sample.txt'})}catch{unavailable=true}return{selected,unavailable}" }) + assert.equal(firstCode.isError, false, JSON.stringify(firstCode)); assert.equal(firstCode.value.result.unavailable, true) + await ctx.get('systemPrompt').assemble({ scope: code }) + assert.equal(names(ctx, code).includes('ops_stat'), false) + await step(ctx, code) + const secondCode = await call(ctx, code, 'run_code', { description: 'Check loaded stat API', code: "return await tools.ops_stat({path:'sample.txt'})" }) + assert.equal(secondCode.value.result.data.bytes, 6, JSON.stringify(secondCode)) + assert.equal(names(ctx, child).includes('ops_stat'), false) + await fileRow.dispose() + assert.equal((await call(ctx, native, 'ops_stat', { path: 'sample.txt' })).value.bytes, 6, 'discovery owns its independent worker') + await discoverRow.dispose() + assert.equal(names(ctx, native).includes('ops_discover'), false) + assert.ok(names(ctx, code).includes('ops_sdk')) + } finally { await shutdown(ctx) } +})) +await test('overlapping discovery rows restore nearest ownership and preserve unrelated external definitions', () => withTempDir('discovery-rows', async dir => { + const { ctx, mint, mountDiscovery, mountFile } = await setup(dir) + try { + const external = definition('stat', async () => ({ status: 'completed', bytes: 7 })) + const removeExternal = ctx.tools.register(external) + const parent = await mint(), child = await mint('native', parent), sibling = await mint() + assert.equal(ctx.get('tools').get('ops_stat', parent), external, 'an unrelated global registration must remain visible') + removeExternal() + const first = await mountDiscovery(parent.ctx, { allowedTools: ['grep'], alwaysLoaded: ['grep'] }) + await waitFor(() => names(ctx, parent).includes('ops_grep') && names(ctx, child).includes('ops_grep')) + assert.equal(names(ctx, sibling).includes('ops_grep'), false) + const old = ctx.get('tools').get('ops_discover', parent) + const second = await mountDiscovery(parent.ctx, { allowedTools: ['diff'], alwaysLoaded: ['diff'] }) + await waitFor(() => names(ctx, parent).includes('ops_diff') && !names(ctx, parent).includes('ops_grep')) + assert.equal((await old.execute({ query: 'select:ops_grep' }, { agent: parent })).status, 'rejected') + let changes = 0; const observe = ctx.on('tools/change', () => { ++changes }) + await step(ctx, parent); await step(ctx, child) + assert.ok(changes < 100, 'unchanged publication must converge'); observe() + await second.dispose() + await waitFor(() => names(ctx, parent).includes('ops_grep') && !names(ctx, parent).includes('ops_diff')) + await first.dispose() + await waitFor(() => names(ctx, parent).includes('ops_discover') && !names(ctx, parent).includes('ops_grep')) + const fileRow = await mountFile() + await waitFor(() => names(ctx, parent).includes('ops_inspect_local_file')) + const restriction = await parent.ctx.plugin({ inject: ['tools'], apply(inner) { inner.tools.restrict({ deny: ['ops_inspect_local_file'] }) } }) + assert.equal(names(ctx, child).includes('ops_inspect_local_file'), false) + await ctx.serial('tools/change'); await step(ctx, child) + assert.equal(names(ctx, child).includes('ops_inspect_local_file'), false, 'PTC shadow refresh must not bypass an external ancestor mask') + await restriction.dispose(); await fileRow.dispose() + } finally { await shutdown(ctx) } +})) + +await test('late discovery discovers existing agents and exposes the Native pager independently of PTC', () => withTempDir('discovery-late', async dir => { + const { ctx, mint, mountFile, mountDiscovery, codeRow } = await setup(dir, null) + try { + await mountFile() + const owner = await mint(), sibling = await mint() + assert.ok(names(ctx, owner).includes('ops_stat')) + const row = await mountDiscovery() + await waitFor(() => names(ctx, owner).includes('ops_discover') && !names(ctx, owner).includes('ops_stat')) + await codeRow.dispose() + owner.session.mode = 'read-only' + await ctx.serial('session/event', owner.session, { type: 'sandbox/mode', data: { mode: 'read-only' } }) + await waitFor(() => names(ctx, owner).includes('ops_discover')) + const selected = await call(ctx, owner, 'ops_discover', { query: 'select:ops_result' }) + assert.deepEqual(selected.value.activated, ['ops_result']) + assert.equal(names(ctx, owner).includes('ops_result'), false) + await step(ctx, owner) + const pager = ctx.get('tools').get('ops_result', owner) + assert.deepEqual(pager.parameters.properties.action.enum, ['page', 'release']) + assert.equal(names(ctx, sibling).includes('ops_result'), false) + const invalid = await call(ctx, owner, 'ops_result', { action: 'put', items: [] }) + assert.equal(invalid.value.error.code, 'INVALID_ARGUMENT', 'Native discovery must never expose PTC storage writes') + await row.dispose() + assert.ok(names(ctx, owner).includes('ops_stat'), 'unload restores the file row owned by another component') + } finally { await shutdown(ctx) } +})) + +await test('same-scope PTC rows also use unique owned sections and restore the prior SDK', () => withTempDir('discovery-ptc-rows', async dir => { + const { ctx, mint } = await setup(dir) + try { + const owner = await mint('ptc') + const mount = tool => owner.ctx.plugin({ apply(inner) { ptc.apply(inner, { binaryPath: WORKER, required: true, alwaysLoaded: [tool] }) } }) + const first = await mount('stat') + await waitFor(() => names(ctx, owner).includes('ops_stat')) + const second = await mount('grep') + await waitFor(() => names(ctx, owner).includes('ops_grep') && !names(ctx, owner).includes('ops_stat')) + const assembled = await ctx.get('systemPrompt').assemble({ scope: owner }) + assert.equal(assembled.sections.filter(section => section.name.startsWith('dsh-ops:repository-tooling:ptc') && section.text).length, 1) + await second.dispose() + await waitFor(() => names(ctx, owner).includes('ops_stat') && !names(ctx, owner).includes('ops_grep')) + await first.dispose() + await waitFor(() => names(ctx, owner).includes('ops_sdk') && !names(ctx, owner).includes('ops_stat')) + } finally { await shutdown(ctx) } +})) +report('discovery-composition') diff --git a/test/discovery.test.mjs b/test/discovery.test.mjs index 1cb19df0..491fc0c4 100644 --- a/test/discovery.test.mjs +++ b/test/discovery.test.mjs @@ -28,11 +28,11 @@ await test('discovery arguments enforce max_results 1-5 and reject hidden action assert.deepEqual(search('metadata revision hash', DISCOVERABLE_TOOLS, 1), ['stat']) assert.deepEqual(search('select:ops_pdf_read', DISCOVERABLE_TOOLS, 5), []) }) -await test('BM-03 discovery-entry overhead is <=400 pinned cl100k_base tokens without abbreviating contracts', () => { +await test('discovery-entry schema has a reproducible pinned token measurement, separate from the full BM-03 gate', () => { const wire = JSON.stringify([{ name: publicToolName('discover'), description: DISCOVERY_DESCRIPTION, parameters: DISCOVERY_PARAMETERS }]) const tokens = getEncoding('cl100k_base').encode(wire).length console.log(` discovery schema: ${tokens} cl100k_base tokens, ${Buffer.byteLength(wire)} UTF-8 bytes; js-tiktoken 1.0.21; compact JSON name/description/parameters; not provider billing or the whole OPS surface`) - assert.ok(tokens <= 400) + assert.equal(tokens, 120) }) await test('scoped file cancellation aborts active workers and disposal waits for their completion', () => withTempDir('discovery-cancel', async dir => { let spawned, complete diff --git a/test/lib/discovery.mjs b/test/lib/discovery.mjs index 05ec90f2..4b67e0b6 100644 --- a/test/lib/discovery.mjs +++ b/test/lib/discovery.mjs @@ -150,10 +150,10 @@ export async function discoveryMountChecks() { const native = await mint(ctx, 'discovery-coexist-native', dir) const code = await mint(ctx, 'discovery-coexist-ptc', dir) const lift = code.ctx.tools.presentAs('ptc') - await waitFor(() => scopedToolNames(ctx, code).includes(publicToolName('batch')) && scopedToolNames(ctx, native).includes(DISCOVER)) + await waitFor(() => scopedToolNames(ctx, code).includes(publicToolName('sdk')) && scopedToolNames(ctx, native).includes(DISCOVER)) await call(ctx, native, 'discover', { query: 'select:ops_stat' }) await nextStep(ctx, native) - await waitFor(() => scopedToolNames(ctx, code).includes(publicToolName('batch'))) + await waitFor(() => scopedToolNames(ctx, code).includes(publicToolName('sdk'))) const stable = ctx.get('tools').get(DISCOVER, native) for (let i = 0; i < 3; i++) await assemble(ctx, code) assert.equal(ctx.get('tools').get(DISCOVER, native), stable) From 4f29b2526e9215f35a67ddfdfb2ebc5028d87a44 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:59:51 +0800 Subject: [PATCH 033/118] feat(native): deduplicate reads with verified current-context evidence --- docs/CHANGELOG.md | 5 + docs/context-read-dedup.md | 38 +++ ...73\345\217\226\345\216\273\351\207\215.md" | 38 +++ src/file/runtime.js | 5 +- src/modes/native/read-dedup.ts | 130 ++++++++++ src/modes/native/read-evidence.ts | 53 ++++ src/modes/native/read-observation.ts | 66 +++++ test/context-read-dedup.test.mjs | 244 ++++++++++++++++++ test/context-read-many.test.mjs | 99 +++++++ test/lib/context-read.mjs | 82 ++++++ 10 files changed, 759 insertions(+), 1 deletion(-) create mode 100644 docs/context-read-dedup.md create mode 100644 "docs/\344\270\212\344\270\213\346\226\207\350\257\273\345\217\226\345\216\273\351\207\215.md" create mode 100644 src/modes/native/read-dedup.ts create mode 100644 src/modes/native/read-evidence.ts create mode 100644 src/modes/native/read-observation.ts create mode 100644 test/context-read-dedup.test.mjs create mode 100644 test/context-read-many.test.mjs create mode 100644 test/lib/context-read.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 852c4b98..6324cebe 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,11 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Deduplicate Native reads with current-context evidence (#261, 10/16) + +- Replace repeated line bodies only when the exact successful prior read is present in the latest completed ordinary model request and every file is freshly revalidated. Keep revisions, coverage, paging notices and canonical values. Compaction, redaction, authority changes and uncertain evidence restore full output. +- Native 单文件和多文件重读仅在当前模型请求包含此前准确正文且每个文件重新验证后省略重复行;保留版本、覆盖范围、续页提示和规范数据,压缩、脱敏、权限变化或证据不足时恢复全文。 + ### Expose canonical PTC outcomes and generated declarations (#261, 7/16) - Wrap PTC canonical data in discriminated completed/partial/failure outcomes with stable effects diff --git a/docs/context-read-dedup.md b/docs/context-read-dedup.md new file mode 100644 index 00000000..98a2c2ea --- /dev/null +++ b/docs/context-read-dedup.md @@ -0,0 +1,38 @@ +# Context-aware Native read deduplication + +中文:[上下文读取去重](上下文读取去重.md). + +A repeated Native text read can replace its numbered body with an unchanged reference only after OPS proves that the exact successful earlier body was included in the latest completed ordinary model request. The result retains its path, line range, revision, completeness and continuation notices, and identifies the earlier tool call and durable message. Its count says that rows were revalidated, not displayed again. + +Every invocation still performs the normal authorized worker read. This saves repeated model-output/context bytes; it is not an IO cache or an IO-speed claim. PTC canonical values and nested transport content remain complete. First reads, short reads for which a reference would be longer, hex output, clipped output and uncertain evidence use normal output. + +## Evidence and invalidation + +All of these facts must agree: + +- A successful owned top-level read, correlated through the registry's opaque execution token and final tools/result. +- The committed tool/result message identity, source call identity and exact text. User messages or arguments containing identical words are insufficient. +- The same message and body in the latest successfully completed ordinary llm/stream request, preceded by its matching assistant tool call and arguments. +- The same live session object, surface.contentGeneration, path, file identity, size, nanosecond modification/change times, revision, requested arguments/range and freshly rendered body. + +An ordinary request clears previous proof before starting. Failed, aborted, incomplete or superseded requests cannot restore it. Auxiliary compaction/title calls never supply evidence. An older concurrent completion cannot override a newer request. Both surface replacement and message-content projection invalidate records. An unchanged file or a body retained only in the transcript is never enough. + +Permission-mode changes, agent/session disposal, component unload, and mutation or unknown tool results discard evidence. Missing signals silently disable optimization. Evidence belongs to the mounting component and calling agent. Retention is bounded to 16 agents, 32 records and 2 MiB of bodies per agent; evictions return full output. There is no disk cache, host registry replacement or new dependency. + +This supports ops_inspect_local_file and ops_read_many. A repeated batch must match the complete earlier batch message and arguments. Every returned file is independently revalidated; a changed, missing, clipped or unobserved sibling keeps the entire body. Pending files, failed items and storage references are never eligible. Bounded line ranges may remain incomplete at file level: their original completeness and continuation notices are retained. + +## Pinned host research + +Research inspected these primary sources in the installed, lockfile-pinned 0.2.0-rc.2 npm packages: + +| Source | Contract | +| --- | --- | +| @deepseek-ai/dsh-llm/lib/types/types.d.ts: GenerateOptions | messages is the ordered provider-visible request; auxiliary calls carry purpose. | +| @deepseek-ai/dsh-llm/lib/types/index.d.ts: llm/stream; lib/index.js: adapter dispatch | The public waterfall surrounds adapter dispatch; text tool bodies preserve content and message identities. | +| @deepseek-ai/dsh-llm/lib/types/message.d.ts | Tool messages carry stable message ID, role, source and call ID; assistant tool-call blocks retain raw arguments. | +| @deepseek-ai/dsh-session/lib/types/surface.d.ts; lib/index.js: applySurfacePlan | contentGeneration changes for replacement and message projection; ordinary tail appends do not change it. | +| @deepseek-ai/dsh-tools/lib/types/index.d.ts: ToolDefinition, ToolExecution, tools/result | Finalization changes only content; the registry owns other fields and the token; final results are frozen; nested dispatches carry parent. | + +Tests use real Cordis tools, LLM runtime with a recording adapter, SessionStore, Native worker and host sandbox/subprocess services. They exercise the actual session replacement mechanism used by compaction and actual message-projection registration, not an external compactor's summarization quality. Coverage includes reduction and canonical-value preservation, current authority, changed/forged identities, auxiliary/latest/concurrent requests, metadata/range changes, nested transport, policy content, compaction/projection, disposal and bounded retention. + +Run npm run build:ptc, npm run build, then node test/run.mjs context-read. A missing worker is a skip, never a pass. diff --git "a/docs/\344\270\212\344\270\213\346\226\207\350\257\273\345\217\226\345\216\273\351\207\215.md" "b/docs/\344\270\212\344\270\213\346\226\207\350\257\273\345\217\226\345\216\273\351\207\215.md" new file mode 100644 index 00000000..846e6a90 --- /dev/null +++ "b/docs/\344\270\212\344\270\213\346\226\207\350\257\273\345\217\226\345\216\273\351\207\215.md" @@ -0,0 +1,38 @@ +# 感知当前上下文的 Native 读取去重 + +English: [Context-aware Native read deduplication](context-read-dedup.md). + +只有能够证明此前一次成功读取的完整正文确实进入最近一次成功完成的普通模型请求,OPS 才会把重复 Native 文本读取的行号正文替换为“未改变”的引用。结果仍保留路径、行范围、版本、完整性和续页提示,并注明此前工具调用和持久消息的标识。计数说明这些行已重新验证,不会声称本次再次显示了正文。 + +每次调用仍执行正常权限检查与 worker 读取。节省的是重复模型输出和上下文字节,不是 IO 缓存,也不宣称 IO 加速。PTC 的规范值及嵌套传输内容保持完整。首次读取、引用反而更长的短文本、十六进制输出、被裁切的输出,以及证据不明的情况,均返回正常全文。 + +## 证据与失效条件 + +以下事实必须全部一致: + +- 本组件拥有的成功顶层读取,通过注册表生成的执行 token 与最终 tools/result 关联。 +- 已提交的 tool/result 消息身份、来源调用身份和逐字正文。用户消息或参数里出现相同文字不足以构成证据。 +- 最近一次成功完成的普通 llm/stream 请求包含相同消息和正文,且此前存在匹配的 assistant 工具调用与参数。 +- 同一个活动会话对象、surface.contentGeneration、路径、文件身份、大小、纳秒修改/变更时间、版本、请求参数/范围,以及本次新读取渲染的正文。 + +普通请求开始前清除已有证明。失败、取消、未结束或被较新请求取代的请求不能恢复它。压缩和标题等辅助请求不能提供证明;较早请求晚完成也不能覆盖较新请求。上下文替换和消息投影都会使记录失效。文件未变或正文仅存在历史日志中,都不够。 + +权限模式变化、代理/会话销毁、组件卸载,以及修改类或未知工具的结果会丢弃证据。缺少信号时静默关闭优化。证据归挂载组件及调用代理所有。最多保留 16 个代理,每个代理 32 条记录和 2 MiB 正文;淘汰后返回全文。不新增磁盘缓存、宿主注册表替换或依赖。 + +支持 ops_inspect_local_file 和 ops_read_many。重复批次必须匹配此前整个批次的消息与参数,并分别重新验证每个返回文件;只要其中一个文件变化、缺失、被裁切或缺少证据,就保留全部正文。存在待处理文件、失败项或存储引用时不启用去重。限定行范围的读取仍可能没有覆盖整个文件,原有完整性和续页提示会保留。 + +## 固定宿主版本的研究依据 + +研究直接检查了本地安装、锁文件固定的 0.2.0-rc.2 npm 包中的一手来源: + +| 来源 | 契约 | +| --- | --- | +| @deepseek-ai/dsh-llm/lib/types/types.d.ts:GenerateOptions | messages 是模型可见的有序请求;辅助调用带有 purpose。 | +| @deepseek-ai/dsh-llm/lib/types/index.d.ts:llm/stream;lib/index.js:适配器派发 | 公开流事件包围适配器派发;文本工具正文保留内容和消息身份。 | +| @deepseek-ai/dsh-llm/lib/types/message.d.ts | 工具消息具有稳定 ID、角色、来源和调用 ID;assistant 工具调用保留原始参数。 | +| @deepseek-ai/dsh-session/lib/types/surface.d.ts;lib/index.js:applySurfacePlan | 上下文替换和消息投影改变 contentGeneration,普通尾部追加不会。 | +| @deepseek-ai/dsh-tools/lib/types/index.d.ts:ToolDefinition、ToolExecution、tools/result | 最终处理只能修改内容;其他字段与 token 归注册表所有;最终结果被冻结;嵌套调用带有 parent。 | + +测试使用真实 Cordis 工具、带记录适配器的 LLM runtime、SessionStore、Native worker 及宿主 sandbox/subprocess 服务。测试实际执行压缩使用的上下文替换机制与消息投影注册,不宣称验证外部压缩器的摘要质量。覆盖输出减少与规范值完整性、实时权限、变化/伪造身份、辅助/最新/并行请求、元数据/范围变化、嵌套传输、策略内容、压缩/投影、卸载和保留上限。 + +依次运行 npm run build:ptc、npm run build 和 node test/run.mjs context-read。缺少 worker 只能算跳过,不能算通过。 diff --git a/src/file/runtime.js b/src/file/runtime.js index ac0d52eb..eadff8f2 100644 --- a/src/file/runtime.js +++ b/src/file/runtime.js @@ -6,6 +6,7 @@ import { resultDefinition, invokeResult } from './result.js' import { registerNativeProvider } from './result-provider.js' import { safeDisposeAll } from '../teardown.js' import { invoke, failure } from './worker.js' +import { createNativeReadDedup } from '../modes/native/read-dedup.js' export class NativeFileTools { constructor({ ctx, config, report }) { @@ -16,6 +17,7 @@ export class NativeFileTools { this.active = new Map() this.disposers = [] this.stopped = false + this.readDedup = createNativeReadDedup(ctx) } async start() { @@ -26,7 +28,7 @@ export class NativeFileTools { } const definitions = NATIVE_FILE_TOOLS.map(rawName => { const execute = (args, exec) => this.call(rawName, args, exec) - return rawName === 'result' ? resultDefinition(execute) : definition(rawName, execute) + return this.readDedup.wrap(rawName === 'result' ? resultDefinition(execute) : definition(rawName, execute)) }) this.unregisterProvider = await registerNativeProvider(this.ctx, definitions, tool => this.ctx.tools.register(tool)) return NATIVE_FILE_TOOLS.map(publicToolName) @@ -54,6 +56,7 @@ export class NativeFileTools { async stop() { this.stopped = true + this.readDedup.dispose() safeDisposeAll([this.unregisterProvider].filter(Boolean), error => this.report('dsh-ops: Native pager teardown failed: ' + error, 'warn'), 'result provider') for (const controller of this.active.keys()) controller.abort(new Error('Native file component unloaded')) await Promise.allSettled(this.active.values()) diff --git a/src/modes/native/read-dedup.ts b/src/modes/native/read-dedup.ts new file mode 100644 index 00000000..f93d4374 --- /dev/null +++ b/src/modes/native/read-dedup.ts @@ -0,0 +1,130 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ToolDefinition, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import { publicToolName } from '../../policy.js' +import { CAPABILITIES } from '../../capabilities/registry.js' +import { exactJson, observeRead, unchangedContent } from './read-observation.js' +import { generation, readRecord, requestProof } from './read-evidence.js' +import type { EvidenceState, ReadRecord } from './read-evidence.js' + +const READS = new Set([publicToolName('inspect_local_file'), publicToolName('read_many')]) +const harmless = new Set(CAPABILITIES.filter(entry => !entry.mutates).map(entry => publicToolName(entry.rawName))) +const MAX_AGENTS = 16, MAX_RECORDS = 32, MAX_BODY_BYTES = 2 * 1024 * 1024 + +/** Scope-owned, bounded evidence. This never caches execution values or skips file authority/IO. */ +export function createNativeReadDedup(ctx: Context): { wrap(tool: ToolDefinition): ToolDefinition; dispose(): void } { + if (typeof ctx.on !== 'function') return { wrap: tool => tool, dispose() {} } + const states = new Map() + const pending = new Map() + const disposers: (() => void)[] = [] + let stopped = false + const forget = (agent: Agent) => { + states.delete(agent) + for (const [token, candidate] of pending) if (candidate.state.agent === agent) pending.delete(token) + } + const current = (agent: Agent): EvidenceState | undefined => { + if (stopped) return + const value = generation(agent.session) + const previous = states.get(agent) + if (value === undefined) { forget(agent); return } + if (previous?.session === agent.session && previous.generation === value) return previous + forget(agent) + if (states.size >= MAX_AGENTS) { + const oldest = states.keys().next().value + if (oldest) forget(oldest) + } + const state: EvidenceState = { agent, session: agent.session, generation: value, epoch: 0, records: [], proof: new Map() } + states.set(agent, state) + return state + } + const finalized = (exec: Readonly, result: Readonly) => { + const candidate = pending.get(exec.token) + pending.delete(exec.token) + if (!exec.agent || exec.parent) return + if (!harmless.has(exec.name)) { forget(exec.agent); return } + if (!candidate || result.isError || exactJson(result.content) !== exactJson([{ type: 'text', text: candidate.entry.body }]) + || current(exec.agent) !== candidate.state) return + const state = candidate.state + if (state.records.some(entry => entry.callId === exec.callId)) { forget(exec.agent); return } + state.records.push(candidate.entry) + while (state.records.length > MAX_RECORDS || state.records.reduce((size, entry) => size + Buffer.byteLength(entry.body), 0) > MAX_BODY_BYTES) { + const removed = state.records.shift() + if (removed) state.proof.delete(removed) + } + } + const stream = async function* (options: GenerateOptions, next: () => AsyncIterable): AsyncIterable { + if (options.purpose || stopped) { yield* next(); return } + if (!options.sessionId) { + for (const state of states.values()) { state.proof.clear(); state.epoch++ } + yield* next() + return + } + const matching = [...states.values()].filter(state => state.session.id === options.sessionId) + // A reused session id cannot transfer evidence between owners. + if (matching.length !== 1) { for (const state of matching) forget(state.agent); yield* next(); return } + const previous = matching[0]! + const state = current(previous.agent) + if (!state) { yield* next(); return } + const epoch = ++state.epoch + state.proof.clear() + const proof = () => { try { return requestProof(options, state) } catch { return new Map() } } + const before = proof() + let finished = false + for await (const chunk of next()) { + if (chunk.type === 'finish') finished = chunk.reason.kind === 'stop' || chunk.reason.kind === 'tool-calls' + yield chunk + } + if (!finished || options.signal?.aborted || current(state.agent) !== state || state.epoch !== epoch) return + const after = proof() + state.proof = new Map([...before].filter(([entry, id]) => after.get(entry) === id)) + } + disposers.push(ctx.on('tools/result', (exec, result) => { try { finalized(exec, result) } catch { if (exec.agent) forget(exec.agent) } })) + disposers.push(ctx.on('llm/stream', stream)) + disposers.push(ctx.on('session/event', (session, event) => { + try { + for (const previous of [...states.values()]) { + if (previous.session !== session) continue + if (event.type === 'sandbox/mode') { forget(previous.agent); continue } + const state = current(previous.agent) + if (!state || event.type !== 'tool/result') continue + const message = event.data.message + for (const entry of state.records) { + if (entry.callId !== message.toolCallId) continue + if (message.isError || message.source.callId !== entry.callId || !message.id + || exactJson(message.content) !== exactJson([{ type: 'text', text: entry.body }]) + || (entry.messageId && entry.messageId !== message.id)) { forget(state.agent); break } + entry.messageId = message.id + } + } + } catch { for (const state of [...states.values()]) if (state.session === session) forget(state.agent) } + })) + disposers.push(ctx.on('agent/disposed', ({ agent }) => { forget(agent) })) + disposers.push(ctx.on('session/disposed', session => { for (const state of [...states.values()]) if (state.session === session) forget(state.agent) })) + return { + wrap(tool) { + if (!READS.has(tool.name) || tool.finalizeContent) return tool + return { ...tool, finalizeContent(exec, result) { + try { + if (!exec.agent || !exec.token || exec.callId.length > 256) return + const state = current(exec.agent) + const observation = state && observeRead(tool, exec, result) + if (!state || !observation) return + for (const [entry, messageId] of state.proof) { + if (entry.key !== observation.key || entry.body !== observation.body || messageId.length > 256) continue + const text = unchangedContent(observation.body, entry.callId, messageId) + if (text) return [{ type: 'text', text }] + } + pending.set(exec.token, { state, entry: readRecord(observation, exec) }) + } catch { if (exec.agent) forget(exec.agent) } + return undefined + } } + }, + dispose() { + stopped = true + states.clear() + pending.clear() + for (const dispose of disposers.splice(0)) { try { dispose() } catch { /* Scope teardown continues. */ } } + }, + } +} diff --git a/src/modes/native/read-evidence.ts b/src/modes/native/read-evidence.ts new file mode 100644 index 00000000..7d3de159 --- /dev/null +++ b/src/modes/native/read-evidence.ts @@ -0,0 +1,53 @@ +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { GenerateOptions } from '@deepseek-ai/dsh-llm' +import type { Session } from '@deepseek-ai/dsh-session' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import { exactJson } from './read-observation.js' +import type { ReadObservation } from './read-observation.js' + +export interface ReadRecord extends ReadObservation { + readonly callId: string + readonly name: string + readonly args: string + messageId?: string +} +export interface EvidenceState { + readonly agent: Agent + readonly session: Session + readonly generation: number + epoch: number + records: ReadRecord[] + proof: Map +} +export function generation(session: Session): number | undefined { + try { + const value = session.surface.contentGeneration + return Number.isSafeInteger(value) && value >= 0 ? value : undefined + } catch { return } +} +export function readRecord(observation: ReadObservation, exec: Readonly): ReadRecord { + return { ...observation, callId: exec.callId, name: exec.name, args: exactJson(exec.arguments) } +} +/** Positive evidence is the exact role, source, durable identity and body in this provider request. */ +export function requestProof(options: GenerateOptions, state: EvidenceState): Map { + const proof = new Map() + for (const entry of state.records) { + if (!entry.messageId) continue + const results = options.messages.filter(message => message.role === 'tool' && message.id === entry.messageId) + if (results.length !== 1) continue + const result = results[0] + if (!result || result.role !== 'tool' || result.isError || result.toolCallId !== entry.callId + || result.source.kind !== 'tool' || result.source.callId !== entry.callId + || exactJson(result.content) !== exactJson([{ type: 'text', text: entry.body }])) continue + const resultIndex = options.messages.indexOf(result) + const calls = options.messages.slice(0, resultIndex).flatMap(message => message.role === 'assistant' && message.source.kind === 'model' + ? message.content.filter(block => block.type === 'tool-call' && block.id === entry.callId) : []) + if (calls.length !== 1) continue + const call = calls[0] + if (!call || call.type !== 'tool-call' || call.name !== entry.name) continue + try { + if (exactJson(JSON.parse(call.arguments)) === entry.args) proof.set(entry, entry.messageId) + } catch { /* Raw arguments that cannot identify this successful call are not proof. */ } + } + return proof +} diff --git a/src/modes/native/read-observation.ts b/src/modes/native/read-observation.ts new file mode 100644 index 00000000..995cbbe8 --- /dev/null +++ b/src/modes/native/read-observation.ts @@ -0,0 +1,66 @@ +import { lstatSync } from 'node:fs' +import { isAbsolute } from 'node:path' +import type { ToolDefinition, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import { publicToolName } from '../../policy.js' + +export interface ReadObservation { + readonly body: string + readonly key: string +} +export const record = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value) +export function exactJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(exactJson).join(',')}]` + if (record(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${exactJson(value[key])}`).join(',')}}` + return JSON.stringify(value) ?? 'undefined' +} +/** Each file needs its own fresh identity and revision; one successful sibling proves nothing. */ +function fileEvidence(value: Record): unknown[] | undefined { + if (value.status !== 'completed' || value.error || value.reference || value.storageError + || typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision) + || !Number.isSafeInteger(value.bytes) || Number(value.bytes) < 0 + || !Array.isArray(value.items) || !value.items.length) return + const items = value.items + if (!items.every(item => record(item) && typeof item.path === 'string' && isAbsolute(item.path) + && Number.isSafeInteger(item.line) && Number(item.line) > 0 && typeof item.text === 'string' + && item.truncatedInLine !== true && item.view !== 'hex')) return + const first = items[0] + if (!record(first) || typeof first.path !== 'string' || !items.every(item => record(item) && item.path === first.path)) return + const stat = lstatSync(first.path, { bigint: true }) + if (!stat.isFile() || stat.isSymbolicLink() || stat.ino === 0n || stat.size !== BigInt(Number(value.bytes))) return + return [first.path, value.revision, items.map(item => record(item) ? item.line : 0), + stat.dev.toString(), stat.ino.toString(), stat.size.toString(), stat.mtimeNs.toString(), stat.ctimeNs.toString()] +} +/** A fresh successful worker read is required before any metadata is observed. */ +export function observeRead(tool: ToolDefinition, exec: Readonly, result: Readonly): ReadObservation | undefined { + if (exec.parent || !exec.agent || result.isError || exec.signal.aborted || !record(result.value)) return + const value = result.value + if (value.error || value.reference || value.storageError || result.content.length !== 1) return + const block = result.content[0] + if (block?.type !== 'text' || block.text.includes('TruncatedBy: output-budget')) return + // Only the unmodified trusted Native renderer is eligible. Post-policy content wins. + if (exactJson(tool.output.render(exec.arguments, result.value)) !== exactJson(result.content)) return + let observations: unknown[] + if (exec.name === publicToolName('read_many')) { + if (!['completed', 'partial'].includes(String(value.status)) || value.pending !== 0 || value.failed !== 0 + || !Array.isArray(value.items) || !value.items.length || value.nextIndex !== value.items.length + || !record(exec.arguments) || !Array.isArray(exec.arguments.files) || exec.arguments.files.length !== value.items.length) return + const files = value.items.map(item => record(item) ? fileEvidence(item) : undefined) + if (files.some(item => item === undefined)) return + observations = files + } else { + const file = fileEvidence(value) + if (!file) return + observations = [file] + } + return { + body: block.text, + key: exactJson([exec.name, exec.arguments, observations]), + } +} +export function unchangedContent(body: string, callId: string, messageId: string): string | undefined { + const kept = body.split('\n').filter(line => !/^\d+ \| /.test(line)) + .map(line => line.replace(/^(\[Notice: \d+ items?) displayed;/, '$1 revalidated;')) + const notice = `[Notice: Unchanged file range; body already present in the current model context in tool call ${JSON.stringify(callId)} / message ${JSON.stringify(messageId)}. Fresh read revalidated the revision above.]` + const compact = [...kept, notice].join('\n') + return Buffer.byteLength(compact, 'utf8') < Buffer.byteLength(body, 'utf8') ? compact : undefined +} diff --git a/test/context-read-dedup.test.mjs b/test/context-read-dedup.test.mjs new file mode 100644 index 00000000..efbf7e0a --- /dev/null +++ b/test/context-read-dedup.test.mjs @@ -0,0 +1,244 @@ +import fs from 'node:fs' +import { createAssistantMessage, createToolResultMessage, createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm' +import { assert, test, report } from './lib/harness.mjs' +import { requireWorker } from './lib/boundary.mjs' +import { fixture, READ, text, full, elided } from './lib/context-read.mjs' +requireWorker('context-read-dedup') + +await test('only exact prior Native body in a completed ordinary provider request permits a compact repeat', () => fixture(async ({ agent, adapter, request, read, policyReads }) => { + const first = await read() + full(first) + full(await read()) + const before = agent.session.surface.contentGeneration + const chunks = await request() + assert.equal(chunks.at(-1).reason.kind, 'stop') + assert.deepEqual(adapter.requests[0].messages, agent.session.deriveMessages(), 'real adapter receives the same body and identities') + const beforeAuthority = policyReads.count + const repeat = await read() + assert.ok(policyReads.count > beforeAuthority, 'repeat traverses current host authority again') + elided(repeat) + assert.deepEqual(repeat.value, first.value, 'canonical value remains complete') + assert.equal(agent.session.surface.contentGeneration, before, 'append-only history is not a rewrite') + assert.ok(text(repeat).includes(first.message.id)) + assert.ok(Buffer.byteLength(text(repeat)) < Buffer.byteLength(text(first))) + // No IO cache: each call still executes the read and returns its full canonical rows. + assert.equal(repeat.value.items.length, 30) +})) + +await test('retained transcript alone, uncommitted results and untrusted user body text are not proof', () => fixture(async ({ request, read }) => { + const first = await read({ commit: false }) + await request([first.assistant, first.message]) + full(await read()) + const second = await read() + await request([createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: text(second) }] })]) + full(await read()) +})) + +await test('changed body, source call, message identity and assistant arguments fail closed', () => fixture(async ({ request, read }) => { + const first = await read() + const alternatives = [ + [first.assistant, { ...first.message, content: [{ type: 'text', text: 'redacted' }] }], + [first.assistant, createToolResultMessage({ callId: first.message.toolCallId, content: first.content, isError: false })], + [first.assistant, { ...first.message, source: { kind: 'tool', callId: 'wrong' } }], + [createAssistantMessage({ source: { provider: 'fixture', model: 'fixture' }, content: [{ type: 'tool-call', id: first.message.toolCallId, name: READ, arguments: '{"path":"wrong"}' }] }), first.message], + [first.message], + ] + for (const messages of alternatives) { await request(messages); full(await read()) } +})) + +await test('latest ordinary request must still include the body; auxiliary calls cannot supply proof', () => fixture(async ({ request, read }) => { + const first = await read() + await request(undefined, { purpose: 'compaction' }) + full(await read()) + await request([first.assistant, first.message]) + await request([], { purpose: 'session-title' }) + elided(await read()) + await request([]) + full(await read()) +})) + +await test('real session surface compaction invalidates evidence even if an old request is replayed', () => fixture(async ({ request, read, compact }) => { + const first = await read() + await request() + compact() + await request([first.assistant, first.message]) + full(await read()) +})) + +await test('file revision, same-size mtime change, identity replacement and requested range invalidate', () => fixture(async ({ file, request, read }) => { + const first = await read() + await request([first.assistant, first.message]) + const stat = fs.statSync(file) + fs.utimesSync(file, stat.atime, new Date(stat.mtimeMs + 2000)) + full(await read()) + await request([first.assistant, first.message]) + const bytes = fs.readFileSync(file) + fs.renameSync(file, file + '.old') + fs.writeFileSync(file, bytes) + fs.utimesSync(file, stat.atime, stat.mtime) + full(await read()) + await request([first.assistant, first.message]) + const range = await read({ args: { path: file, offset: 2, limit: 5 } }) + assert.match(text(range), /^2 \| payload/m) + fs.writeFileSync(file, bytes.toString().replace('payload line 1', 'changed line 1')) + const changed = await read() + assert.match(text(changed), /^1 \| changed/m) + assert.notEqual(changed.value.revision, first.value.revision) +})) + +await test('paged reads keep completeness and continuation notices when identical body is omitted', () => fixture(async ({ file, request, read }) => { + const args = { path: file, limit: 10 } + const first = await read({ args }) + await request() + const repeat = await read({ args }) + elided(repeat) + assert.match(text(repeat), /10 items revalidated; complete=false.*nextOffset=11.*omitted=20/) + assert.deepEqual(repeat.value, first.value) +})) + +await test('nested transport dispatch uses a real registry parent token and keeps full PTC data', () => fixture(async ({ ctx, agent, file, request, read }) => { + const first = await read() + await request() + let nested + await ctx.plugin({ inject: ['tools'], apply: inner => inner.tools.register({ + name: 'fixture_transport', description: 'Nested canonical dispatch fixture', parameters: { type: 'object', properties: {} }, + output: { schema: { type: 'object' }, render: () => [] }, + execute: async (_args, exec) => { + nested = await ctx.get('tools').execute({ agent, callId: 'nested-read', rootCallId: exec.rootCallId, parent: exec.token, + name: READ, arguments: { path: file }, signal: exec.signal }) + return nested.value + }, + }) }) + const outer = await ctx.get('tools').execute({ agent, callId: 'transport', name: 'fixture_transport', arguments: {}, signal: new AbortController().signal }) + full(nested) + assert.deepEqual(nested.value, first.value) + assert.deepEqual(outer.value, first.value) + await request([first.assistant, first.message]) + full(await read()) // Unknown outer tools conservatively discard evidence. +})) + +await test('post-execute policy content and failures remain authoritative', () => fixture(async ({ ctx, request, read }) => { + await read() + await request() + const stop = ctx.on('tools/post-execute', async () => ({ kind: 'accept', content: [{ type: 'text', text: 'policy redacted' }] })) + assert.equal(text(await read()), 'policy redacted') + stop() + const deny = ctx.on('tools/pre-execute', async () => ({ kind: 'deny', reason: 'permission withdrawn' })) + const denied = await ctx.get('tools').execute({ callId: 'denied', name: READ, arguments: {}, signal: new AbortController().signal }) + assert.equal(denied.isError, true) + assert.doesNotMatch(text(denied), /Unchanged file range/) + deny() +})) + +await test('missing content generation, separate owners and agent disposal cannot borrow proof', () => fixture(async ({ ctx, agent, mint, request, read }) => { + await read() + await request() + full(await read({ owner: mint('other-session') })) + full(await read({ owner: { id: 'old-host', session: { id: 'old-host', header: agent.session.header } } })) + await ctx.serial('agent/disposed', { agent }) + full(await read()) +})) + +await test('failed or superseded ordinary requests cannot restore stale provider evidence', () => fixture(async ({ adapter, request, read }) => { + const first = await read() + await request() + adapter.next = async function* () { throw new Error('provider failed') } + const failed = await request() + assert.equal(failed.at(-1).reason.kind, 'error') + full(await read()) + let release, entered + const started = new Promise(resolve => { entered = resolve }) + const hold = new Promise(resolve => { release = resolve }) + adapter.next = async function* () { entered(); await hold; yield { type: 'finish', reason: { kind: 'stop' } } } + const stale = request([first.assistant, first.message]) + await started + await request([]) + release() + await stale + full(await read()) +})) + +await test('component disposal removes hooks and stale definitions cannot elide', () => fixture(async ({ ctx, agent, fiber, request, read }) => { + await read() + await request() + const old = ctx.get('tools').get(READ, agent) + const last = await read() + elided(last) + await fiber.dispose() + assert.equal(old.finalizeContent({ agent, callId: 'stale', token: Symbol(), arguments: {}, signal: new AbortController().signal }, last), undefined) +})) +await test('message content projection increments generation and invalidates without surface replacement', () => fixture(async ({ ctx, agent, request, read }) => { + const first = await read() + await request() + const generation = agent.session.surface.contentGeneration + const replacement = agent.session.surface.replaceGeneration + await ctx.get('sessions').registerMessageProjection({ + type: 'fixture/read-redaction', + project: () => new Map([[first.event.seq, freezeMessage({ ...first.message, content: [{ type: 'text', text: 'rewritten body' }] })]]), + }) + agent.session.append('fixture/read-redaction', { target: first.event.seq }) + assert.equal(agent.session.surface.replaceGeneration, replacement) + assert.ok(agent.session.surface.contentGeneration > generation) + await request([first.assistant, first.message]) + full(await read()) +})) + +await test('sandbox-mode events and other tool results discard evidence before another read', () => fixture(async ({ ctx, agent, request, read }) => { + const first = await read() + await request() + agent.session.append('sandbox/mode', { mode: 'read-only' }) + full(await read()) + await request([first.assistant, first.message]) + full(await read()) + const fresh = await read() + await request([fresh.assistant, fresh.message]) + await ctx.plugin({ inject: ['tools'], apply: inner => inner.tools.register({ name: 'fixture_mutation', description: 'Potential mutation', + parameters: { type: 'object', properties: {} }, output: { schema: { type: 'object' }, render: () => [] }, execute: async () => ({}) }) }) + await ctx.get('tools').execute({ agent, callId: 'mutation', name: 'fixture_mutation', arguments: {}, signal: new AbortController().signal }) + full(await read()) +})) + +await test('bounded record retention expires old bodies rather than inferring that history remains visible', () => fixture(async ({ request, read }) => { + const first = await read() + let newest + for (let i = 0; i < 33; i++) newest = await read() + await request([first.assistant, first.message]) + full(await read()) + await request([newest.assistant, newest.message]) + elided(await read()) +})) + +await test('ordinary requests without session identity clear proof and incomplete streams cannot restore it', () => fixture(async ({ adapter, request, read }) => { + const first = await read() + await request() + await request([], { sessionId: undefined }) + full(await read()) + adapter.next = async function* () { yield { type: 'finish', reason: { kind: 'max-tokens' } } } + await request([first.assistant, first.message]) + full(await read()) +})) + +await test('short, hex, clipped and denied reads preserve the full authoritative result', () => fixture(async ({ ctx, agent, file, request, read }) => { + fs.writeFileSync(file, 'payload') + const short = await read() + await request() + assert.equal(text(await read()), text(short), 'a longer reference is never substituted') + const args = { path: file, view: 'hex' } + const hex = await read({ args }) + await request() + assert.equal(text(await read({ args })), text(hex)) + fs.writeFileSync(file, 'payload' + 'x'.repeat(100_000)) + const clipped = await read() + assert.ok(clipped.value.items.some(item => item.truncatedInLine) || text(clipped).includes('TruncatedBy: output-budget')) + await request() + const clippedRepeat = await read() + assert.equal(text(clippedRepeat), text({ content: ctx.get('tools').get(READ, agent).output.render({ path: file }, clippedRepeat.value) }), + 'a fresh stored reference must keep its own full authoritative rendering') + assert.doesNotMatch(text(clippedRepeat), /Unchanged file range/) + fs.unlinkSync(file) + const absent = await ctx.get('tools').execute({ agent, callId: 'absent', name: READ, arguments: { path: file }, signal: new AbortController().signal }) + assert.ok(absent.isError || absent.value.status !== 'completed') + assert.doesNotMatch(text(absent), /Unchanged file range/) +})) + +report('context-read-dedup') diff --git a/test/context-read-many.test.mjs b/test/context-read-many.test.mjs new file mode 100644 index 00000000..2f1b7b67 --- /dev/null +++ b/test/context-read-many.test.mjs @@ -0,0 +1,99 @@ +import fs from 'node:fs' +import path from 'node:path' +import { assert, test, report } from './lib/harness.mjs' +import { requireWorker } from './lib/boundary.mjs' +import { fixture, text, full, elided } from './lib/context-read.mjs' +import { publicToolName } from '../lib/policy.js' +import { observeRead } from '../lib/modes/native/read-observation.js' +requireWorker('context-read-many') +const MANY = publicToolName('read_many') +const setup = ({ dir, file, read }) => { + const second = path.join(dir, 'second.txt') + fs.copyFileSync(file, second) + const args = { files: [{ path: file, limit: 10 }, { path: second, offset: 3, limit: 12 }] } + return { second, args, batch: options => read({ name: MANY, args, ...options }) } +} + +await test('multi-file repeats require every fresh file and the exact prior batch in current provider context', () => fixture(async context => { + const { request, policyReads } = context, { batch } = setup(context) + const first = await batch() + full(first) + assert.equal(first.value.status, 'partial', 'bounded file ranges retain incomplete-file coverage') + full(await batch()) + await request([first.assistant, first.message]) + const before = policyReads.count + const repeat = await batch() + assert.ok(policyReads.count > before, 'all reads still cross current host authority') + elided(repeat) + assert.deepEqual(repeat.value, first.value) + assert.equal((text(repeat).match(/=== FILE:/g) ?? []).length, 2) + assert.match(text(repeat), /nextOffset=11/) + assert.match(text(repeat), /nextOffset=15/) + assert.match(text(repeat), /nextIndex=2; pending=0/) + assert.match(text(repeat), /complete=false/) +})) + +await test('a changed sibling, its metadata or its identity restores the entire batch body', () => fixture(async context => { + const { request } = context, { second, batch } = setup(context) + const first = await batch(), original = fs.readFileSync(second), stat = fs.statSync(second) + await request([first.assistant, first.message]) + fs.utimesSync(second, stat.atime, new Date(stat.mtimeMs + 2000)) + full(await batch()) + await request([first.assistant, first.message]) + fs.renameSync(second, second + '.old') + fs.writeFileSync(second, original) + fs.utimesSync(second, stat.atime, stat.mtime) + full(await batch()) + await request([first.assistant, first.message]) + fs.writeFileSync(second, original.toString().replace('payload line 3', 'changed line 3')) + const changed = await batch() + full(changed) + assert.match(text(changed), /^3 \| changed/m) +})) + +await test('single-file proof, reordered ranges and compaction cannot stand in for a batch', () => fixture(async context => { + const { request, read, compact } = context, { args, batch } = setup(context) + await read() + await request() + full(await batch()) + const first = await batch() + await request([first.assistant, first.message]) + const reordered = await batch({ args: { files: [...args.files].reverse() } }) + assert.match(text(reordered), /^3 \| payload/m) + assert.doesNotMatch(text(reordered), /Unchanged file range/) + compact() + await request([first.assistant, first.message]) + full(await batch()) +})) + +await test('failed and clipped file ranges never acquire dedup evidence from a successful sibling', () => fixture(async context => { + const { request } = context, { second, batch } = setup(context) + fs.unlinkSync(second) + const missing = await batch() + assert.equal(missing.value.failed, 1) + await request() + full(await batch()) + fs.writeFileSync(second, 'payload' + 'x'.repeat(100000)) + const clipped = await batch({ args: { files: [{ path: context.file }, { path: second }] } }) + await request() + const again = await batch({ args: { files: [{ path: context.file }, { path: second }] } }) + full(again) + assert.doesNotMatch(text(again), /Unchanged file range/) + assert.ok(clipped.value.items.some(file => file.items?.some(item => item.truncatedInLine)) || text(clipped).includes('TruncatedBy: output-budget')) +})) + +await test('pending files and malformed receipt coverage cannot be promoted to evidence', () => fixture(async context => { + const { ctx, agent } = context, { args, batch } = setup(context) + const first = await batch(), tool = ctx.get('tools').get(MANY, agent) + const exec = { agent, name: MANY, arguments: args, signal: new AbortController().signal } + assert.ok(observeRead(tool, exec, first)) + for (const change of [{ pending: 1 }, { failed: 1 }, { nextIndex: 1 }, { reference: { refId: 'ref://test' } }, { storageError: { code: 'IO_ERROR' } }]) { + const value = { ...first.value, ...change } + assert.equal(observeRead(tool, exec, { ...first, value, content: tool.output.render(args, value) }), undefined) + } + const value = structuredClone(first.value) + value.items[1].revision = undefined + assert.equal(observeRead(tool, exec, { ...first, value, content: tool.output.render(args, value) }), undefined) +})) + +report('context-read-many') diff --git a/test/lib/context-read.mjs b/test/lib/context-read.mjs new file mode 100644 index 00000000..60d9d2bf --- /dev/null +++ b/test/lib/context-read.mjs @@ -0,0 +1,82 @@ +import fs from 'node:fs' +import path from 'node:path' +import LlmRuntime, { LlmAdapter, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import Sandbox from '@deepseek-ai/dsh-sandbox-local' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import { createScope } from '@deepseek-ai/dsh-scope' +import { publicToolName } from '../../lib/policy.js' +import { assert, withTempDir } from './harness.mjs' +import { bootHost, mountPlugin, shutdown } from './host.mjs' + +export const READ = publicToolName('inspect_local_file') +export const text = result => result.content.map(block => block.type === 'text' ? block.text : '').join('\n') +export const full = result => assert.match(text(result), /^1 \| payload/m) +export const elided = result => { + assert.match(text(result), /Unchanged file range; body already present in the current model context/) + assert.doesNotMatch(text(result), /^\d+ \| /m) + assert.match(text(result), /=== FILE: .*Lines \d+-\d+ of \d+/) + assert.match(text(result), /revision=[a-f0-9]{64}/) +} +class RecordingAdapter extends LlmAdapter { + requests = [] + next = undefined + async *stream(options) { + this.requests.push(structuredClone({ messages: options.messages, purpose: options.purpose })) + if (this.next) { const run = this.next; this.next = undefined; yield* run(options); return } + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'ack' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'ack' } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} +export async function fixture(run) { + return withTempDir('context-read', async dir => { + const file = path.join(dir, 'read.txt') + fs.writeFileSync(file, Array.from({ length: 30 }, (_, i) => `payload line ${i + 1}: ${'content '.repeat(10)}`).join('\n')) + const { ctx } = await bootHost() + try { + await ctx.plugin(SessionStore) + await ctx.plugin(LlmRuntime) + await ctx.plugin(Subprocess) + await ctx.plugin(Sandbox, {}) + const modes = new Map() + const policyReads = { count: 0 } + await ctx.plugin(inner => inner.provide('sandboxPolicy', { + resolve: ({ session }) => { policyReads.count++; return { mode: modes.get(session) ?? 'danger-full-access', workspaceRoot: session.header.cwd, sessionId: session.id } }, + })) + const { fiber } = await mountPlugin({ ctx, config: { required: true } }) + const adapter = new RecordingAdapter() + ctx.get('llm').registerAdapter(['fixture'], adapter) + let sequence = 0 + const mint = id => { + const agent = { id, session: ctx.get('sessions').create(id, { meta: { cwd: dir } }) } + agent.ctx = createScope(ctx, agent).ctx + return agent + } + const agent = mint('read-session') + const request = async (messages = agent.session.deriveMessages(), options = {}) => { + const chunks = [] + for await (const chunk of ctx.get('llm').stream({ provider: 'fixture', model: 'fixture', sessionId: agent.session.id, messages, ...options })) chunks.push(chunk) + return chunks + } + const read = async ({ args = { path: file }, owner = agent, parent, commit = true, callId = `read-${++sequence}`, name = READ } = {}) => { + const assistant = createAssistantMessage({ source: { provider: 'fixture', model: 'fixture' }, content: [{ type: 'tool-call', id: callId, name, arguments: JSON.stringify(args) }] }) + if (commit && owner.session.append) owner.session.append('assistant/message', { turn: 1, step: sequence, message: assistant, stream: [] }, { surfaceOp: 'append' }) + const result = await ctx.get('tools').execute({ agent: owner, callId, name, arguments: args, signal: new AbortController().signal, ...(parent ? { parent } : {}) }) + const message = createToolResultMessage({ callId, content: result.content, isError: result.isError }) + const event = commit && owner.session.append ? owner.session.append('tool/result', { turn: 1, step: sequence, message }, { surfaceOp: 'append' }) : undefined + assert.equal(result.isError, false, text(result)) + return { ...result, message, assistant, event } + } + const compact = () => { + const nodes = [...agent.session.surface.nodes] + const before = agent.session.surface.contentGeneration + agent.session.append('user/message', createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: 'Compacted summary, without the read body.' }] }), + { surfaceOp: { op: 'replace', startSeq: nodes[0], endSeq: nodes.at(-1) }, sourceEventSeqs: nodes }) + assert.ok(agent.session.surface.contentGeneration > before) + } + await run({ ctx, dir, file, agent, adapter, modes, mint, request, read, compact, fiber, policyReads }) + } finally { await shutdown(ctx) } + }) +} From 6e1417f72bdf55368cb0d2f46bb35f0912baec76 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:03:54 +0800 Subject: [PATCH 034/118] feat(native): keep the resident file surface small --- docs/component-reference.md | 4 +- ...04\344\273\266\345\217\202\350\200\203.md" | 4 +- src/capabilities/discovery.ts | 2 +- src/capabilities/registry.ts | 5 +- src/capabilities/types.ts | 6 +- src/components/file/locale/en.json | 2 +- src/components/file/locale/zh.json | 2 +- src/components/file/package.json | 2 +- src/index.js | 4 +- src/policy.ts | 17 +++-- test/bundle-startup.test.mjs | 14 ++-- test/capabilities.test.mjs | 9 ++- test/discovery-composition.test.mjs | 14 ++-- test/lib/host.mjs | 2 +- test/lib/native-catalogue.mjs | 10 +++ test/mount.test.mjs | 43 +++++------ test/native-result.test.mjs | 12 +-- test/policy.test.mjs | 8 ++ test/ptc-cost.test.mjs | 2 +- test/ptc-sdk.test.mjs | 4 +- test/resident-tools.test.mjs | 76 +++++++++++++++++++ 21 files changed, 170 insertions(+), 72 deletions(-) create mode 100644 test/lib/native-catalogue.mjs create mode 100644 test/resident-tools.test.mjs diff --git a/docs/component-reference.md b/docs/component-reference.md index 52d85abf..4bb09581 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -7,7 +7,7 @@ What each of the six components publishes, with parameters and permission requir | Row `name` | Description | Default | Published tools | | --- | --- | --- | --- | | `dsh-ops/shell` | Prefer bash as the terminal, with PowerShell 7 for operations bash cannot cover | On | `ops_bash`; host `pwsh` uses bundled PowerShell 7 | -| `dsh-ops/file` | Faster, higher-performance Rust file retrieval with more concise, token-efficient output | On | Native file tools and ops_result page/release | +| `dsh-ops/file` | Faster, higher-performance Rust file retrieval with more concise, token-efficient output | On | `ops_inspect_local_file`; advanced tools load through discovery | | `dsh-ops/ptc` | Independent Rust safe batches and bounded result references, only in pure PTC sessions | Off | `ops_batch`, `ops_result` | | `dsh-ops/panel` | Data capture, off at the switch: new conversations write `/.dsh-ops/panel/.json` plus the viewer `panel.html` | Row on, `capture: false` | None; see the [configuration reference](configuration-reference.md) | | `dsh-ops/tui` | Read-only current-session OPS sidebar | Off | None; see [sidebar guide](tui-panel.md) | @@ -36,6 +36,8 @@ The former `dsh-ops/background` component (FastCtx background jobs: `ops_run`, ` ## `dsh-ops/file` — file tools +Only the bounded reader is resident. The advanced contracts below are activated through [tool discovery](discovery.md) or explicit always-loaded choices. Routing lists only the tools currently published. + All file tools resolve relative paths against the DSH session cwd, accept a single `path`, and expose no `root`, `workspace`, `cwd` or `file_path` alias. All are UTF-8 first with bounded output; glob and grep do not traverse symlinks, junctions, reparse points, hidden files or `.git`. When the host provides its `fs` service, `ops_inspect_local_file` reads and real `ops_replace` writes are recorded in the host's read-before-edit table (`fs/observed`) exactly like host `read`/`edit`: a file read with ops can be edited with host `edit` directly, and an ops write does not make the host's earlier read look like an external change. `ops_grep`, `ops_glob` and `dryRun` record nothing. diff --git "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" index 638572e5..ac508eeb 100644 --- "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" +++ "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" @@ -7,7 +7,7 @@ | 配置行 `name` | 介绍 | 默认 | 发布的工具 | | --- | --- | --- | --- | | `dsh-ops/shell` | 将默认终端改为 bash,以及 bash 覆盖不到时提供 PowerShell 7 | 开 | `ops_bash`;宿主 `pwsh` 使用随包 PowerShell 7 | -| `dsh-ops/file` | 更快、更高性能、输出更精简更省 token 的 Rust 文件检索 | 开 | Native 文件工具与 ops_result 分页/释放 | +| `dsh-ops/file` | 更快、更高性能、输出更精简更省 token 的 Rust 文件检索 | 开 | `ops_inspect_local_file`;高级工具通过发现加载 | | `dsh-ops/ptc` | 独立 Rust 安全批处理和有界结果引用,仅纯 PTC 会话加载 | 关 | `ops_batch`、`ops_result` | | `dsh-ops/panel` | 数据采集,开关默认关:新对话写入 `/.dsh-ops/panel/.json` 与读取它的 `panel.html` | 行开,`capture: false` | 无;见[配置参考](配置参考.md) | | `dsh-ops/tui` | 当前会话的只读 OPS 侧栏 | 关 | 无;见 [侧栏说明](tui-panel.zh.md) | @@ -36,6 +36,8 @@ ## `dsh-ops/file` —— 文件工具 +仅有界读取器常驻。下述高级工具契约通过[工具发现](工具发现.md)或显式常驻选择激活;路由说明只列出当前实际发布的工具。 + 全部文件工具都以 DSH 会话 cwd 为基准解析相对路径,只接受一个 `path`,不提供 `root`、`workspace`、`cwd` 或 `file_path` 别名。全部为 UTF-8 优先且有界输出;glob 与 grep 不遍历符号链接、junction、重解析点、隐藏文件和 `.git`。 宿主提供 `fs` 服务时,`ops_inspect_local_file` 的读取与 `ops_replace` 的实际写入会像宿主 `read`/`edit` 一样登记到宿主的「先读后改」观测表(`fs/observed`):用 OPS 读过的文件可直接用宿主 `edit`,OPS 写过之后宿主也不会把它误判为外部改动。`ops_grep`、`ops_glob` 与 `dryRun` 不登记。 diff --git a/src/capabilities/discovery.ts b/src/capabilities/discovery.ts index c298db2e..f0d0fc7a 100644 --- a/src/capabilities/discovery.ts +++ b/src/capabilities/discovery.ts @@ -3,7 +3,7 @@ import type { ParameterSchema } from './types.js' /** Reviewed, currently implemented advanced operations. Future formats are absent. */ export const DISCOVERY_TERMS = Object.freeze({ - read_many: 'read inspect multiple files bounded line windows', + inspect_local_file: 'read inspect single file text hex binary bounded line windows', edit_many: 'edit patch multiple files atomic per file revision cas', result: 'result reference page release stored output continuation', glob: 'glob find search file paths patterns', diff --git a/src/capabilities/registry.ts b/src/capabilities/registry.ts index a86309bc..885abc9b 100644 --- a/src/capabilities/registry.ts +++ b/src/capabilities/registry.ts @@ -16,13 +16,13 @@ const allPresentations = Object.freeze(['native', 'ptc', 'both'] as const) export const FILE_CAPABILITIES = Object.freeze(fileOrder.map(rawName => Object.freeze({ rawName, component: 'file' as const, presentations: allPresentations, - availability: 'resident' as const, mutates: fileMutations.has(rawName), + availability: rawName === 'read_many' ? 'resident' as const : 'discoverable' as const, mutates: fileMutations.has(rawName), concurrencySafe: !fileMutations.has(rawName), parameters: fileParameters[rawName], description: fileDescriptions[rawName], output: fileOutput(rawName), } satisfies SchemaCapability))) export const PTC_CAPABILITIES = Object.freeze((Object.keys(ptcParameters) as PtcToolName[]).map(rawName => Object.freeze({ rawName, component: 'ptc' as const, presentations: rawName === 'result' ? allPresentations : Object.freeze(['ptc'] as const), - ...(rawName === 'result' ? { nativeProjection: { component: 'file' as const, parameters: NATIVE_RESULT_PARAMETERS, description: NATIVE_RESULT_DESCRIPTION } } : {}), + ...(rawName === 'result' ? { nativeProjection: { component: 'file' as const, availability: 'discoverable' as const, parameters: NATIVE_RESULT_PARAMETERS, description: NATIVE_RESULT_DESCRIPTION } } : {}), availability: 'resident' as const, mutates: true, concurrencySafe: false, parameters: ptcParameters[rawName], description: ptcDescriptions[rawName], output: ptcOutput, } satisfies SchemaCapability))) @@ -47,6 +47,7 @@ export function capabilitiesFor(component: Component): readonly Capability[] { } export const FILE_TOOLS = Object.freeze(FILE_CAPABILITIES.map(capability => capability.rawName)) export const NATIVE_FILE_TOOLS = Object.freeze([...FILE_TOOLS, ...PTC_CAPABILITIES.filter(entry => entry.nativeProjection?.component === 'file').map(entry => entry.rawName)]) +export const NATIVE_RESIDENT_FILE_TOOLS = Object.freeze(FILE_CAPABILITIES.filter(capability => capability.availability === 'resident').map(capability => capability.rawName)) export const FILE_MUTATIONS = Object.freeze(FILE_CAPABILITIES.filter(capability => capability.mutates).map(capability => capability.rawName)) export const PTC_TOOLS = PTC_CAPABILITIES.map(capability => capability.rawName) export const IN_PROCESS_TOOLS = Object.freeze(SHELL_CAPABILITIES.map(capability => capability.rawName)) diff --git a/src/capabilities/types.ts b/src/capabilities/types.ts index 16bddab0..6a466c41 100644 --- a/src/capabilities/types.ts +++ b/src/capabilities/types.ts @@ -6,12 +6,12 @@ export type ParameterSchema = ObjectJsonSchema & Record & { required: string[] } -/** Availability describes today's registrations; discovery changes it in a later slice. */ +/** Native publication tier; the PTC adapter independently projects the full kernel. */ export interface CapabilityMetadata { readonly rawName: string readonly component: Component readonly presentations: readonly ToolPresentationMode[] - readonly availability: 'resident' + readonly availability: 'resident' | 'discoverable' /** Conservative per-tool flag: some actions (batch plan/result page) remain read-only. */ readonly mutates: boolean readonly concurrencySafe: boolean @@ -21,5 +21,5 @@ export interface SchemaCapability extends CapabilityMetadata { readonly parameters: ParameterSchema readonly description: string readonly output: JsonSchemaNode - readonly nativeProjection?: { readonly component: 'file'; readonly parameters: ParameterSchema; readonly description: string } + readonly nativeProjection?: { readonly component: 'file'; readonly availability?: 'resident' | 'discoverable'; readonly parameters: ParameterSchema; readonly description: string } } diff --git a/src/components/file/locale/en.json b/src/components/file/locale/en.json index eb206484..271deebd 100644 --- a/src/components/file/locale/en.json +++ b/src/components/file/locale/en.json @@ -1,6 +1,6 @@ { "meta": { "title": "dsh-ops-file", - "description": "Bounded Native file reads, edits, search and owned result-reference paging." + "description": "Bounded Native file reading; advanced operations load through tool discovery." } } diff --git a/src/components/file/locale/zh.json b/src/components/file/locale/zh.json index df62e3a7..8cff154e 100644 --- a/src/components/file/locale/zh.json +++ b/src/components/file/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { "title": "dsh-ops-file", - "description": "有界 Native 文件读取、编辑、搜索与会话结果引用分页。" + "description": "有界原生文件读取;高级操作通过工具发现按需加载。" } } diff --git a/src/components/file/package.json b/src/components/file/package.json index 9f82edc9..c7cbdad1 100644 --- a/src/components/file/package.json +++ b/src/components/file/package.json @@ -1,6 +1,6 @@ { "name": "dsh-ops-file", - "description": "High-performance Rust file search with faster execution and compact output for token efficiency.", + "description": "Bounded Native file reading; advanced operations load through tool discovery.", "icon": "./icon.png", "type": "module" } diff --git a/src/index.js b/src/index.js index 5670ed6e..d3c47195 100644 --- a/src/index.js +++ b/src/index.js @@ -2,7 +2,7 @@ import { resolveConfig } from './config.js' import { mountConfigUpdates, updatedConfig } from './config-updates.js' import { SECTION_ORDERS, TOOLING_SECTION, BASH_TOOL, publicToolName, hostShellRefusal, renderToolingPolicy } from './policy.js' -import { NATIVE_FILE_TOOLS as FILE_TOOLS } from './capabilities/registry.js' +import { NATIVE_FILE_TOOLS as FILE_TOOLS, NATIVE_RESIDENT_FILE_TOOLS } from './capabilities/registry.js' import { resolveShells } from './shells.js' import { mountSessionBash } from './session-shells.js' import { mountBundledPwsh } from './session-pwsh.js' @@ -72,7 +72,7 @@ async function mountNativeFile(ctx, config, file) { ctx.effect(() => () => { return surface.stop().catch(error => report(ctx, `dsh-ops: native file worker shutdown failed: ${error?.message ?? error}`, 'warn')) }, 'dsh-ops: native file worker') - const tools = await surface.start() + const tools = await surface.start(NATIVE_RESIDENT_FILE_TOOLS) if (tools.length) report(ctx, `dsh-ops: native Rust file worker ready; ${tools.length} tools.`, 'info') } diff --git a/src/policy.ts b/src/policy.ts index fec5b1c1..71c731c5 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -36,16 +36,18 @@ export function renderToolingPolicy({ published, mounted, levels, pwsh7 = false, ? [...NATIVE_FILE_TOOLS, ...(levels?.bash ? IN_PROCESS_TOOLS : [])].map(publicToolName) : [])) if (levels?.pwsh) names.add('pwsh') if (!shellComponent) names.delete('pwsh') + const hasDiscovery = names.has(publicToolName('discover')) const hasFiles = NATIVE_FILE_TOOLS.some(name => names.has(publicToolName(name))) const hasBash = names.has(BASH_TOOL) const hasPwsh = shellComponent && names.has('pwsh') - if (!hasFiles && !hasBash && !hasPwsh) return '' + if (!hasFiles && !hasBash && !hasPwsh && !hasDiscovery) return '' const heading = hasBash ? '# Shell routing: bash → PowerShell 7' - : hasFiles ? '# Repository file tools' : '# Windows-native shell' + : hasFiles || hasDiscovery ? '# Repository file tools' : '# Windows-native shell' const lines = [heading, '', '| Task | Tool |', '| --- | --- |'] const row = (raw: string, task: string) => { if (names.has(publicToolName(raw))) lines.push(`| ${task} | ${publicToolName(raw)} |`) } + row('discover', 'Find advanced capabilities for the next model step') row('read_many', 'Read UTF-8 ranges from multiple files') row('edit_many', 'Atomic original-text edits in one file (CAS)') row('inspect_local_file', 'Bounded UTF-8 lines / hex') @@ -64,12 +66,15 @@ export function renderToolingPolicy({ published, mounted, levels, pwsh7 = false, row('bash', 'General commands / builds / git / pipelines / scripts — preferred command executor') if (names.has('pwsh')) lines.push(`| Windows-native cmdlets / registry / services only | pwsh (${pwsh7 || levels?.pwsh ? 'bundled PowerShell 7 via host' : 'host PowerShell'}) |`) lines.push('', '- ops_* arguments are strict: pass only declared fields.') - if (hasFiles) lines.push('- Use file tools for file operations and batch independent requests.') + if (hasFiles) lines.push('- Use published file tools for file operations.') + if (hasDiscovery) lines.push('- Discover a capability when needed; selection makes it available on the next model step and performs no file action.') if (names.has(publicToolName('grep'))) lines.push('- ops_grep: summary for aggregate file/match totals; count for per-file counts; content for matching text. Follow nextOffset for remaining pages, but do not loop blindly on truncated/failed scans: they are not exhaustive.') if (hasFiles) lines.push( - '- Single-file tools use `path`, resolved relative to the current DSH session cwd. `to` on copy/move uses the same base. Use `/` separators; do not pass workspace, cwd, or file_path aliases.', - '- Native file tools are UTF-8 first and bounded. glob and grep do not traverse symlinks, junctions, reparse points, hidden files, or `.git`.', + '- Single-file tools use `path`, resolved relative to the current DSH session cwd. Use `/` separators; do not pass workspace, cwd, or file_path aliases.', + '- Native file tools are UTF-8 first and bounded.', ) + if (names.has(publicToolName('copy')) || names.has(publicToolName('move'))) lines.push('- The destination `to` uses the same session cwd base as `path`.') + if (names.has(publicToolName('glob')) || names.has(publicToolName('grep'))) lines.push('- Published searches exclude symlinks, junctions, reparse points, hidden files and `.git`.') if (names.has(publicToolName('result'))) lines.push(`- ${publicToolName('result')} accepts the original reference root; page completeness describes stored rows only.`) if (names.has(publicToolName('read_many'))) lines.push(`- ${publicToolName('read_many')} takes files with independent ranges and per-file recovery.`) const globbing = ['glob', 'grep', 'replace'].filter(raw => names.has(publicToolName(raw))) @@ -79,7 +84,7 @@ export function renderToolingPolicy({ published, mounted, levels, pwsh7 = false, lines.push(`- ${arrays} ${arrays.includes(' and ') ? 'are ARRAYS' : 'is an ARRAY'} of globs relative to \`path\`, e.g. \`["**/*.md","!vendor/**"]\`; \`*.md\` matches the top level only.${notes.length ? ` ${notes.join('; ')}.` : ''}`) } if (hasFiles) lines.push('- ops file tools never throw: check `status` and `error.code`/`error.nextAction`; a try/catch alone misses their failures.') - if (names.has(publicToolName('write_file')) || names.has(publicToolName('edit_file'))) lines.push('- Read/stat first; reuse SHA-256 `revision` as `expectedRevision` (write create-only: `absent`). Receipts include new hash, bytes and lineCount; Windows preserves owner/group/ordered DACL and supported basic attributes/timestamps; readonly overwrite is refused and unsupported streams/EA/special storage fail with METADATA_UNSUPPORTED. SACL/audit labels are not preserved. Unsupported metadata is not a permission escalation. Edit hunks target the same original and must be unique/nonoverlapping; CRLF/BOM are literal.') + if (names.has(publicToolName('write_file')) || names.has(publicToolName('edit_file'))) lines.push('- Read the current file state first; reuse SHA-256 `revision` as `expectedRevision` (write create-only: `absent`). Receipts include new hash, bytes and lineCount; Windows preserves owner/group/ordered DACL and supported basic attributes/timestamps; readonly overwrite is refused and unsupported streams/EA/special storage fail with METADATA_UNSUPPORTED. SACL/audit labels are not preserved. Unsupported metadata is not a permission escalation. Edit hunks target the same original and must be unique/nonoverlapping; CRLF/BOM are literal.') if (hasFiles) lines.push('- Follow error.nextAction; authorization denials require the user to change mode, not retries. After applied/unknown effects inspect postconditions; do not switch to a shell workaround.') if (names.has(BASH_TOOL)) lines.push('- Prefer bash for command execution. Use PowerShell only when Windows-native functionality is necessary; fix bash errors in bash, do not switch shells or mix their syntax.', '- Use command only for short commands and pipelines. For scripts longer than about 1 KB or containing heredocs, quotes, backslashes or non-ASCII text, write the script to a file first and pass script_path.', diff --git a/test/bundle-startup.test.mjs b/test/bundle-startup.test.mjs index b4522f6b..3fe639fe 100644 --- a/test/bundle-startup.test.mjs +++ b/test/bundle-startup.test.mjs @@ -45,20 +45,18 @@ for (const mode of ['read-only', 'workspace-write', 'danger-full-access']) { await agent.ctx.plugin(SearchTools, { sampleOverCapGlobResults: false }) await ctx.serial('agent/created', { agent, source: 'startup' }) const names = () => ctx.get('tools').schemas(agent).map(tool => tool.name) - await waitFor(() => names().includes('ops_inspect_local_file') && names().includes('read') && names().includes('grep') && + await waitFor(() => names().includes(presentation === 'ptc' ? 'ops_inspect_local_file' : 'ops_read_many') && names().includes('read') && names().includes('grep') && names().includes('ops_sdk') === (presentation === 'ptc') && names().includes('ops_bash'), { what: `${mode}/${presentation} default publication` }) const assembled = await ctx.get('systemPrompt').assemble({ scope: agent, signal: new AbortController().signal }) assert.equal(assembled.tools.some(tool => tool.name === 'run_code'), presentation !== 'native') assert.ok(assembled.sections.length > 0) assert.ok(names().includes('write'), 'defaults must not mask standard-preset mutation tools') - if (presentation === 'ptc') { - await ctx.get('tools').get('ops_sdk', agent).execute({ tools: ['stat'] }, { agent, signal: new AbortController().signal }) - await ctx.waterfall('agent/pre-step', { agent, messages: [], turn: 1, step: 1, signal: new AbortController().signal }, () => Promise.resolve({ kind: 'continue' })) - } - const result = await ctx.get('tools').get('ops_stat', agent).execute({ path: path.join(root, 'absent') }, { agent, signal: new AbortController().signal }) - assert.equal(result.status, 'completed', JSON.stringify(result)) - assert.equal((presentation === 'ptc' ? result.data : result).exists, false) + const name = presentation === 'ptc' ? 'ops_inspect_local_file' : 'ops_read_many' + const args = presentation === 'ptc' ? { path: path.join(root, 'absent') } : { files: [{ path: path.join(root, 'absent') }] } + const result = await ctx.get('tools').get(name, agent).execute(args, { agent, signal: new AbortController().signal }) + const data = presentation === 'ptc' ? result.data : result + assert.equal(presentation === 'ptc' ? result.error.code : data.items[0].error.code, 'NOT_FOUND', JSON.stringify(result)) console.log(` startup ${mode}/${presentation}: ${assembled.tools.length} wire tools; official + OPS intact`) } } finally { await shutdown(ctx) } diff --git a/test/capabilities.test.mjs b/test/capabilities.test.mjs index 3556a728..6b35f5fa 100644 --- a/test/capabilities.test.mjs +++ b/test/capabilities.test.mjs @@ -1,9 +1,10 @@ /** Registry migration preserves the complete previously published host definitions. */ import { createHash } from 'node:crypto' -import { CAPABILITIES, NATIVE_FILE_TOOLS, FILE_CAPABILITIES, PTC_CAPABILITIES, SHELL_CAPABILITIES, capability, capabilitiesFor } from '../lib/capabilities/registry.js' +import { CAPABILITIES, NATIVE_FILE_TOOLS, NATIVE_RESIDENT_FILE_TOOLS, FILE_CAPABILITIES, PTC_CAPABILITIES, SHELL_CAPABILITIES, capability, capabilitiesFor } from '../lib/capabilities/registry.js' import { FILE_TOOLS, FILE_MUTATIONS, IN_PROCESS_TOOLS, BASH_TOOL, publicToolName } from '../lib/policy.js' import { definition as fileDefinition, PARAMETERS as fileParameters, DESCRIPTIONS as fileDescriptions } from '../lib/file/contract.js' import { definition as ptcDefinition, PTC_TOOLS, PARAMETERS as ptcParameters, DESCRIPTIONS as ptcDescriptions } from '../lib/ptc/contract.js' +import { DISCOVERABLE_TOOLS } from '../lib/discovery/config.js' import { NativeFileTools } from '../lib/file/runtime.js' import { BASH_TOOL_NAME } from '../lib/shells/constants.js' import { assert, test, report } from './lib/harness.mjs' @@ -29,11 +30,13 @@ await test('the catalogue covers every owned file, PTC and shell tool exactly on for (const name of ['unknown', 'toString', '__proto__']) assert.equal(capability(name), undefined) }) -await test('metadata preserves current mode availability and conservative concurrency', () => { +await test('metadata separates the Native resident core from discoverable capabilities', () => { for (const entry of [...FILE_CAPABILITIES, ...SHELL_CAPABILITIES]) assert.deepEqual(entry.presentations, ['native', 'ptc', 'both']) for (const entry of PTC_CAPABILITIES) assert.deepEqual(entry.presentations, entry.rawName === 'result' ? ['native', 'ptc', 'both'] : ['ptc']) + assert.deepEqual(NATIVE_RESIDENT_FILE_TOOLS, ['read_many']) + assert.deepEqual(FILE_CAPABILITIES.filter(entry => entry.availability === 'discoverable').map(entry => entry.rawName).sort(), DISCOVERABLE_TOOLS.filter(name => name !== 'result').sort()) for (const entry of CAPABILITIES) { - assert.equal(entry.availability, 'resident') + assert.equal(entry.availability, entry.component === 'file' && entry.rawName !== 'read_many' ? 'discoverable' : 'resident') assert.equal(entry.concurrencySafe, entry.component === 'file' && !entry.mutates) } }) diff --git a/test/discovery-composition.test.mjs b/test/discovery-composition.test.mjs index 1dd3d2a4..0a41984b 100644 --- a/test/discovery-composition.test.mjs +++ b/test/discovery-composition.test.mjs @@ -45,7 +45,7 @@ for (const discoveryFirst of [true, false]) await test('combined Native/PTC/disc fs.writeFileSync(path.join(dir, 'sample.txt'), 'small\n') const native = await mint(), code = await mint('ptc'), child = await mint('native', code), sibling = await mint('both') const fileRow = await mountFile(native.ctx) - await waitFor(() => names(ctx, native).includes('ops_inspect_local_file')) + await waitFor(() => names(ctx, native).includes('ops_read_many')) for (const scope of [native, child, sibling]) assert.equal(names(ctx, scope).includes('ops_stat'), false) const selection = await call(ctx, native, 'ops_discover', { query: 'select:ops_stat' }) assert.equal(selection.value.status, 'completed', JSON.stringify(selection)) @@ -93,11 +93,11 @@ await test('overlapping discovery rows restore nearest ownership and preserve un await first.dispose() await waitFor(() => names(ctx, parent).includes('ops_discover') && !names(ctx, parent).includes('ops_grep')) const fileRow = await mountFile() - await waitFor(() => names(ctx, parent).includes('ops_inspect_local_file')) - const restriction = await parent.ctx.plugin({ inject: ['tools'], apply(inner) { inner.tools.restrict({ deny: ['ops_inspect_local_file'] }) } }) - assert.equal(names(ctx, child).includes('ops_inspect_local_file'), false) + await waitFor(() => names(ctx, parent).includes('ops_read_many')) + const restriction = await parent.ctx.plugin({ inject: ['tools'], apply(inner) { inner.tools.restrict({ deny: ['ops_read_many'] }) } }) + assert.equal(names(ctx, child).includes('ops_read_many'), false) await ctx.serial('tools/change'); await step(ctx, child) - assert.equal(names(ctx, child).includes('ops_inspect_local_file'), false, 'PTC shadow refresh must not bypass an external ancestor mask') + assert.equal(names(ctx, child).includes('ops_read_many'), false, 'PTC shadow refresh must not bypass an external ancestor mask') await restriction.dispose(); await fileRow.dispose() } finally { await shutdown(ctx) } })) @@ -107,7 +107,7 @@ await test('late discovery discovers existing agents and exposes the Native page try { await mountFile() const owner = await mint(), sibling = await mint() - assert.ok(names(ctx, owner).includes('ops_stat')) + assert.ok(names(ctx, owner).includes('ops_read_many')) const row = await mountDiscovery() await waitFor(() => names(ctx, owner).includes('ops_discover') && !names(ctx, owner).includes('ops_stat')) await codeRow.dispose() @@ -124,7 +124,7 @@ await test('late discovery discovers existing agents and exposes the Native page const invalid = await call(ctx, owner, 'ops_result', { action: 'put', items: [] }) assert.equal(invalid.value.error.code, 'INVALID_ARGUMENT', 'Native discovery must never expose PTC storage writes') await row.dispose() - assert.ok(names(ctx, owner).includes('ops_stat'), 'unload restores the file row owned by another component') + assert.ok(names(ctx, owner).includes('ops_read_many'), 'unload preserves the resident file row owned by another component') } finally { await shutdown(ctx) } })) diff --git a/test/lib/host.mjs b/test/lib/host.mjs index f9f0d084..ff691365 100644 --- a/test/lib/host.mjs +++ b/test/lib/host.mjs @@ -180,7 +180,7 @@ export async function renderSections(ctx, agent) { * @returns {Promise<{value: unknown, text: string, content: {type: string, text?: string}[]}>} the outcome. */ export async function callTool(ctx, name, args, signal = new AbortController().signal, agent) { - const definition = ctx.get('tools').get(name) + const definition = ctx.get('tools').get(name, agent) if (definition === undefined) throw new Error(`tool "${name}" is not registered`) const value = await definition.execute(args, { callId: 'test', name, arguments: args, signal, agent: agent ?? { session: { id: 'file-call', header: { cwd: process.cwd() } } } }) const content = definition.output.render(args, value) diff --git a/test/lib/native-catalogue.mjs b/test/lib/native-catalogue.mjs new file mode 100644 index 00000000..379c4dd7 --- /dev/null +++ b/test/lib/native-catalogue.mjs @@ -0,0 +1,10 @@ +/** Explicit advanced Native fixture: the real discovery component owns the requested tools. */ +import * as file from '../../lib/components/file/index.js' +import * as discovery from '../../lib/discovery/index.js' +import { DISCOVERABLE_TOOLS } from '../../lib/discovery/config.js' +export function mountNativeCatalogue(ctx, config = {}) { + return ctx.plugin({ async apply(inner) { + await inner.plugin(file, config) + await inner.plugin(discovery, { ...config, enabled: false, allowedTools: [...DISCOVERABLE_TOOLS], alwaysLoaded: [...DISCOVERABLE_TOOLS] }) + } }) +} diff --git a/test/mount.test.mjs b/test/mount.test.mjs index af081a52..9d5ec8c4 100644 --- a/test/mount.test.mjs +++ b/test/mount.test.mjs @@ -3,8 +3,9 @@ import { discoveryMountChecks } from './lib/discovery.mjs' import fs from 'node:fs' import path from 'node:path' import { pathToFileURL } from 'node:url' -import { NATIVE_FILE_TOOLS } from '../lib/capabilities/registry.js' -import { BASH_TOOL, TOOLING_SECTION, publicToolName } from '../lib/policy.js' +import { BASH_TOOL, FILE_TOOLS, TOOLING_SECTION, publicToolName } from '../lib/policy.js' +import { NATIVE_RESIDENT_FILE_TOOLS } from '../lib/capabilities/registry.js' +import * as discovery from '../lib/components/discovery/index.js' import { applyComponent } from '../lib/index.js' import * as ptc from '../lib/ptc/index.js' import { resolveWorker } from '../lib/ptc/runtime.js' @@ -18,7 +19,7 @@ import { officialToolHidingMountChecks } from './lib/official-tool-hiding.mjs' // Component 4 has no native runtime dependency; always exercise its real mounts. await officialToolHidingMountChecks() -const EXPECTED = NATIVE_FILE_TOOLS.map(publicToolName).sort() +const EXPECTED = NATIVE_RESIDENT_FILE_TOOLS.map(publicToolName).sort() const FILE_SECTION = `${TOOLING_SECTION}:file` const SHELL_SECTION = `${TOOLING_SECTION}:shell` const BG_SECTION = `${TOOLING_SECTION}:background` @@ -52,8 +53,12 @@ async function agentForCall(ctx, cwd, mode = 'danger-full-access') { const result = { id: 'file-call', session: { id: 'file-call', mode, header: { cwd } } } const { createScope } = await import('@deepseek-ai/dsh-scope') result.ctx = createScope(ctx, result).ctx + await ctx.serial('agent/created', { agent: result, source: 'startup' }) return result } +async function advancedMount(ctx, names) { + return ctx.plugin(discovery, { required: true, enabled: false, allowedTools: names, alwaysLoaded: names }) +} async function component(ctx, name, config = {}) { return ctx.plugin({ name: `fixture-${name}`, apply(inner) { return applyComponent(inner, { ...config }, name) } }) } @@ -143,6 +148,7 @@ await test('real native file calls return data and propagate errors promptly', a const { ctx } = await bootHost() try { await fileMount(ctx) + await advancedMount(ctx, ['inspect_local_file', 'write_file', 'stat', 'glob']) await withTempDir('glob', async dir => { fs.writeFileSync(path.join(dir, 'alpha.txt'), 'alpha\n') const agent = await agentForCall(ctx, dir) @@ -162,6 +168,7 @@ await test('native file paths resolve from session cwd and enforce workspace-wri const { ctx } = await bootHost() try { await fileMount(ctx) + await advancedMount(ctx, ['inspect_local_file', 'glob', 'replace']) await withTempDir('native-paths', async dir => { fs.writeFileSync(path.join(dir, 'cwd.txt'), 'cwd\n') const workspace = await agentForCall(ctx, dir, 'workspace-write') @@ -335,33 +342,19 @@ await test('an old background row loads as a no-op: no tools, no section, one wa await test('the host half refuses to mount a background component itself', () => { assert.throws(() => applyComponent({}, {}, 'background'), /unknown component "background"/) }) -await test('real file replacement surface hides duplicates, keeps the PTC transport visible, and never publishes unready processes', async () => { +await test('legacy blanket hiding fails closed when the file row publishes only its resident core', async () => { const { ctx } = await bootHost() try { await fileMount(ctx) - for (const name of ['read', 'glob', 'grep', process.platform === 'win32' ? 'pwsh' : 'bash']) ctx.tools.register(foreignTool(name)) + for (const name of ['read', 'glob', 'grep']) ctx.tools.register(foreignTool(name)) const { apply: hideOfficial } = await import('../lib/official-tool-hiding.js') - const hiding = await ctx.plugin({ name: 'real-file-takeover', apply: hideOfficial }, {}) - await new Promise(resolve => setImmediate(resolve)) - const current = await agent(ctx, 'real-file-takeover') - const assembly = await ctx.get('systemPrompt').assemble({ scope: current }) - const names = assembly.tools.map(tool => tool.name) - for (const name of ['read', 'glob', 'grep']) assert.equal(names.includes(name), false) - for (const name of EXPECTED) assert.ok(names.includes(name)) - assert.equal(new Set(names).size, names.length) - assert.equal(names.some(name => name.startsWith('ops_process_')), false) - ctx.provide('ptcRuntime', { language: 'typescript' }) - const restore = current.ctx.tools.presentAs('both') - // run_code is inside the PTC face with no reviewed replacement, so strict - // keeps it visible and warns instead of failing the session (#238). - const withTransport = await ctx.get('systemPrompt').assemble({ scope: current }) - const transported = withTransport.tools.map(tool => tool.name) - assert.ok(transported.includes('run_code')) - assert.equal(transported.includes('read'), false) - restore() - await ctx.get('systemPrompt').assemble({ scope: current }) + const hiding = await ctx.plugin({ name: 'legacy-file-takeover', apply: hideOfficial }, {}) + const current = await agent(ctx, 'legacy-file-takeover') + await assert.rejects(() => ctx.get('systemPrompt').assemble({ scope: current }), /READINESS_BLOCKED/) await hiding.dispose() - assert.ok(scopedToolNames(ctx, current).includes('read')) + const assembly = await ctx.get('systemPrompt').assemble({ scope: current }) + for (const name of ['read', 'glob', 'grep', ...EXPECTED]) assert.ok(assembly.tools.some(tool => tool.name === name)) + assert.equal(assembly.tools.some(tool => tool.name.startsWith('ops_process_')), false) } finally { await shutdown(ctx) } }) report('mount') diff --git a/test/native-result.test.mjs b/test/native-result.test.mjs index ae0f2bfa..3203f025 100644 --- a/test/native-result.test.mjs +++ b/test/native-result.test.mjs @@ -10,7 +10,7 @@ import { bootHost, mountPlugin, shutdown } from './lib/host.mjs' import { WORKER, requireWorker } from './lib/boundary.mjs' import { invoke } from '../lib/ptc/worker.js' import * as ptc from '../lib/ptc/index.js' -import { applyComponent } from '../lib/index.js' +import { mountNativeCatalogue } from './lib/native-catalogue.mjs' import { resultStorage } from '../lib/kernel/result-storage.js' import { resultDefinition } from '../lib/file/result.js' import { CAPABILITIES, NATIVE_FILE_TOOLS } from '../lib/capabilities/registry.js' @@ -21,7 +21,7 @@ async function setup(root) { const { ctx } = await bootHost() await ctx.plugin(Subprocess); await ctx.plugin(Sandbox) await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: ({ session }) => ({ mode: session.mode, workspaceRoot: root, sessionId: session.id }) })) - const file = await mountPlugin({ ctx, config: { binaryPath: WORKER, required: true }, expectTools: NATIVE_FILE_TOOLS.length }) + const file = { fiber: await mountNativeCatalogue(ctx, { binaryPath: WORKER, required: true }) } return { ctx, file } } async function agent(ctx, cwd, presentation = 'native', parent) { @@ -51,7 +51,7 @@ await test('one catalogue entry exposes distinct SDK-valid Native and PTC result assert.ok(entry[0].parameters.properties.ttlMs); assert.ok(entry[0].output.properties.reference) }) -await test('file-only Native SDK execution pages/releases with cwd binding, defaults and no business writes', async () => { +await test('Native discovery without PTC SDK execution pages/releases with cwd binding, defaults and no business writes', async () => { await withTempDir('native-reference', async root => { const cwd = path.join(root, 'nested'); fs.mkdirSync(cwd); fs.writeFileSync(path.join(cwd, 'keep'), 'unchanged') const { ctx } = await setup(root) @@ -75,7 +75,7 @@ await test('file-only Native SDK execution pages/releases with cwd binding, defa }) }) -await test('file-only Native spill pages reconstruct one large escaped line exactly through the real host', async () => { +await test('Native discovery without PTC spill pages reconstruct one large escaped line exactly through the real host', async () => { await withTempDir('native-spill-roundtrip', async root => { const cwd = path.join(root, 'nested'); fs.mkdirSync(cwd) const original = '\u0000'.repeat(22000) + '😀end' @@ -142,7 +142,7 @@ await test('Native pager denies hidden producers, other sessions, narrower autho }) }) -await test('Native children of PTC parents retain page-only projection and file unload preserves PTC put', async () => { +await test('Native children of PTC parents retain page-only projection and Native component unload preserves PTC put', async () => { await withTempDir('native-reference-scopes', async root => { const { ctx, file } = await setup(root) try { @@ -181,7 +181,7 @@ await test('agent-scoped file rows isolate sibling pagers and yield only their e const component = await ctx.plugin(ptc, { binaryPath: WORKER, required: true }) const parent = await agent(ctx, root, 'ptc'), left = await agent(ctx, root, 'native', parent), right = await agent(ctx, root, 'native', parent) const current = value => ctx.get('tools').get('ops_result', value) - const mount = value => value.ctx.plugin({ name: 'scoped-file-' + value.id, apply(inner) { return applyComponent(inner, { binaryPath: WORKER, required: true }, 'file') } }) + const mount = value => mountNativeCatalogue(value.ctx, { binaryPath: WORKER, required: true }) const leftFile = await mount(left) await waitFor(() => current(left)?.parameters.properties.action.enum.length === 2, { timeoutMs: 5000, what: 'left scoped Native pager' }) assert.equal(current(right), undefined, 'a sibling cannot borrow the left file executor') diff --git a/test/policy.test.mjs b/test/policy.test.mjs index 8ac6f38f..6f9d8d04 100644 --- a/test/policy.test.mjs +++ b/test/policy.test.mjs @@ -44,6 +44,14 @@ await test('partial tool availability never advertises missing tools', () => { assert.ok(text.includes(files[0])) for (const name of files.slice(1)) assert.equal(text.includes(name), false) }) +await test('resident-only routing omits unavailable operation hints and includes discovery only when published', () => { + const reader = renderToolingPolicy({ published: [publicToolName('inspect_local_file')], shellComponent: false }) + for (const text of ['glob', 'grep', 'copy/move', 'ops_stat', 'ops_discover']) assert.equal(reader.includes(text), false) + const discovery = renderToolingPolicy({ published: [publicToolName('discover')], shellComponent: false }) + assert.ok(discovery.includes(publicToolName('discover'))) + assert.match(discovery, /next model step/) + for (const name of files) assert.equal(discovery.includes(name), false) +}) await test('shell routing prefers bash and names bundled pwsh only when available', () => { const text = renderToolingPolicy({ published: [BASH_TOOL, 'pwsh'], pwsh7: true }) assert.match(text, /^# Shell routing: bash → PowerShell 7/) diff --git a/test/ptc-cost.test.mjs b/test/ptc-cost.test.mjs index 253c371c..fbed30db 100644 --- a/test/ptc-cost.test.mjs +++ b/test/ptc-cost.test.mjs @@ -62,7 +62,7 @@ await test('measure PTC enhancement separately from the existing file/shell decl assert.equal(after.promptSha256, before.promptSha256, `${mode} prompt must not gain PTC declarations: ${JSON.stringify({ before: before.sections, after: after.sections })}`) assert.equal(after.wireSha256, before.wireSha256, `${mode} wire must not gain PTC tools`) } else { - assert.ok(after.promptBytes < before.promptBytes, 'progressive SDK replaces the eager Native file catalogue with a smaller initial declaration surface') + assert.ok(after.promptBytes > 0, 'record the actual PTC declaration cost against the current resident Native surface') assert.equal(after.wireSha256, before.wireSha256, 'run_code remains the only direct transport') } return { mode, enhancementPromptChars: after.promptChars - before.promptChars, diff --git a/test/ptc-sdk.test.mjs b/test/ptc-sdk.test.mjs index 3da959c6..a01ecb9b 100644 --- a/test/ptc-sdk.test.mjs +++ b/test/ptc-sdk.test.mjs @@ -10,7 +10,7 @@ import { assert, test, report, withTempDir, waitFor } from './lib/harness.mjs' import { bootHost, shutdown } from './lib/host.mjs' import { WORKER, requireWorker } from './lib/boundary.mjs' import * as ptc from '../lib/ptc/index.js' -import { applyComponent } from '../lib/index.js' +import { mountNativeCatalogue } from './lib/native-catalogue.mjs' import { resolvePtcConfig } from '../lib/config.js' import { sdkDefinition, requestedApis } from '../lib/ptc/sdk.js' requireWorker('ptc-sdk') @@ -24,7 +24,7 @@ async function setup(root, { native = false, config = {}, real = false } = {}) { if (!real) inner.provide('ptcRuntime', { language: 'typescript' }) }) if (real) await ctx.plugin(NodePtc, {}) - const mountFile = target => target.plugin({ apply(inner) { applyComponent(inner, { binaryPath: WORKER, required: true }, 'file') } }) + const mountFile = target => mountNativeCatalogue(target, { binaryPath: WORKER, required: true }) if (native) await mountFile(ctx) const component = await ctx.plugin(ptc, { binaryPath: WORKER, required: true, ...config }) async function agent(mode = 'ptc', parent) { diff --git a/test/resident-tools.test.mjs b/test/resident-tools.test.mjs new file mode 100644 index 00000000..e41fedcf --- /dev/null +++ b/test/resident-tools.test.mjs @@ -0,0 +1,76 @@ +/** The actual assembled file/discovery lane, with the full catalogue measured separately. */ +import fs from 'node:fs' +import path from 'node:path' +import { getEncoding } from 'js-tiktoken' +import * as file from '../lib/components/file/index.js' +import { FILE_TOOLS, publicToolName } from '../lib/policy.js' +import { NativeFileTools } from '../lib/file/runtime.js' +import { NATIVE_FILE_TOOLS } from '../lib/capabilities/registry.js' +import { bashToolDefinition } from '../lib/shells/tool.js' +import { assert, test, report, withTempDir, waitFor } from './lib/harness.mjs' +import { bootDiscoveryHost, mintDiscoveryAgent, discoveryNextStep } from './lib/discovery.mjs' +import { shutdown, scopedToolNames, registryNames, callTool } from './lib/host.mjs' + +const INSPECT = publicToolName('read_many'), DISCOVER = publicToolName('discover') +const measure = tools => { + const wire = JSON.stringify(tools.map(({ name, description, parameters }) => ({ name, description, parameters }))) + return { tokens: getEncoding('cl100k_base').encode(wire).length, bytes: Buffer.byteLength(wire) } +} +async function mount(config) { + const host = await bootDiscoveryHost(config) + const fileFiber = await host.ctx.plugin(file, { required: true }) + await waitFor(() => registryNames(host.ctx).includes(INSPECT)) + return { ...host, fileFiber } +} +await test('the resident file row publishes one bounded reader even with the discovery entry disabled', () => withTempDir('resident', async dir => { + const { ctx } = await mount({ enabled: false }) + try { + const agent = await mintDiscoveryAgent(ctx, 'resident-disabled', dir) + assert.deepEqual(registryNames(ctx), [INSPECT]) + assert.deepEqual(scopedToolNames(ctx, agent), [INSPECT]) + fs.writeFileSync(path.join(dir, 'read.txt'), 'bounded reader') + const read = await callTool(ctx, INSPECT, { files: [{ path: 'read.txt' }] }, undefined, agent) + assert.equal(read.value.status, 'completed') + assert.match(read.text, /1 \| bounded reader/) + const absent = await ctx.get('tools').execute({ agent, name: publicToolName('stat'), arguments: { path: 'read.txt' }, callId: 'absent', signal: new AbortController().signal }) + assert.equal(absent.isError, true) + } finally { await shutdown(ctx) } +})) +await test('measure the full default Native surface including Bash and retain the unresolved BM-03 budget', () => withTempDir('resident', async dir => { + const { ctx } = await mount() + try { + const agent = await mintDiscoveryAgent(ctx, 'resident-budget', dir) + const assembly = await ctx.get('systemPrompt').assemble({ scope: agent }) + assert.deepEqual(assembly.tools.map(tool => tool.name).sort(), [INSPECT, DISCOVER].sort()) + const core = measure(assembly.tools) + const discovery = assembly.tools.find(tool => tool.name === DISCOVER) + const full = measure([...NATIVE_FILE_TOOLS.map(raw => new NativeFileTools({ctx:{},config:{}}).definition(raw)), discovery]) + const shell = bashToolDefinition({ file: 'fixture', source: 'test', subprocess: {} }) + const withShell = measure([...assembly.tools, shell]) + console.log(' cl100k_base/js-tiktoken1.0.21 compact host-schema JSON:', JSON.stringify({ core, fullFileCatalogue: full, corePlusBash: withShell })) + assert.equal(core.tokens, 291, JSON.stringify(core)) + assert.ok(full.tokens > 400 && full.tokens > core.tokens, 'retain honest full-surface measurement') + assert.equal(withShell.tokens, 659, 'BM-03 remains unresolved until the full default, including Bash, fits400') + const text = assembly.sections.map(section => section.text).join('\n') + for (const raw of FILE_TOOLS.filter(raw => raw !== 'read_many')) assert.equal(text.includes(publicToolName(raw)), false) + assert.ok(text.includes(DISCOVER)) + const selected = await callTool(ctx, DISCOVER, { query: 'select:ops_stat' }, undefined, agent) + assert.deepEqual(selected.value.activated, [publicToolName('stat')]) + assert.equal(scopedToolNames(ctx, agent).includes(publicToolName('stat')), false) + await discoveryNextStep(ctx, agent) + const active = await ctx.get('systemPrompt').assemble({ scope: agent }) + assert.ok(active.sections.some(section => section.text.includes(publicToolName('stat')))) + } finally { await shutdown(ctx) } +})) +await test('always-loaded choices explicitly expand the initial budget and survive file-row unload', () => withTempDir('resident', async dir => { + const { ctx, fileFiber } = await mount({ allowedTools: ['glob', 'grep'], alwaysLoaded: ['glob', 'grep'] }) + try { + const agent = await mintDiscoveryAgent(ctx, 'resident-expanded', dir) + const assembly = await ctx.get('systemPrompt').assemble({ scope: agent }) + assert.deepEqual(assembly.tools.map(tool => tool.name).sort(), [INSPECT, DISCOVER, publicToolName('glob'), publicToolName('grep')].sort()) + assert.ok(measure(assembly.tools).tokens > 400) + await fileFiber.dispose() + assert.deepEqual(scopedToolNames(ctx, agent), [DISCOVER, publicToolName('glob'), publicToolName('grep')].sort()) + } finally { await shutdown(ctx) } +})) +report('resident-tools') From 0904998fbcb53030d6545674fa5f83a1267806b2 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:09:27 +0800 Subject: [PATCH 035/118] test(native): load both readers through their owning components --- test/lib/context-read.mjs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/lib/context-read.mjs b/test/lib/context-read.mjs index 60d9d2bf..b9497655 100644 --- a/test/lib/context-read.mjs +++ b/test/lib/context-read.mjs @@ -6,6 +6,8 @@ import Sandbox from '@deepseek-ai/dsh-sandbox-local' import Subprocess from '@deepseek-ai/dsh-subprocess-local' import { createScope } from '@deepseek-ai/dsh-scope' import { publicToolName } from '../../lib/policy.js' +import * as Discovery from '../../lib/components/discovery/index.js' +import { DISCOVERABLE_TOOLS } from '../../lib/discovery/config.js' import { assert, withTempDir } from './harness.mjs' import { bootHost, mountPlugin, shutdown } from './host.mjs' @@ -45,7 +47,11 @@ export async function fixture(run) { await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: ({ session }) => { policyReads.count++; return { mode: modes.get(session) ?? 'danger-full-access', workspaceRoot: session.header.cwd, sessionId: session.id } }, })) - const { fiber } = await mountPlugin({ ctx, config: { required: true } }) + const { fiber: fileFiber } = await mountPlugin({ ctx, config: { required: true } }) + const discoveryFiber = await ctx.plugin(Discovery, { enabled: false, required: true, + alwaysLoaded: ['inspect_local_file', 'read_many'].filter(name => DISCOVERABLE_TOOLS.includes(name)) }) + const fiber = { dispose: async () => { await discoveryFiber.dispose(); await fileFiber.dispose() } } + const admitted = new WeakSet() const adapter = new RecordingAdapter() ctx.get('llm').registerAdapter(['fixture'], adapter) let sequence = 0 @@ -61,6 +67,11 @@ export async function fixture(run) { return chunks } const read = async ({ args = { path: file }, owner = agent, parent, commit = true, callId = `read-${++sequence}`, name = READ } = {}) => { + if (!admitted.has(owner)) { + owner.ctx ??= createScope(ctx, owner).ctx + await ctx.serial('agent/created', { agent: owner }) + admitted.add(owner) + } const assistant = createAssistantMessage({ source: { provider: 'fixture', model: 'fixture' }, content: [{ type: 'tool-call', id: callId, name, arguments: JSON.stringify(args) }] }) if (commit && owner.session.append) owner.session.append('assistant/message', { turn: 1, step: sequence, message: assistant, stream: [] }, { surfaceOp: 'append' }) const result = await ctx.get('tools').execute({ agent: owner, callId, name, arguments: args, signal: new AbortController().signal, ...(parent ? { parent } : {}) }) From 2476a4d5d3c942ba98890cd9dbec4095cb391e9f Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:04:04 +0800 Subject: [PATCH 036/118] refactor(shell): expose cancellable execution handles --- docs/CHANGELOG.md | 6 + docs/component-reference.md | 1 + ...04\344\273\266\345\217\202\350\200\203.md" | 1 + src/shells/execution.js | 123 +++++++++++++++++ src/shells/tool.js | 127 ++---------------- test/shells-execution.test.mjs | 86 ++++++++++++ 6 files changed, 226 insertions(+), 118 deletions(-) create mode 100644 src/shells/execution.js create mode 100644 test/shells-execution.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 987f2367..7972f122 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,12 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Preserve Bash cancellation through pending confinement preparation (#261) + +- Bash execution exposes one live handle for observation and settlement. Cancellation settles + pending sandbox preparation immediately, and a late provider response cannot start a command. + The first timeout or caller cancellation remains the reported stop cause. + ### Deduplicate Native reads with current-context evidence (#261, 10/16) - Replace repeated line bodies only when the exact successful prior read is present in the latest completed ordinary model request and every file is freshly revalidated. Keep revisions, coverage, paging notices and canonical values. Compaction, redaction, authority changes and uncertain evidence restore full output. diff --git a/docs/component-reference.md b/docs/component-reference.md index 52d85abf..5348f4ee 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -152,6 +152,7 @@ Bounded session-owned result references: PTC can put object rows; Native file to Routing: **toolkit → bash → PowerShell 7**. - `ops_bash`: the preferred general command executor. Use `command` for short commands and `script_path` for long scripts. + Cancellation also stops pending sandbox preparation; a late preparation result cannot start the command. When cancellation races a timeout, the first cause is reported. Relative `workdir` paths (including `.`) resolve against the calling session's workspace, not the host process directory. Omitting `workdir` or passing a blank string uses that workspace; absolute paths remain absolute. Missing or non-directory targets are rejected before execution, with the resolved path in the error. On Windows, `\child` uses the session's drive; a drive-relative path such as `C:child` on a different drive must be written as an absolute path. - Host `pwsh`: Windows-native operations, using bundled PowerShell 7. diff --git "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" index 638572e5..55225e4e 100644 --- "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" +++ "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" @@ -152,6 +152,7 @@ 执行顺序:**工具包 → bash → PowerShell 7**。 - `ops_bash`:优先的通用命令执行器。短命令用 `command`,长脚本用 `script_path`。 + 取消也会终止尚未完成的沙箱准备;迟到的准备结果不能再启动命令。取消与超时同时发生时,报告最先触发的原因。 相对 `workdir`(包括 `.`)按调用会话的工作区解析;省略或传空白字符串时使用该工作区,绝对路径保持绝对路径语义。不存在或不是目录的目标会在执行前被拒绝,错误中包含解析后的路径。 Windows 上的 `\child` 使用会话所在盘符;`C:child` 这类位于另一盘符的相对路径需改写为绝对路径。 - 宿主 `pwsh`:Windows 原生操作,使用随包 PowerShell 7。 diff --git a/src/shells/execution.js b/src/shells/execution.js new file mode 100644 index 00000000..bfae0121 --- /dev/null +++ b/src/shells/execution.js @@ -0,0 +1,123 @@ +/** Live Bash command over the host subprocess service; no service registration. */ +import { Buffer } from 'node:buffer' +import { DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_MAX_SPILL_BYTES, shellEnvOverrides } from './constants.js' +import { formatBashSpawnError } from './spawn-error.js' + +const emptyRead = () => ({ text: '', nextOffset: 0, lossy: false }) + +/** Read the retained stream after settlement, preserving honest truncation facts. */ +export function settledStream(reader, includeMetadata = false) { + const read = reader?.readFrom?.(0) ?? emptyRead() + const text = typeof read.text === 'string' ? read.text : '' + return { + text, + truncated: read.lossy === true, + ...(read.spillPath !== undefined ? { spillPath: read.spillPath } : {}), + ...(includeMetadata ? { + bytes: Buffer.byteLength(text), + bytesTotal: Number.isFinite(read.nextOffset) ? read.nextOffset : Buffer.byteLength(text), + lines: text === '' ? 0 : text.split(/\n/).length - (text.endsWith('\n') ? 1 : 0), + } : {}), + } +} + +/** + * Start preparation and execution once. The caller owns the returned handle; the host owns + * process containment, collection and disposal. A job can observe output without consuming it. + * `onExpiry: none` disables only this executor's deadline, never caller cancellation. + */ +export function startBashExecution({ file, source, subprocess, prepareArgv, env = {}, projectResult }, spec, exec = {}) { + const controller = new AbortController() + const callerSignal = exec.signal + const startedAt = performance.now() + let cause + let handle + let timer + const stop = (kind, reason) => { + if (controller.signal.aborted) return + cause = kind + controller.abort(reason) + } + const onCallerAbort = () => stop('cancelled', callerSignal.reason) + if (callerSignal?.aborted) onCallerAbort() + else callerSignal?.addEventListener('abort', onCallerAbort, { once: true }) + if (spec.onExpiry !== 'none' && !controller.signal.aborted) { + timer = setTimeout(() => stop('timeout', new Error(`command timed out after ${spec.timeoutMs}ms`)), spec.timeoutMs) + timer.unref?.() + } + const reader = stream => ({ readFrom: offset => handle?.collected?.[stream]?.readFrom(offset) ?? emptyRead() }) + const process = { + status: 'running', + observed: { stdout: reader('stdout'), stderr: reader('stderr') }, + kill: reason => stop('cancelled', new Error(reason ?? 'bash execution cancelled')), + result: () => result, + done: undefined, + } + const flags = () => ({ + timedOut: cause === 'timeout', + timeoutMs: spec.timeoutMs, + ...(cause === 'cancelled' ? { cancelled: true } : {}), + ...(spec.includeDiagnostics ? { diagnostics: { + durationMs: Math.max(0, Math.round(performance.now() - startedAt)), + executor: { kind: 'subprocess', source }, + } } : {}), + }) + const run = async () => { + let value + try { + controller.signal.throwIfAborted() + const argv = spec.command === undefined ? [file, spec.scriptPath] : [file, '-c', spec.command] + // Race preparation so an unavailable provider cannot strand a cancelled job. + let preparedArgv = argv + if (prepareArgv) { + let abort + const cancelled = new Promise((_, reject) => { + abort = () => reject(controller.signal.reason) + controller.signal.addEventListener('abort', abort, { once: true }) + }) + try { preparedArgv = await Promise.race([prepareArgv(argv, controller.signal), cancelled]) } + finally { controller.signal.removeEventListener('abort', abort) } + } + controller.signal.throwIfAborted() + handle = subprocess.spawn({ + argv: preparedArgv, + cwd: spec.workdir, + stdio: { + stdin: 'ignore', + stdout: { maxBytes: DEFAULT_MAX_OUTPUT_BYTES, spill: { maxBytes: DEFAULT_MAX_SPILL_BYTES } }, + stderr: { maxBytes: DEFAULT_MAX_OUTPUT_BYTES, spill: { maxBytes: DEFAULT_MAX_SPILL_BYTES } }, + }, + graceMs: spec.graceMs, + signal: controller.signal, + env: shellEnvOverrides(globalThis.process.env, env), + }) + const outcome = await handle.done + value = { + ...(outcome?.exitCode != null ? { exitCode: outcome.exitCode } : {}), + ...(outcome?.signal != null ? { signal: outcome.signal } : {}), + ...flags(), + stdout: settledStream(handle.collected?.stdout, spec.includeDiagnostics), + stderr: settledStream(handle.collected?.stderr, spec.includeDiagnostics), + } + } catch (error) { + value = { + error: formatBashSpawnError(error, file, source, spec.workdir), + ...flags(), + stdout: settledStream(handle?.collected?.stdout, spec.includeDiagnostics), + stderr: settledStream(handle?.collected?.stderr, spec.includeDiagnostics), + } + } finally { + clearTimeout(timer) + callerSignal?.removeEventListener('abort', onCallerAbort) + } + const projected = projectResult ? await projectResult(value) : value + process.status = projected.error || projected.timedOut || projected.cancelled || projected.signal ? 'killed' : 'completed' + process.exitCode = projected.exitCode ?? null + process.signal = projected.signal ?? null + process.sandbox = projected.sandbox + return projected + } + const result = run() + process.done = result.then(() => undefined) + return process +} diff --git a/src/shells/tool.js b/src/shells/tool.js index eeba5c64..04def445 100644 --- a/src/shells/tool.js +++ b/src/shells/tool.js @@ -14,19 +14,15 @@ */ import fs from 'node:fs' -import { Buffer } from 'node:buffer' import { BASH_ARGUMENTS, BASH_TOOL_NAME, DEFAULT_BASH_TIMEOUT_MS, DEFAULT_GRACE_MS, - DEFAULT_MAX_OUTPUT_BYTES, - DEFAULT_MAX_SPILL_BYTES, MAX_BASH_TIMEOUT_MS, - shellEnvOverrides, } from './constants.js' import { BashArgumentError } from './argument-error.js' -import { formatBashSpawnError } from './spawn-error.js' +import { startBashExecution } from './execution.js' /** * Reject a value this tool cannot run with, naming the field. @@ -112,33 +108,6 @@ export function renderBashResult(value) { return [body, ...markers].join('\n') } -/** - * Read one settled collect-mode stream as its complete retained output. - * @param {{readFrom: (from: number) => {text: string, lossy: boolean, spillPath?: string}}|undefined} reader - the reader. - * @returns {{text: string, truncated: boolean, spillPath?: string}} the canonical stream value. - */ -function settledStream(reader, includeMetadata = false) { - if (reader === undefined || typeof reader.readFrom !== 'function') return { text: '', truncated: false } - const read = reader.readFrom(0) - const text = typeof read.text === 'string' ? read.text : '' - if (!includeMetadata) { - return { - text, - truncated: read.lossy === true, - ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {}, - } - } - const lines = text === '' ? 0 : text.split(/\n/).length - (text.endsWith('\n') ? 1 : 0) - return { - text, - truncated: read.lossy === true, - bytes: Buffer.byteLength(text), - bytesTotal: Number.isFinite(read.nextOffset) ? read.nextOffset : Buffer.byteLength(text), - lines, - ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {}, - } -} - /** * The canonical output contract of `ops_bash`, as plain JSON Schema. * @@ -247,8 +216,8 @@ export function bashToolDescription() { * @param {{spawn: (spec: object) => object}} options.subprocess - the harness subprocess service. * @returns {object} the definition `ctx.tools.register()` accepts. */ -export function bashToolDefinition({ file, source, subprocess, prepareArgv, env = {} }) { - return { +export function bashToolDefinition({ file, source, subprocess, prepareArgv, env = {}, projectResult = withBashStatus }) { + const definition = { name: BASH_TOOL_NAME, description: bashToolDescription(), parameters: { @@ -301,7 +270,7 @@ export function bashToolDefinition({ file, source, subprocess, prepareArgv, env * @param {{signal?: AbortSignal}} [exec] - the host execution context. * @returns {Promise} the canonical result value. */ - async execute(args, exec) { + start(args, exec = {}, { onExpiry } = {}) { assertKnownArguments(args, BASH_ARGUMENTS) // Every refusal in this body is a BashArgumentError, never a plain Error: the session layer // folds RUNTIME failures into a value (#168) and rethrows a caller's bad argument, so a wrong @@ -362,89 +331,11 @@ export function bashToolDefinition({ file, source, subprocess, prepareArgv, env } } - // No subprocess re-check here: `publishShellTools` publishes this tool - // only while `ctx.subprocess` is mounted, so a definition that exists at - // all always has the one service it needs. - - // One deadline plus the caller's cancellation, fused into the signal the - // subprocess service terminates its process range on. - const controller = new AbortController() - let timedOut = false - let cancelled = false - const startedAt = performance.now() - const timer = setTimeout(() => { - timedOut = true - controller.abort(new Error(`command timed out after ${timeoutMs}ms`)) - }, timeoutMs) - timer.unref?.() - const callerSignal = exec?.signal - const onCallerAbort = () => { - cancelled = true - controller.abort(callerSignal?.reason) - } - if (callerSignal !== undefined) { - if (callerSignal.aborted) onCallerAbort() - else callerSignal.addEventListener('abort', onCallerAbort, { once: true }) - } - - try { - let handle - try { - const argv = hasCommand ? [file, '-c', command] : [file, scriptPath] - if (controller.signal.aborted) throw controller.signal.reason ?? new Error('bash execution cancelled') - const preparedArgv = prepareArgv ? await prepareArgv(argv, controller.signal) : argv - if (controller.signal.aborted) throw controller.signal.reason ?? new Error('bash execution cancelled') - handle = subprocess.spawn({ - argv: preparedArgv, - cwd: workdir, - stdio: { - stdin: 'ignore', - stdout: { maxBytes: DEFAULT_MAX_OUTPUT_BYTES, spill: { maxBytes: DEFAULT_MAX_SPILL_BYTES } }, - stderr: { maxBytes: DEFAULT_MAX_OUTPUT_BYTES, spill: { maxBytes: DEFAULT_MAX_SPILL_BYTES } }, - }, - graceMs, - signal: controller.signal, - // The terminal overrides and nothing else: the subprocess service's - // own scrub decides what else a child may inherit, and this plugin - // never adds a credential-shaped name or a `DSH_*` fact to that set. - env: shellEnvOverrides(process.env, env), - }) - const outcome = await handle.done - // "Nothing to report" is an ABSENT key, not a null: the output schema - // declares single types only (see BASH_OUTPUT_SCHEMA). - return withBashStatus({ - ...outcome?.exitCode !== undefined && outcome?.exitCode !== null ? { exitCode: outcome.exitCode } : {}, - ...outcome?.signal !== undefined && outcome?.signal !== null ? { signal: outcome.signal } : {}, - timedOut, - timeoutMs, - ...(cancelled ? { cancelled: true } : {}), - stdout: settledStream(handle.collected?.stdout, includeDiagnostics), - stderr: settledStream(handle.collected?.stderr, includeDiagnostics), - ...(includeDiagnostics ? { diagnostics: { - durationMs: Math.max(0, Math.round(performance.now() - startedAt)), - executor: { kind: 'subprocess', source }, - } } : {}), - }) - } catch (error) { - return withBashStatus({ - // The workdir is part of the diagnosis: a vanished cwd and a vanished executable - // both surface as one ENOENT, and only one of them is the bash payload (#180). - error: formatBashSpawnError(error, file, source, workdir), - timedOut, - timeoutMs, - ...(cancelled ? { cancelled: true } : {}), - stdout: { text: '', truncated: false }, - stderr: { text: '', truncated: false }, - ...(includeDiagnostics ? { diagnostics: { - durationMs: Math.max(0, Math.round(performance.now() - startedAt)), - executor: { kind: 'subprocess', source }, - } } : {}), - }) - } - } finally { - clearTimeout(timer) - callerSignal?.removeEventListener('abort', onCallerAbort) - } + return startBashExecution({ file, source, subprocess, prepareArgv, env, projectResult }, { + command, scriptPath, workdir, timeoutMs, graceMs, includeDiagnostics, onExpiry, + }, exec) }, + async execute(args, exec) { return definition.start(args, exec).result() }, } + return definition } diff --git a/test/shells-execution.test.mjs b/test/shells-execution.test.mjs new file mode 100644 index 00000000..d519e483 --- /dev/null +++ b/test/shells-execution.test.mjs @@ -0,0 +1,86 @@ +import { bashToolDefinition } from '../lib/shells.js' +import { assert, report, test } from './lib/harness.mjs' + +const empty = { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } + +await test('one execution handle shares settlement and independent output observers', async () => { + const finished = Promise.withResolvers() + let spawned = 0 + const definition = bashToolDefinition({ file: process.execPath, source: 'config', subprocess: { + spawn() { + spawned++ + return { done: finished.promise, collected: { + stdout: { readFrom: offset => ({ text: 'hello'.slice(offset), nextOffset: 5, lossy: false }) }, stderr: empty, + } } + }, + } }) + const execution = definition.start({ command: 'echo hello' }) + assert.equal(execution.observed.stdout.readFrom(0).text, 'hello') + assert.equal(execution.observed.stdout.readFrom(2).text, 'llo') + assert.equal(execution.observed.stdout.readFrom(0).text, 'hello') + assert.equal(execution.result(), execution.result()) + finished.resolve({ exitCode: 0, signal: null }) + await execution.done + assert.equal(execution.status, 'completed') + assert.equal((await execution.result()).stdout.text, 'hello') + assert.equal(spawned, 1) +}) + +await test('cancellation settles pending preparation and a late provider never spawns', async () => { + const preparation = Promise.withResolvers() + let spawned = 0 + const definition = bashToolDefinition({ file: process.execPath, source: 'config', + prepareArgv: () => preparation.promise, subprocess: { spawn() { spawned++; throw new Error('unexpected spawn') } }, + }) + const execution = definition.start({ command: 'true' }, {}, { onExpiry: 'none' }) + execution.kill('job cancelled during preparation') + const value = await execution.result() + assert.equal(value.cancelled, true) + assert.equal(value.timedOut, false) + preparation.resolve([process.execPath, '-c', 'true']) + await Promise.resolve() + assert.equal(spawned, 0) + assert.equal(execution.status, 'killed') +}) + +await test('job-owned execution has no deadline and retains its cancellation signal', async () => { + const finished = Promise.withResolvers() + const aborted = Promise.withResolvers() + const definition = bashToolDefinition({ file: process.execPath, source: 'config', subprocess: { + spawn(spec) { + spec.signal.addEventListener('abort', () => { aborted.resolve(); finished.resolve({ exitCode: null, signal: 'SIGTERM' }) }) + return { done: finished.promise, collected: { stdout: empty, stderr: empty } } + }, + } }) + const execution = definition.start({ command: 'true', timeoutMs: 1 }, {}, { onExpiry: 'none' }) + await new Promise(resolve => setTimeout(resolve, 10)) + assert.equal(execution.status, 'running') + execution.kill('stop job') + await aborted.promise + const value = await execution.result() + assert.equal(value.timedOut, false) + assert.equal(value.cancelled, true) +}) + +await test('the first stop cause wins when timeout and caller cancellation race', async () => { + const timeout = Promise.withResolvers() + const finished = Promise.withResolvers() + const caller = new AbortController() + const definition = bashToolDefinition({ file: process.execPath, source: 'config', subprocess: { + spawn(spec) { + spec.signal.addEventListener('abort', () => timeout.resolve()) + return { done: finished.promise, collected: { stdout: empty, stderr: empty } } + }, + } }) + const execution = definition.start({ command: 'true', timeoutMs: 1 }, { signal: caller.signal }) + const keepAlive = setTimeout(() => {}, 1000) + await timeout.promise + caller.abort(new Error('later cancellation')) + finished.resolve({ exitCode: null, signal: 'SIGTERM' }) + const value = await execution.result() + clearTimeout(keepAlive) + assert.equal(value.timedOut, true) + assert.equal(value.cancelled, undefined) +}) + +report('shells-execution') From c6770c523ba11dca8180ad2599f13d755f9e78e6 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:25:17 +0800 Subject: [PATCH 037/118] feat(shell): reuse host approvals and background jobs --- NOTICE | 6 + docs/CHANGELOG.md | 9 + docs/PROVENANCE.md | 13 ++ docs/component-reference.md | 6 + docs/native-ptc.md | 4 +- docs/troubleshooting.md | 10 +- docs/usage.md | 6 +- ...77\347\224\250\346\214\207\345\215\227.md" | 6 +- ...70\350\247\201\351\227\256\351\242\230.md" | 10 +- ...04\344\273\266\345\217\202\350\200\203.md" | 6 + licenses/deepseek-harness/LICENSE | 21 ++ src/bash-ipc.js | 2 +- src/policy.ts | 2 +- src/session-shells.js | 84 +------ src/shells/approval.js | 38 +++ src/shells/execution.js | 3 +- src/shells/host-contract.js | 43 ++++ src/shells/host-tool.js | 64 +++++ src/shells/jobs.js | 63 +++++ src/shells/session-execution.js | 56 +++++ src/shells/tool.js | 12 +- test/authority.test.mjs | 4 +- test/bash-ipc.test.mjs | 2 +- test/linux-shell.test.mjs | 6 +- test/permissions.test.mjs | 21 +- test/policy.test.mjs | 4 +- test/shells-host.test.mjs | 221 ++++++++++++++++++ test/shells-session.test.mjs | 27 ++- 28 files changed, 630 insertions(+), 119 deletions(-) create mode 100644 licenses/deepseek-harness/LICENSE create mode 100644 src/shells/approval.js create mode 100644 src/shells/host-contract.js create mode 100644 src/shells/host-tool.js create mode 100644 src/shells/jobs.js create mode 100644 src/shells/session-execution.js create mode 100644 test/shells-host.test.mjs diff --git a/NOTICE b/NOTICE index 15ed7492..856ced5c 100644 --- a/NOTICE +++ b/NOTICE @@ -94,3 +94,9 @@ tarballs of earlier versions. FastCtx is developed and maintained at https://github.com/yc-duan/fastctx + + +DeepSeek Harness shell contract, approval and job adapters are adapted from +@deepseek-ai/dsh-tool-bash 0.2.0-rc.2 (MIT), Copyright (c) 2026 DeepSeek. +Upstream: https://github.com/deepseek-ai/deepseek-harness +License: licenses/deepseek-harness/LICENSE diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7972f122..cf9ac3cc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,15 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Reuse host Bash approvals and background jobs (#261) + +- Require command descriptions and return host foreground/background/promoted result kinds, with + 120-second defaults and 600-second caps. Reuse host jobs for background work and foreground + timeout promotion, host per-call approvals for wider access, and host subprocess governance. +- Recheck session authority after approval and confinement; retain absolute executable selection, + verified Windows IPC compatibility, honest partial enforcement and fail-closed runtime errors. +- Keep script_path, graceMs and includeDiagnostics as documented OPS compatibility extensions. + ### Preserve Bash cancellation through pending confinement preparation (#261) - Bash execution exposes one live handle for observation and settlement. Cancellation settles diff --git a/docs/PROVENANCE.md b/docs/PROVENANCE.md index da090c6a..93b645eb 100644 --- a/docs/PROVENANCE.md +++ b/docs/PROVENANCE.md @@ -3,6 +3,19 @@ What this repository is, where its parts came from, what was verified, and what was not. +## Unreleased: host Bash contract + +The shell contract, approval and job adapters in `src/shells/` adapt +`@deepseek-ai/dsh-tool-bash` 0.2.0-rc.2 (MIT, Copyright (c) 2026 DeepSeek). +The executor was checked against `@deepseek-ai/dsh-bash-local` at the same version. +OPS selects an absolute executable, preserves its argument and Windows IPC validation, +and borrows host services without replacing them. The upstream MIT license is retained +in `licenses/deepseek-harness/LICENSE`; the package adds no host value imports. + +Shell 契约、审批与 jobs 适配改写自同版本宿主工具(MIT,2026 DeepSeek), +执行器参照同版本 bash-local 核验。OPS 保留绝对执行路径、参数及 Windows IPC 校验, +只借用宿主服务,不替换服务,也不增加宿主值导入。上游许可证保留于上述路径。 + ## Unreleased: FastCtx removed The FastCtx-backed `dsh-ops/background` component was removed together with the diff --git a/docs/component-reference.md b/docs/component-reference.md index 5348f4ee..dd502c1f 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -157,6 +157,12 @@ Routing: **toolkit → bash → PowerShell 7**. On Windows, `\child` uses the session's drive; a drive-relative path such as `C:child` on a different drive must be written as an absolute path. - Host `pwsh`: Windows-native operations, using bundled PowerShell 7. +`ops_bash` requires a non-empty `description` and keeps the host Bash fields and result kinds: `foreground`, `background`, or `promoted`. Its default timeout is 120 seconds and the cap is 600 seconds. When the host jobs service is available, `run_in_background: true` returns a job ID immediately; a foreground timeout promotes the same running job. Use the host `job_output` and `job_kill` tools to collect or stop it. Without host jobs, the background parameter is absent and a foreground timeout stops the command. Unknown arguments are rejected before execution. + +`sandbox_permissions` plus `justification` can request a strictly wider mode through the host approval service. An allowed-once grant applies to that command only; rejection never executes it or changes the session mode. Authority is checked again after approval and sandbox preparation. Windows confinement reports the host provider’s actual enforcement (including `partial`); incompatible Bash runtimes fail closed. + +`script_path` (instead of `command`), `graceMs`, and `includeDiagnostics` remain explicit OPS compatibility extensions. They do not change the host command contract or its timeout limits. + "Prefer bash as the terminal" means prompting the model to use `ops_bash` first; it **does not change the system default terminal, PATH or existing host shell tool names**. When the shell component is on, the official configuration lifecycle temporarily sets the PowerShell 7 path for `pwsh-sandbox`; when off, it restores the executor's latest original configuration without writing profile configuration. Switching executor configuration does not cancel already-running host commands. The routing table the model sees is rendered by the plugin from what it actually published: diff --git a/docs/native-ptc.md b/docs/native-ptc.md index 58852009..83eae91e 100644 --- a/docs/native-ptc.md +++ b/docs/native-ptc.md @@ -133,9 +133,7 @@ is refused before it can become a candidate, so no `ops_glob` item is a link; `ops_stat` reports the opened file's time for a path reached through one. The native schemas deliberately do not expose `root`, `workspace`, `cwd`, or -`file_path` aliases. `ops_bash` accepts the host-compatible optional -`description` field but remains foreground-only; it does not accept -`run_in_background` or create job IDs. +`file_path` aliases. `ops_bash` requires `description`, uses the host foreground/background/promoted result contract, and exposes `run_in_background` when host jobs are available. See the bilingual shell contract in [component-reference.md](component-reference.md) / [组件参考.md](组件参考.md). Their important fields are (paths resolve relative to the DSH session cwd): diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 289d7f5a..c74c3ef9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -54,9 +54,9 @@ When the bundled bash payload remains declared in the profile lockfile but its p Long scripts, heredocs and multi-layer quotes and backslashes make the model escape JSON repeatedly. Write the script with the host file tools first and pass it through `script_path` instead of putting it in `command`. See the [usage guide](usage.md). -### Passing `run_in_background` to `ops_bash` is rejected +### Background Bash requires host jobs -**`ops_bash` has no `run_in_background` and returns no job id**; every command runs in the foreground. The host `bash` and `pwsh` tools accept the parameter, which is why a model tries it here. For a long-running process, use the host `pwsh` tool's `run_in_background`, or detach inside the command (for example `nohup ... & disown`). Otherwise the call occupies the whole `timeoutMs` (up to about 24.9 days). +`run_in_background` is available only while the host jobs service is mounted. It returns a host job ID; use `job_output` / `job_kill`. Without that service, remove the background argument and run a bounded foreground command. The default timeout is 120 seconds, capped at 600 seconds. With jobs, foreground expiry promotes the same job; otherwise it stops the command. ### An argument is rejected as unknown @@ -74,9 +74,9 @@ What to do: - Or switch the session to `danger-full-access`, where `bashPath` runs directly. - Check with the profile's own copy: `node "\node_modules\dsh-ops\bin\dsh-ops.mjs" ladder --config ` reports `restricted: yes/no` for the resolved bash. -### `ops_bash` rejects `sandbox_permissions` / `justification` +### Bash approval is rejected or unavailable -The host `bash` tool accepts per-call escalation; `ops_bash` does not, and says so in the error. Ask the user to change the session permission mode instead of retrying the call. +`ops_bash` uses host approval for a strictly wider `sandbox_permissions` mode. Supply a non-empty `justification` for that exact command. A rejection or missing approval service prevents execution; do not retry through another shell. Approval does not repair an incompatible restricted Bash runtime. ### Empty placeholder arguments on file tools Strict tool-call adapters that fill every schema field send `""`, `null` or `[]` for unused optional fields of `ops_inspect_local_file`, `ops_grep`, `ops_glob` and `ops_replace`. The plugin drops those placeholders for optional fields before the native worker runs, so they no longer trip `path`/`encoding`/`view` validation. Required fields, `replacement: ""`, `false` and `0` are forwarded unchanged, so genuinely invalid values still error. @@ -127,7 +127,7 @@ lets a later writer believe it is alone. ### Where did the background tools go? -The `dsh-ops/background` component and its FastCtx-backed tools (`ops_run`, `ops_run_background`, `ops_job_output`, `ops_job_list`, `ops_job_kill`) were removed. A profile row that still names `dsh-ops/background` loads as a no-op and logs `the dsh-ops/background component (FastCtx background jobs) was removed in this version; it publishes no tools`; delete the row to silence it. For a long-running process, use the host `pwsh` tool's `run_in_background`, or detach inside an `ops_bash` command (for example `nohup ... & disown`). +The `dsh-ops/background` component and its FastCtx-backed tools (`ops_run`, `ops_run_background`, `ops_job_output`, `ops_job_list`, `ops_job_kill`) were removed. A profile row that still names `dsh-ops/background` loads as a no-op and logs `the dsh-ops/background component (FastCtx background jobs) was removed in this version; it publishes no tools`; delete the row to silence it. For a long-running process, use `ops_bash` with `run_in_background` when host jobs are available, then collect it with the host `job_output` tool. ### `DSH workspace preparation failed` diff --git a/docs/usage.md b/docs/usage.md index e556a299..02e94293 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -127,7 +127,11 @@ Use `ops_bash`'s `command` for short commands and pipelines. For scripts longer `script_path` accepts only the script path, so the script body is not repeated in the `ops_bash` arguments. `command` and `script_path` are mutually exclusive and exactly one is required. This interface cannot repair JSON that the model or host failed to parse; an unparseable tool call still requires a host/model retry. -`ops_bash` arguments are **strict**: a key the schema does not declare is refused before execution, and the error names both the unknown keys and the accepted ones instead of ignoring them. **This tool has no `run_in_background` and returns no job id**; every command runs in the foreground. The host `bash` and `pwsh` tools do accept `run_in_background`, so a model may reasonably try it here; such a call is rejected rather than silently run in the foreground. For a server, listener or watcher, use the host `pwsh` tool's `run_in_background`, or detach the process from inside the command (for example `nohup ... & disown`). Otherwise the call occupies the whole `timeoutMs` (up to `MAX_BASH_TIMEOUT_MS`, about 24.9 days). +`ops_bash` requires a non-empty `description` and keeps the host Bash fields and result kinds: `foreground`, `background`, or `promoted`. Its default timeout is 120 seconds and the cap is 600 seconds. When the host jobs service is available, `run_in_background: true` returns a job ID immediately; a foreground timeout promotes the same running job. Use the host `job_output` and `job_kill` tools to collect or stop it. Without host jobs, the background parameter is absent and a foreground timeout stops the command. Unknown arguments are rejected before execution. + +`sandbox_permissions` plus `justification` can request a strictly wider mode through the host approval service. An allowed-once grant applies to that command only; rejection never executes it or changes the session mode. Authority is checked again after approval and sandbox preparation. Windows confinement reports the host provider’s actual enforcement (including `partial`); incompatible Bash runtimes fail closed. + +`script_path` (instead of `command`), `graceMs`, and `includeDiagnostics` remain explicit OPS compatibility extensions. They do not change the host command contract or its timeout limits. --- diff --git "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" index 3fe51e4d..1a65e3fa 100644 --- "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -125,7 +125,11 @@ Without `--profile`, uninstall only cleans legacy provisioned runtime/shell file `script_path` 只接收脚本路径,脚本内容不会重复进入 `ops_bash` 的工具参数;`command` 与 `script_path` 必须二选一。该接口不会替模型修复非法 JSON,工具调用本身若未能解析,仍需由宿主或模型重试。 -`ops_bash` 的参数是**严格**的:传入 schema 未声明的键会在执行前被拒绝,错误信息同时点出未知键与可接受的键,不会静默忽略。**本工具没有 `run_in_background`,也没有 job id**,命令一律前台执行。宿主 `bash` / `pwsh` 工具接受 `run_in_background`,因此模型容易把该参数套到 `ops_bash` 上;这样调用会被明确拒绝而不是静默当前台执行。服务、监听器、watch 等常驻进程请改用宿主 `pwsh` 工具的 `run_in_background`,或在命令内部自行脱离(例如 `nohup ... & disown`)。否则该次调用会一直占用到 `timeoutMs`(上限 `MAX_BASH_TIMEOUT_MS`,约 24.9 天)。 +`ops_bash` 必须提供非空 `description`,输入字段和结果类型与宿主 Bash 保持一致:`foreground`、`background` 或 `promoted`。默认超时 120 秒,上限 600 秒。宿主 jobs 服务可用时,`run_in_background: true` 立即返回 job ID;前台等待超时会把同一个运行中任务交给宿主管理。用宿主 `job_output` 收集输出、`job_kill` 停止任务。没有 jobs 服务时不声明后台参数,前台超时会停止命令。未知参数在执行前被拒绝。 + +`sandbox_permissions` 和 `justification` 可通过宿主审批服务申请更宽的权限。一次批准只作用于当前命令;拒绝后不执行,也不修改会话模式。审批与沙箱准备后都会重新检查会话权限。Windows 如实报告宿主的实际约束级别(包括 `partial`);不兼容的 Bash 运行时直接拒绝执行。 + +`script_path`(替代 `command`)、`graceMs` 和 `includeDiagnostics` 是保留的 OPS 兼容扩展,不改变宿主命令契约或超时上限。 --- diff --git "a/docs/\345\270\270\350\247\201\351\227\256\351\242\230.md" "b/docs/\345\270\270\350\247\201\351\227\256\351\242\230.md" index b4d952ac..bdab8095 100644 --- "a/docs/\345\270\270\350\247\201\351\227\256\351\242\230.md" +++ "b/docs/\345\270\270\350\247\201\351\227\256\351\242\230.md" @@ -54,9 +54,9 @@ provisioned 副本在 `/dsh-ops/shells`(跨 profile 共享,provisi 长脚本、heredoc、多层引号与反斜杠会让模型在工具调用 JSON 里做多层转义。请先用宿主的文件工具把脚本写入文件,再传给 `script_path` 执行,不要整段塞进 `command`。详见 [使用指南](使用指南.md)。 -### 把 `run_in_background` 传给 `ops_bash` 被拒绝 +### Bash 后台执行需要宿主 jobs 服务 -**`ops_bash` 没有 `run_in_background`,也没有 job id**,命令一律前台执行。宿主 `bash` / `pwsh` 工具接受该参数,所以模型容易套错。常驻进程请改用宿主 `pwsh` 工具的 `run_in_background`,或在命令内部自行脱离(如 `nohup ... & disown`)。否则该次调用会一直占用到 `timeoutMs`(上限约 24.9 天)。 +只有宿主 jobs 服务已挂载时才提供 `run_in_background`;它返回宿主 job ID,通过 `job_output` / `job_kill` 收集或停止。没有该服务时,移除后台参数并使用有界前台命令。默认超时 120 秒,上限 600 秒;有 jobs 时超时会交接同一个任务,没有时停止命令。 ### 参数被判为未知键 @@ -74,9 +74,9 @@ provisioned 副本在 `/dsh-ops/shells`(跨 profile 共享,provisi - 或者把会话切换到 `danger-full-access`,此时 `bashPath` 会直接运行。 - 用 profile 自己的副本检查:`node "\node_modules\dsh-ops\bin\dsh-ops.mjs" ladder --config <文件>`,报告中的 `restricted: yes/no` 表示受限模式能否运行所选 bash。 -### `ops_bash` 拒绝 `sandbox_permissions` / `justification` +### Bash 审批被拒绝或不可用 -宿主 `bash` 工具支持单次调用升级;`ops_bash` 不支持,错误信息里会写明这一点。请让用户切换会话权限模式,而不是重试该调用。 +`ops_bash` 通过宿主审批申请更宽的 `sandbox_permissions`,必须为当前命令提供非空 `justification`。拒绝或缺少审批服务时不执行;不要换 shell 重试。审批无法修复不兼容的受限 Bash 运行时。 ### 文件工具收到空占位参数 严格的工具调用适配器会把 schema 里每个字段都填上,于是 `ops_inspect_local_file`、`ops_grep`、`ops_glob`、`ops_replace` 未使用的可选字段会收到 `""`、`null` 或 `[]` 占位值。插件会在原生 worker 运行前丢弃这些可选字段的占位值,不再触发 `path`/`encoding`/`view` 校验报错。必填字段、`replacement: ""`、`false` 和 `0` 仍原样转发,真正非法的值依旧会报错。 @@ -120,7 +120,7 @@ provisioned 副本在 `/dsh-ops/shells`(跨 profile 共享,provisi ### 后台工具去哪了 -`dsh-ops/background` 组件及其基于 FastCtx 的工具(`ops_run`、`ops_run_background`、`ops_job_output`、`ops_job_list`、`ops_job_kill`)已移除。仍写着 `dsh-ops/background` 的 profile 行会作为空操作加载,并记录 `the dsh-ops/background component (FastCtx background jobs) was removed in this version; it publishes no tools`;删除该行即可消除提示。常驻进程请改用宿主 `pwsh` 工具的 `run_in_background`,或在 `ops_bash` 命令内部自行脱离(如 `nohup ... & disown`)。 +`dsh-ops/background` 组件及其基于 FastCtx 的工具(`ops_run`、`ops_run_background`、`ops_job_output`、`ops_job_list`、`ops_job_kill`)已移除。仍写着 `dsh-ops/background` 的 profile 行会作为空操作加载,并记录 `the dsh-ops/background component (FastCtx background jobs) was removed in this version; it publishes no tools`;删除该行即可消除提示。长时间运行的进程可在宿主 jobs 可用时使用 `ops_bash` 的 `run_in_background`,再通过宿主 `job_output` 收集结果。 ### `DSH workspace preparation failed` diff --git "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" index 55225e4e..47c023eb 100644 --- "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" +++ "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" @@ -157,6 +157,12 @@ Windows 上的 `\child` 使用会话所在盘符;`C:child` 这类位于另一盘符的相对路径需改写为绝对路径。 - 宿主 `pwsh`:Windows 原生操作,使用随包 PowerShell 7。 +`ops_bash` 必须提供非空 `description`,输入字段和结果类型与宿主 Bash 保持一致:`foreground`、`background` 或 `promoted`。默认超时 120 秒,上限 600 秒。宿主 jobs 服务可用时,`run_in_background: true` 立即返回 job ID;前台等待超时会把同一个运行中任务交给宿主管理。用宿主 `job_output` 收集输出、`job_kill` 停止任务。没有 jobs 服务时不声明后台参数,前台超时会停止命令。未知参数在执行前被拒绝。 + +`sandbox_permissions` 和 `justification` 可通过宿主审批服务申请更宽的权限。一次批准只作用于当前命令;拒绝后不执行,也不修改会话模式。审批与沙箱准备后都会重新检查会话权限。Windows 如实报告宿主的实际约束级别(包括 `partial`);不兼容的 Bash 运行时直接拒绝执行。 + +`script_path`(替代 `command`)、`graceMs` 和 `includeDiagnostics` 是保留的 OPS 兼容扩展,不改变宿主命令契约或超时上限。 + “默认终端改为 bash”指模型提示优先使用 `ops_bash`,**不修改系统默认终端、PATH 或宿主原有 shell 工具名称**。Shell 组件开时,通过官方配置生命周期临时指定 `pwsh-sandbox` 的 PowerShell 7 路径;关时按执行器最新原配置恢复,不写 profile 配置。执行器配置切换不代表取消已在运行的宿主命令。 模型侧看到的路由表由插件按其实际发布情况渲染: diff --git a/licenses/deepseek-harness/LICENSE b/licenses/deepseek-harness/LICENSE new file mode 100644 index 00000000..c1f7a78e --- /dev/null +++ b/licenses/deepseek-harness/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DeepSeek + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/bash-ipc.js b/src/bash-ipc.js index 8988012f..a25d058b 100644 --- a/src/bash-ipc.js +++ b/src/bash-ipc.js @@ -25,7 +25,7 @@ export function restrictedRemedy(file, source, why) { +'By design, restricted modes run only the pinned MSYS whose DSH IPC compatibility proxies were verified; this is not a user authorization denial. ' +'A custom bashPath or system Git for Windows works only in danger-full-access. ' +'Next steps: remove bashPath (and allowSystemShellFallback) so the bundled or provisioned pinned bash is used (`dsh-ops provision-shells --bash` if it is missing), ' - +'or ask the user to switch the session to danger-full-access. ops_bash has no per-call sandbox_permissions escalation; do not bypass confinement.' + +'or ask the user to switch the session to danger-full-access. Approvals never repair an incompatible confined runtime; do not bypass confinement.' } /** Whether restricted modes can run this bash, without copying or building anything. */ export function restrictedCompatibility(file, {platform=process.platform}={}) { diff --git a/src/policy.ts b/src/policy.ts index fec5b1c1..ce2b53ec 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -83,7 +83,7 @@ export function renderToolingPolicy({ published, mounted, levels, pwsh7 = false, if (hasFiles) lines.push('- Follow error.nextAction; authorization denials require the user to change mode, not retries. After applied/unknown effects inspect postconditions; do not switch to a shell workaround.') if (names.has(BASH_TOOL)) lines.push('- Prefer bash for command execution. Use PowerShell only when Windows-native functionality is necessary; fix bash errors in bash, do not switch shells or mix their syntax.', '- Use command only for short commands and pipelines. For scripts longer than about 1 KB or containing heredocs, quotes, backslashes or non-ASCII text, write the script to a file first and pass script_path.', - '- ops_bash resolves with `status` (completed/failed), `exitCode`, stdout/stderr, `error` and `permission`; it throws only for invalid arguments or an unavailable executor.') + '- ops_bash requires description and returns kind foreground, background or promoted. Foreground has status (completed/failed), exitCode, stdout/stderr and error; background/promoted has jobId for host job_output/job_kill. Invalid arguments throw; runtime failures are values.') if (extraGuidance.trim()) lines.push('', extraGuidance.trim()) return lines.join('\n') } diff --git a/src/session-shells.js b/src/session-shells.js index 07118433..70b72f96 100644 --- a/src/session-shells.js +++ b/src/session-shells.js @@ -1,11 +1,11 @@ /** Plugin-owned bash layers; permission changes never mutate the shared registry. */ import path from 'node:path' import { BASH_TOOL } from './policy.js' -import { bashToolDefinition, withBashStatus, DEFAULT_BASH_TIMEOUT_MS } from './shells.js' -import { BashArgumentError, isBashArgumentError } from './shells/argument-error.js' -import { MODES, authorityChanged, executionRestriction } from './permissions.js' -import { prepareWorkspace } from './workspace-preparation.js' -import { confinedBash } from './bash-ipc.js' +import { bashToolDefinition, DEFAULT_BASH_TIMEOUT_MS } from './shells.js' +import { BashArgumentError } from './shells/argument-error.js' +import { MODES } from './permissions.js' +import { hostBashDefinition } from './shells/host-tool.js' +import { startSessionBash } from './shells/session-execution.js' /** * Whether a session workspace is written in a Windows form. @@ -146,75 +146,10 @@ export function mountSessionBash(ctx, config, shells) { // Publish authorized ancestors before masking inheritance in restricted children. for (const [agent, entry] of entries) { if (!entry.ctx || !allowed(agent)) continue - const definition = bashToolDefinition({ file: bash.file, source: bash.source, subprocess: ctx.get('subprocess') }) - const run = async (args, exec) => { - const session = exec?.agent?.session - // Declared before the helper so every refusal below can state the authoritative mode it was - // made under: a caller that only sees `failed` cannot tell a read-only refusal from a - // missing runtime, and the mode is what makes the difference actionable. - let policy - const unavailable = message => withBashStatus({ error: `EXECUTOR_UNAVAILABLE: ${message}`, - ...(policy ? { sandbox: { mode: policy.mode } } : {}), - timedOut: false, timeoutMs: DEFAULT_BASH_TIMEOUT_MS, - stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false } }) - if (!session?.id || !ready || stopped) return unavailable('Bash requires an authoritative DSH session and executor.') - try { policy = ctx.get('sandboxPolicy')?.resolve({ session }) } - catch { return unavailable('DSH policy resolution is unavailable.') } - if (!MODES.includes(policy?.mode)) return unavailable('Invalid authoritative DSH permission mode.') - let confinement - let runtime - try { runtime = policy.mode === 'danger-full-access' ? { file: bash.file } : confinedBash(bash.file, { source: bash.source }) } - catch { return unavailable('The restricted Bash runtime is unavailable or incompatible. Restore the verified pinned Bash payload and IPC runtime; changing the DSH selector is not a repair. Do not retry or bypass confinement.') } - const definitionForCall = bashToolDefinition({ file: runtime.file, source: bash.source, env: runtime.env, - prepareArgv: async (argv, signal) => { - await prepareWorkspace(policy) - if (policy.mode !== 'danger-full-access') { - const sandbox = ctx.get('sandbox') - if (!sandbox?.confine) throw new Error('SANDBOX_UNAVAILABLE: DSH confinement provider is unavailable; no command executed.') - confinement = await sandbox.confine(argv, policy, signal) - if (!['full', 'partial'].includes(confinement?.enforcement) || !confinement.argv?.length) throw new Error('SANDBOX_UNAVAILABLE: invalid DSH confinement result.') - } - const current = ctx.get('sandboxPolicy').resolve({ session }) - if (current.mode !== policy.mode || current.workspaceRoot !== policy.workspaceRoot) { - const changed = authorityChanged(current, 'bash') - throw new Error(`${changed.error.code}: ${changed.error.message} ${changed.error.nextAction}`) - } - return confinement?.argv ?? argv - }, subprocess: ctx.get('subprocess'), - }) - let value - try { - value = await definitionForCall.execute({ ...args, workdir: sessionWorkdir(args, session) }, exec) - } catch (error) { - // The bash contract states RUNTIME failures as values, never as thrown errors (#168): a - // refusing runtime that throws must still come back with the mode it refused under, or the - // caller cannot tell a read-only refusal from a runtime that is simply absent. A CALLER's - // bad argument is the one thing that must not be folded away: a bare `{ error }` fails - // BASH_OUTPUT_SCHEMA, so the host reported "returned invalid output" and the wrong field - // never reached the caller (#235). Refusals are rethrown; everything else settles on a - // value complete enough for the registered schema. - if (isBashArgumentError(error)) throw error - value = bashRefusalValue(error) - } - const text = `${value.stderr?.text ?? ''}\n${value.error ?? ''}`.toLowerCase() - const runnerFailed = value.exitCode !== 0 && confinement?.runnerFailureRules?.some(rule => - (!rule.allowedExitCodes || rule.allowedExitCodes.includes(value.exitCode)) && - rule.fatalSignatures?.some(s => text.includes(s.toLowerCase()))) - if (runnerFailed || /couldn't create signal pipe|fatal error.*win32 error|sandbox_unavailable/.test(text)) { - return { ...value, error: 'EXECUTOR_UNAVAILABLE: The bash runtime could not initialize under the DSH sandbox; the command did not run. This is not a user authorization denial. Restore the host runtime or confinement provider. Do not retry or bypass confinement; changing the DSH selector is not a repair.', - sandbox: { mode: policy.mode, ...(confinement ? { enforcement: confinement.enforcement } : {}) } } - } - const refused = value.exitCode !== 0 && confinement?.denialSignatures?.some(s => text.includes(s.toLowerCase())) - if (refused) { - // A settled bash result states the authoritative mode it ran under, and a refusal is - // exactly when the caller needs that fact — the status field alone cannot carry it. - return { ...value, restriction: executionRestriction('command file effects'), - sandbox: { mode: policy.mode, ...(confinement ? { enforcement: confinement.enforcement } : {}) } } - } - return { ...value, sandbox: { mode: policy.mode, ...(confinement ? { enforcement: confinement.enforcement } : {}) } } - } - // Stamp status after every wrapper above, so an added error/permission always reads as failed (#44). - definition.execute = async (args, exec) => withBashStatus(await run(args, exec)) + const base = bashToolDefinition({ file: bash.file, source: bash.source, subprocess: ctx.get('subprocess') }) + const definition = hostBashDefinition(ctx, base, + (args, exec, standing, policy, dshEnv, options) => startSessionBash({ ctx, bash, active: () => ready && !stopped && entries.has(exec.agent) }, + args, exec, standing, policy, dshEnv, options), sessionWorkdir) publish(entry, definition) } for (const [agent, entry] of entries) { @@ -253,6 +188,7 @@ export function mountSessionBash(ctx, config, shells) { ctx.on('agent/disposed', ({ agent }) => detach(agent)) ctx.on('session/event', (_session, event) => { if (event.type === 'sandbox/mode') reconcile() }) ctx.inject(['sandbox'], () => { reconcile() }) + ctx.inject(['jobs'], jobsCtx => { reconcile(); jobsCtx.effect(() => () => { reconcile() }) }) ctx.inject(['sandboxPolicy', 'subprocess'], authorityCtx => { ready = true reconcile() diff --git a/src/shells/approval.js b/src/shells/approval.js new file mode 100644 index 00000000..4c15084f --- /dev/null +++ b/src/shells/approval.js @@ -0,0 +1,38 @@ +// Adapted from @deepseek-ai/dsh-tool-bash 0.2.0-rc.2 (MIT). See licenses/deepseek-harness/LICENSE. +/** Per-call widening follows the pinned host approval vocabulary; grants are never cached. */ +import { BashArgumentError } from './argument-error.js' +import { BASH_TOOL_NAME } from './constants.js' + +const modes = ['read-only', 'workspace-write', 'danger-full-access'] + +export function validateBashEscalation(args, standing) { + const target = args.sandbox_permissions + const reason = args.justification + if (target !== undefined && !modes.slice(1).includes(target)) { + throw new BashArgumentError('invalid sandbox_permissions: expected workspace-write or danger-full-access', 'sandbox_permissions') + } + if (reason !== undefined && typeof reason !== 'string') throw new BashArgumentError('invalid justification: expected a string', 'justification') + if (target === undefined && reason?.trim()) throw new BashArgumentError('invalid justification: requires sandbox_permissions', 'justification') + if (target !== undefined && target !== standing.mode && !reason?.trim()) { + throw new BashArgumentError('invalid justification: a different sandbox_permissions mode requires a non-empty reason', 'justification') + } + if (target !== undefined && modes.indexOf(target) < modes.indexOf(standing.mode)) { + throw new BashArgumentError('invalid sandbox_permissions: the requested mode is narrower than the current mode', 'sandbox_permissions') + } +} + +export async function approveBashPolicy(ctx, args, exec, standing) { + validateBashEscalation(args, standing) + if (args.sandbox_permissions === undefined || args.sandbox_permissions === standing.mode) return standing + const approval = ctx.get('approval') + if (!exec?.agent || !approval?.request) throw new Error('APPROVAL_UNAVAILABLE: no command executed; approval is unavailable. Do not bypass the session policy.') + const outcome = await approval.request({ agent: exec.agent, callId: exec.callId, toolName: BASH_TOOL_NAME, + reason: args.justification, signal: exec.signal }) + if (outcome !== 'allowed-once') throw new Error(`APPROVAL_${String(outcome).toUpperCase().replaceAll('-', '_')}: no command executed; the requested wider mode was not granted. Do not retry or bypass this decision.`) + exec.signal?.throwIfAborted() + const current = ctx.get('sandboxPolicy')?.resolve({ session: exec.agent.session }) + if (current?.mode !== standing.mode || current?.workspaceRoot !== standing.workspaceRoot) { + throw new Error('AUTHORITY_CHANGED: session policy changed while approval was pending; no command executed.') + } + return { ...standing, mode: args.sandbox_permissions } +} diff --git a/src/shells/execution.js b/src/shells/execution.js index bfae0121..bcdd998e 100644 --- a/src/shells/execution.js +++ b/src/shells/execution.js @@ -45,9 +45,10 @@ export function startBashExecution({ file, source, subprocess, prepareArgv, env timer = setTimeout(() => stop('timeout', new Error(`command timed out after ${spec.timeoutMs}ms`)), spec.timeoutMs) timer.unref?.() } - const reader = stream => ({ readFrom: offset => handle?.collected?.[stream]?.readFrom(offset) ?? emptyRead() }) + const reader = stream => ({ readFrom: offset => handle?.collected?.[stream]?.readFrom(offset) ?? { ...emptyRead(), nextOffset: offset } }) const process = { status: 'running', + get started() { return handle !== undefined }, observed: { stdout: reader('stdout'), stderr: reader('stderr') }, kill: reason => stop('cancelled', new Error(reason ?? 'bash execution cancelled')), result: () => result, diff --git a/src/shells/host-contract.js b/src/shells/host-contract.js new file mode 100644 index 00000000..af1d09de --- /dev/null +++ b/src/shells/host-contract.js @@ -0,0 +1,43 @@ +// Adapted from @deepseek-ai/dsh-tool-bash 0.2.0-rc.2 (MIT). See licenses/deepseek-harness/LICENSE. +/** DSH 0.2.0-rc.2 shell DTO and rendering, with explicit OPS compatibility fields. */ +import { BASH_OUTPUT_SCHEMA, renderBashResult, withBashStatus } from './tool.js' + +const nullable = type => ({ oneOf: [{ type }, { type: 'null' }] }) +export const HOST_BASH_OUTPUT_SCHEMA = { + oneOf: [ + { type: 'object', additionalProperties: false, required: ['kind', 'jobId'], properties: { + kind: { type: 'string', const: 'background' }, jobId: { type: 'string' }, + } }, + { type: 'object', additionalProperties: false, required: ['kind', 'jobId', 'timeoutMs', 'output'], properties: { + kind: { type: 'string', const: 'promoted' }, jobId: { type: 'string' }, timeoutMs: { type: 'number' }, output: { type: 'string' }, + } }, + { ...BASH_OUTPUT_SCHEMA, required: [...BASH_OUTPUT_SCHEMA.required, 'kind', 'exitCode', 'signal', 'aborted'], properties: { + ...BASH_OUTPUT_SCHEMA.properties, + kind: { type: 'string', const: 'foreground' }, exitCode: nullable('integer'), signal: nullable('string'), + aborted: { type: 'boolean' }, stopped: { type: 'string' }, + } }, + ], +} + +export function hostForeground(value) { + return { ...withBashStatus(value), kind: 'foreground', exitCode: value.exitCode ?? null, + signal: value.signal ?? null, aborted: value.cancelled === true } +} + +export function hostFailure(message, timeoutMs = 120_000) { + return hostForeground({ error: message, timedOut: false, timeoutMs, + stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false } }) +} + +export function renderHostBash(value) { + if (value.kind === 'background') return `started background job ${value.jobId}` + if (value.kind === 'promoted') return `${value.output || '(no output)'}\n[still running after ${value.timeoutMs}ms; moved to background job ${value.jobId}]\nUse job_output to collect output and job_kill to stop the command.` + const { exitCode, signal, ...rest } = value + const body = renderBashResult(rest) + const markers = [] + if (value.sandbox?.denied) markers.push(`[sandbox: file access denied under ${value.sandbox.mode} mode]`) + if (value.stopped) markers.push(`[stopped: ${value.stopped}]`) + if (signal !== null) markers.push(`[killed by signal: ${signal}]`) + if (exitCode !== null) markers.push(`[exit code: ${exitCode}]`) + return [body, ...markers].join('\n') +} diff --git a/src/shells/host-tool.js b/src/shells/host-tool.js new file mode 100644 index 00000000..5ddcca8a --- /dev/null +++ b/src/shells/host-tool.js @@ -0,0 +1,64 @@ +// Adapted from @deepseek-ai/dsh-tool-bash 0.2.0-rc.2 (MIT). See licenses/deepseek-harness/LICENSE. +/** Host-compatible consumer over an OPS-owned executor; host services stay scoped and borrowed. */ +import { BashArgumentError, isBashArgumentError } from './argument-error.js' +import { approveBashPolicy } from './approval.js' +import { HOST_BASH_OUTPUT_SCHEMA, hostFailure, hostForeground, renderHostBash } from './host-contract.js' +import { startBashJob, waitOnBashJob } from './jobs.js' + +export const HOST_MAX_BASH_TIMEOUT_MS = 600_000 + +export function hostBashDefinition(ctx, base, createExecution, resolveWorkdir) { + const jobs = ctx.get('jobs') + const parameters = { ...base.parameters, required: ['description'], properties: { + ...base.parameters.properties, + description: { type: 'string', description: 'Required: clear active-voice description of this command, 5–10 words.' }, + timeoutMs: { type: 'number', description: 'Defaults to 120000 ms; capped at 600000 ms. With host jobs, expiry hands the running command to job_output/job_kill; otherwise it stops the command.' }, + ...(jobs ? { run_in_background: { type: 'boolean', description: 'Return a host job id immediately; collect with job_output and stop with job_kill. No execution timeout.' } } : {}), + sandbox_permissions: { type: 'string', enum: ['workspace-write', 'danger-full-access'], description: 'After a real denial, request the narrowest wider mode for this exact command with justification. Approval applies only to this call. Do not bypass a rejection.' }, + justification: { type: 'string', description: 'One sentence explaining why this command needs wider access, in the user’s language. Required when changing sandbox_permissions.' }, + } } + return { + ...base, + description: 'Execute a Bash command in a fresh shell using the host sandbox, approvals and jobs. Relative workdir uses the current session workspace. Output is bounded with spill paths. Use command for host-compatible calls; script_path is an OPS extension for an existing script file. Verify resolved absolute targets before delete or move. Never bypass a sandbox denial by switching shells.', + parameters, + output: { schema: HOST_BASH_OUTPUT_SCHEMA, render: (_args, value) => [{ type: 'text', text: renderHostBash(value) }] }, + async execute(args, exec = {}) { + let timeoutMs = 120_000 + let policy + try { + if (!args || typeof args !== 'object' || Array.isArray(args)) throw new BashArgumentError('invalid arguments: expected an object', 'arguments') + const unknown = Object.keys(args).find(key => !Object.hasOwn(parameters.properties, key)) + if (unknown !== undefined) throw new BashArgumentError(`invalid arguments: unknown key ${JSON.stringify(unknown)}; this tool accepts ${Object.keys(parameters.properties).join(', ')}`, unknown) + if (args.run_in_background !== undefined && typeof args.run_in_background !== 'boolean') throw new BashArgumentError('invalid run_in_background: expected a boolean', 'run_in_background') + const { run_in_background, sandbox_permissions, justification, ...local } = args + if (local.timeoutMs !== undefined && (!Number.isFinite(local.timeoutMs) || local.timeoutMs <= 0)) throw new BashArgumentError('invalid timeoutMs: expected a positive finite number', 'timeoutMs') + timeoutMs = Math.min(local.timeoutMs ?? 120_000, HOST_MAX_BASH_TIMEOUT_MS) + local.timeoutMs = timeoutMs + local.workdir = resolveWorkdir(local, exec.agent?.session) + // Pure validation occurs before approval, job admission or physical preparation. + base.resolve(local) + if (typeof args.description !== 'string' || !args.description.trim()) throw new BashArgumentError('invalid description: expected a non-empty string', 'description') + if (!exec.agent?.session?.id) return hostFailure('EXECUTOR_UNAVAILABLE: an authoritative session is required.', timeoutMs) + const standing = ctx.get('sandboxPolicy')?.resolve({ session: exec.agent.session }) + if (!['read-only', 'workspace-write', 'danger-full-access'].includes(standing?.mode)) return hostFailure('EXECUTOR_UNAVAILABLE: authoritative session policy is unavailable.', timeoutMs) + policy = await approveBashPolicy(ctx, args, exec, standing) + exec.signal?.throwIfAborted() + const dshEnv = ctx.get('shellEnv')?.collect(exec) ?? {} + const start = (execution, options) => createExecution(local, execution, standing, policy, dshEnv, options) + if (jobs && ctx.get('jobs') === jobs) { + let attached + try { attached = startBashJob(jobs, args, exec, start) } + catch (error) { + if (run_in_background) throw error + ctx.logger?.warn?.(`Bash job admission refused; using the foreground deadline: ${error.message}`) + } + if (attached) return run_in_background ? { kind: 'background', jobId: attached.id } : await waitOnBashJob(jobs, attached, exec, timeoutMs) + } else if (run_in_background) return hostFailure('EXECUTOR_UNAVAILABLE: the host job registry is unavailable.', timeoutMs) + return hostForeground(await (await start(exec, {})).result()) + } catch (error) { + if (isBashArgumentError(error)) throw error + return { ...hostFailure(String(error?.message ?? error), timeoutMs), ...(exec.signal?.aborted ? { aborted: true, cancelled: true } : {}), ...(policy ? { sandbox: { mode: policy.mode, denied: false } } : {}) } + } + }, + } +} diff --git a/src/shells/jobs.js b/src/shells/jobs.js new file mode 100644 index 00000000..6831a99c --- /dev/null +++ b/src/shells/jobs.js @@ -0,0 +1,63 @@ +// Adapted from @deepseek-ai/dsh-tool-bash 0.2.0-rc.2 (MIT). See licenses/deepseek-harness/LICENSE. +/** Adapt one OPS execution to the host job registry without replacing its lifecycle. */ +import { hostForeground } from './host-contract.js' + +const running = view => ['running', 'stopping'].includes(view.status) + +export function startBashJob(jobs, args, exec, start) { + let process + let stopped + const output = ['stdout', 'stderr'].map(stream => ({ + channel: stream, + read: offset => process?.observed[stream].readFrom(offset) ?? { text: '', nextOffset: offset, lossy: false }, + })) + const id = jobs.start({ kind: 'bash', label: args.command ?? args.script_path, + ...(exec.agent ? { owner: exec.agent.id } : {}), output, + run: () => { + const cancellation = new AbortController() + const done = Promise.resolve().then(async () => { + cancellation.signal.throwIfAborted() + process = await start({ ...exec, signal: cancellation.signal }, { onExpiry: 'none' }) + if (cancellation.signal.aborted) process.kill(stopped) + const value = await process.result() + const state = value.cancelled || value.signal || value.timedOut ? 'killed' : value.error ? 'failed' : 'completed' + const detail = value.error ?? (value.signal ? `signal: ${value.signal}` : `exit code: ${value.exitCode ?? 0}`) + const sandbox = value.sandbox?.runnerFailed ? '; sandbox runner failed before the command ran' + : value.sandbox?.denied ? `; sandbox denied file access under ${value.sandbox.mode} mode` : '' + return { status: state, detail: detail + sandbox } + }).catch(error => ({ status: cancellation.signal.aborted ? 'killed' : 'failed', detail: String(error?.message ?? error) })) + return { done, cancel: reason => { stopped = reason; cancellation.abort(new Error(reason)); process?.kill(reason) } } + }, + }) + return { id, process: () => process, stopped: () => stopped } +} + +export async function waitOnBashJob(jobs, attached, exec, timeoutMs) { + const owner = exec.agent?.id + const stop = async reason => { + jobs.kill(attached.id, owner, reason) + const settled = await jobs.wait(attached.id, timeoutMs, owner) + if (!running(settled)) jobs.remove(attached.id, owner) + } + let view + try { view = await jobs.wait(attached.id, timeoutMs, owner, exec.signal) } + catch (error) { await stop('tool call aborted'); throw error } + if (running(view)) { + if (!attached.process()?.started) { + await stop('timed out during preparation') + return hostForeground({ timedOut: true, timeoutMs, + stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false } }) + } + const read = jobs.read(attached.id, owner) + const stdout = read.chunks.filter(chunk => chunk.channel !== 'stderr').map(chunk => chunk.text).join('') + const stderr = read.chunks.filter(chunk => chunk.channel === 'stderr').map(chunk => chunk.text).join('') + const text = stdout + (stderr ? `${stdout && !stdout.endsWith('\n') ? '\n' : ''}[stderr]\n${stderr}` : '') + const loss = read.lossy ? '\n[some output was dropped from memory; full output: ' + + ((read.job.output.spillPaths ?? []).join(', ') || '(unavailable)') + ']' : '' + return { kind: 'promoted', jobId: attached.id, timeoutMs, output: text + loss } + } + jobs.remove(attached.id, owner) + const process = attached.process() + if (!process) throw new Error(view.detail ?? 'Bash job preparation failed') + return hostForeground({ ...await process.result(), ...(attached.stopped() ? { stopped: attached.stopped() } : {}) }) +} diff --git a/src/shells/session-execution.js b/src/shells/session-execution.js new file mode 100644 index 00000000..627e24e0 --- /dev/null +++ b/src/shells/session-execution.js @@ -0,0 +1,56 @@ +/** Resolve and confine every invocation using the current session authority. */ +import path from 'node:path' +import { bashToolDefinition, withBashStatus } from './tool.js' +import { confinedBash } from '../bash-ipc.js' +import { prepareWorkspace } from '../workspace-preparation.js' +import { executionRestriction } from '../permissions.js' + +export function startSessionBash({ ctx, bash, active }, args, exec, standing, policy, dshEnv, options) { + const session = exec.agent.session + const assertAuthority = () => { + const current = ctx.get('sandboxPolicy')?.resolve({ session }) + if (!active() || current?.mode !== standing.mode || current?.workspaceRoot !== standing.workspaceRoot) { + throw new Error('AUTHORITY_CHANGED: session authority changed before execution; no command executed. Read the current policy before another call.') + } + } + assertAuthority() + if (!path.isAbsolute(bash.file)) throw new Error('EXECUTOR_UNAVAILABLE: Bash requires a resolved absolute executable path; configure an absolute bashPath. No command executed.') + let runtime + try { runtime = policy.mode === 'danger-full-access' ? { file: bash.file } : confinedBash(bash.file, { source: bash.source }) } + catch { throw new Error('EXECUTOR_UNAVAILABLE: the restricted Bash runtime is unavailable or incompatible; this is not a user authorization denial. Restore the verified pinned Bash payload and IPC runtime; changing the DSH selector is not a repair. Do not retry or bypass confinement.') } + let confinement + const definition = bashToolDefinition({ file: runtime.file, source: bash.source, + env: { ...runtime.env, ...dshEnv }, subprocess: ctx.get('subprocess'), + prepareArgv: async (argv, signal) => { + signal.throwIfAborted() + assertAuthority() + await prepareWorkspace(policy) + signal.throwIfAborted() + if (policy.mode !== 'danger-full-access') { + const sandbox = ctx.get('sandbox') + if (!sandbox?.confine) throw new Error('SANDBOX_UNAVAILABLE: confinement is unavailable; no command executed.') + confinement = await sandbox.confine(argv, policy, signal) + if (!['full', 'partial'].includes(confinement?.enforcement) || !confinement.argv?.length) throw new Error('SANDBOX_UNAVAILABLE: invalid confinement result; no command executed.') + } + signal.throwIfAborted() + assertAuthority() + return confinement?.argv ?? argv + }, + projectResult: value => { + const stderr = `${value.stderr?.text ?? ''}\n${value.error ?? ''}` + const runnerFailed = value.exitCode !== 0 && confinement?.runnerFailureRules?.some(rule => + (!rule.allowedExitCodes || rule.allowedExitCodes.includes(value.exitCode)) && + stderr.split(/\r?\n/).filter(line => !rule.informationalLines?.some(info => info.toLowerCase() === line.toLowerCase())) + .some(line => rule.fatalSignatures?.some(signature => line.toLowerCase().includes(signature.toLowerCase())))) + const unavailable = runnerFailed || /couldn't create signal pipe|fatal error.*win32 error|sandbox_unavailable/i.test(stderr) + const denied = !unavailable && value.exitCode !== 0 && confinement?.denialSignatures?.some(signature => stderr.toLowerCase().includes(signature.toLowerCase())) + return withBashStatus({ ...value, + ...(unavailable ? { error: 'EXECUTOR_UNAVAILABLE: the sandbox runtime failed before the command ran; this is not a user authorization denial. Restore the runtime or confinement provider; changing the DSH selector is not a repair. Do not bypass confinement.' } : {}), + ...(denied ? { restriction: executionRestriction('command file effects') } : {}), + sandbox: { mode: policy.mode, denied: denied === true, + ...(confinement ? { enforcement: confinement.enforcement } : {}), ...(unavailable ? { runnerFailed: true } : {}) }, + }) + }, + }) + return definition.start(args, exec, options) +} diff --git a/src/shells/tool.js b/src/shells/tool.js index 04def445..61f3e041 100644 --- a/src/shells/tool.js +++ b/src/shells/tool.js @@ -177,7 +177,7 @@ export const BASH_OUTPUT_SCHEMA = Object.freeze({ */ export function withBashStatus(value) { const completed = value.exitCode === 0 && value.error === undefined && value.permission === undefined && value.restriction === undefined - && value.signal === undefined && value.timedOut !== true && value.cancelled !== true + && value.signal == null && value.timedOut !== true && value.cancelled !== true return { ...value, status: completed ? 'completed' : 'failed' } } @@ -270,7 +270,7 @@ export function bashToolDefinition({ file, source, subprocess, prepareArgv, env * @param {{signal?: AbortSignal}} [exec] - the host execution context. * @returns {Promise} the canonical result value. */ - start(args, exec = {}, { onExpiry } = {}) { + resolve(args) { assertKnownArguments(args, BASH_ARGUMENTS) // Every refusal in this body is a BashArgumentError, never a plain Error: the session layer // folds RUNTIME failures into a value (#168) and rethrows a caller's bad argument, so a wrong @@ -331,9 +331,11 @@ export function bashToolDefinition({ file, source, subprocess, prepareArgv, env } } - return startBashExecution({ file, source, subprocess, prepareArgv, env, projectResult }, { - command, scriptPath, workdir, timeoutMs, graceMs, includeDiagnostics, onExpiry, - }, exec) + return { command, scriptPath, workdir, timeoutMs, graceMs, includeDiagnostics } + }, + start(args, exec = {}, { onExpiry } = {}) { + const spec = definition.resolve(args) + return startBashExecution({ file, source, subprocess, prepareArgv, env, projectResult }, { ...spec, onExpiry }, exec) }, async execute(args, exec) { return definition.start(args, exec).result() }, } diff --git a/test/authority.test.mjs b/test/authority.test.mjs index c294382c..98be3105 100644 --- a/test/authority.test.mjs +++ b/test/authority.test.mjs @@ -206,10 +206,10 @@ await test('Bash stops on a middle-preparation authority change using the real s const tool = ctx.get('tools').get('ops_bash', agent) assert.ok(tool) race = true - const value = await tool.execute({ command: 'unused', workdir: root }, { agent, signal: signal() }) + const value = await tool.execute({ description: 'Check current session authority', command: 'unused', workdir: root }, { agent, signal: signal() }) assert.equal(value.status, 'failed'); assert.match(value.error, /AUTHORITY_CHANGED/) assert.equal(value.permission, undefined); assert.equal(spawned, 0) - const missing = await tool.execute({ command: 'unused' }, { signal: signal() }) + const missing = await tool.execute({ description: 'Check missing session authority', command: 'unused' }, { signal: signal() }) assert.match(missing.error, /EXECUTOR_UNAVAILABLE/); assert.equal(missing.status, 'failed') } finally { await scope.dispose(); await shutdown(ctx) } }) diff --git a/test/bash-ipc.test.mjs b/test/bash-ipc.test.mjs index 03f83d23..6333ba52 100644 --- a/test/bash-ipc.test.mjs +++ b/test/bash-ipc.test.mjs @@ -38,7 +38,7 @@ await test('private runtime accepts only matching original MSYS, and never edits assert.match(message,/works only in danger-full-access/) assert.match(message,/remove bashPath/) assert.match(message,/provision-shells --bash/) - assert.match(message,/no per-call sandbox_permissions escalation/) + assert.match(message,/Approvals never repair an incompatible confined runtime/) assert.doesNotMatch(message,/Rebuild the matching proxies/) const check=restrictedCompatibility(bash) assert.equal(check.compatible,false) diff --git a/test/linux-shell.test.mjs b/test/linux-shell.test.mjs index 68a6253e..ef11be7d 100644 --- a/test/linux-shell.test.mjs +++ b/test/linux-shell.test.mjs @@ -146,7 +146,7 @@ await test('ops_bash runs a real command through the host bash', async () => { definition = ctx.get('tools').get(BASH_TOOL, agent) } assert.ok(definition, `ops_bash must be published to the session on Linux, got ${publicToolNames(ctx).join(', ')}`) - const value = await definition.execute({ command: 'echo dsh-ops-linux-bash' }, { agent, signal: new AbortController().signal }) + const value = await definition.execute({ description: 'Check confined Bash execution', command: 'echo dsh-ops-linux-bash' }, { agent, signal: new AbortController().signal }) assert.equal(value.sandbox?.mode, 'danger-full-access', JSON.stringify(value.sandbox)) assert.match(String(value.stdout?.text ?? ''), /dsh-ops-linux-bash/, `the host bash must have run: ${JSON.stringify(value)}`) // The resolved executable is the machine's own bash, not a plugin-owned copy. @@ -178,7 +178,7 @@ await test('a confined mode is refused rather than run unrestricted', async () = definition = ctx.get('tools').get(BASH_TOOL, agent) } assert.ok(definition, 'a confined session with a provider must still see ops_bash') - const value = await definition.execute({ command: 'echo no' }, { agent, signal: new AbortController().signal }) + const value = await definition.execute({ description: 'Check invalid confinement refuses execution', command: 'echo no' }, { agent, signal: new AbortController().signal }) // The refusal arrives as an error envelope rather than a thrown exception — the plugin's // documented shape, and the same one Windows gets (#44 status stamping). What matters here is // that the host sandbox was consulted and that the command did not run. @@ -225,7 +225,7 @@ await test('session bash resolves workdir before validating or spawning it (#180 await new Promise(resolve => setTimeout(resolve, 50)) } assert.ok(definition, 'the session must publish ops_bash') - const execute = args => definition.execute(args, { agent, signal: new AbortController().signal }) + const execute = args => definition.execute({ description: 'Check session Bash execution', ...args }, { agent, signal: new AbortController().signal }) for (const [workdir, expected] of [ [undefined, workspace], ['', workspace], [' ', workspace], ['.', workspace], ['retest-dir', child], ['./retest-dir', child], ['retest-dir/../retest-dir', child], [child, child], diff --git a/test/permissions.test.mjs b/test/permissions.test.mjs index d96d0b32..6b4801b8 100644 --- a/test/permissions.test.mjs +++ b/test/permissions.test.mjs @@ -356,21 +356,22 @@ await test('owned Modify-only workspace starts real DSH sandbox; three modes and let bash for(let n=0;n<50;n++) { bash=ctx.get('tools').get('ops_bash',agent);if(bash)break;await new Promise(r=>setTimeout(r,10)) } assert.ok(bash) - let result=await bash.execute({command:"printf 'READ_OK'",workdir:root,timeoutMs:10000},exec) + let result=await bash.execute({description:"Check session permission enforcement",command:"printf 'READ_OK'",workdir:root,timeoutMs:10000},exec) if (process.platform==='win32' && result.error?.startsWith('EXECUTOR_UNAVAILABLE')) { assert.match(result.error,/not a user authorization denial/) throw new Error('Windows confined Bash regression: initialization failed; do not bypass the DSH sandbox.') } else { assert.equal(result.exitCode,0,JSON.stringify(result));assert.equal(result.stdout.text,'READ_OK') - result=await bash.execute({command:"printf bad > denied.txt",workdir:root,timeoutMs:10000},exec) + if (process.platform==='win32') assert.equal(result.sandbox.enforcement,'partial','Windows must preserve the host partial-enforcement fact') + result=await bash.execute({description:"Check session permission enforcement",command:"printf bad > denied.txt",workdir:root,timeoutMs:10000},exec) assert.notEqual(result.exitCode,0); assert.ok(!fs.existsSync(path.join(root,'denied.txt'))) assert.equal(result.restriction?.code,'EXECUTION_RESTRICTED',JSON.stringify(result)) agent.session.mode='workspace-write' - result=await bash.execute({command:"printf forbidden > outside.txt",workdir:outside,timeoutMs:10000},exec) + result=await bash.execute({description:"Check session permission enforcement",command:"printf forbidden > outside.txt",workdir:outside,timeoutMs:10000},exec) assert.notEqual(result.exitCode,0,JSON.stringify(result)) assert.equal(result.restriction?.code,'EXECUTION_RESTRICTED',JSON.stringify(result)) assert.ok(!fs.existsSync(path.join(outside,'outside.txt'))) - result=await bash.execute({command:"printf allowed > allowed.txt",workdir:root,timeoutMs:10000},exec) + result=await bash.execute({description:"Check session permission enforcement",command:"printf allowed > allowed.txt",workdir:root,timeoutMs:10000},exec) assert.equal(result.exitCode,0,JSON.stringify(result));assert.equal(fs.readFileSync(path.join(root,'allowed.txt'),'utf8'),'allowed') } await ctx.plugin(NodePtc,{}) @@ -378,10 +379,10 @@ await test('owned Modify-only workspace starts real DSH sandbox; three modes and agent.session.mode='workspace-write' fs.writeFileSync(path.join(root,'probe.sh'),"printf 'SCRIPT_OK'\n") const request = {name:'run_code',callId:'ptc-bash-acceptance',agent,signal:exec.signal,arguments:{description:'Test confined Bash via pure PTC SDK',code:` - const command = await tools.ops_bash({command:"printf 'PTC_OK'",workdir:${JSON.stringify(root)},timeoutMs:10000}); - const script = await tools.ops_bash({script_path:${JSON.stringify(path.join(root,'probe.sh'))},workdir:${JSON.stringify(root)},timeoutMs:10000}); - const pipeline = await tools.ops_bash({command:"printf 'PIPE_OK' | cat",workdir:${JSON.stringify(root)},timeoutMs:10000}); - const denied = await tools.ops_bash({command:"printf blocked > ptc-outside.txt",workdir:${JSON.stringify(outside)},timeoutMs:10000}); + const command = await tools.ops_bash({description:"Check programmatic Bash permissions",command:"printf 'PTC_OK'",workdir:${JSON.stringify(root)},timeoutMs:10000}); + const script = await tools.ops_bash({description:"Check programmatic Bash permissions",script_path:${JSON.stringify(path.join(root,'probe.sh'))},workdir:${JSON.stringify(root)},timeoutMs:10000}); + const pipeline = await tools.ops_bash({description:"Check programmatic Bash permissions",command:"printf 'PIPE_OK' | cat",workdir:${JSON.stringify(root)},timeoutMs:10000}); + const denied = await tools.ops_bash({description:"Check programmatic Bash permissions",command:"printf blocked > ptc-outside.txt",workdir:${JSON.stringify(outside)},timeoutMs:10000}); return {command,script,pipeline,denied}; `}} const ptc=await ctx.get('tools').execute(request) @@ -398,12 +399,12 @@ await test('owned Modify-only workspace starts real DSH sandbox; three modes and assert.ok(!fs.existsSync(path.join(outside,'ptc-outside.txt'))) assert.doesNotMatch(ptcText,/3221225794|EXECUTOR_UNAVAILABLE|fatal error/) agent.session.mode='read-only' - const downgrade=await ctx.get('tools').execute({...request,callId:'ptc-bash-downgrade',arguments:{description:'Verify PTC permission downgrade',code:`return await tools.ops_bash({command:"printf blocked > ptc-readonly.txt",workdir:${JSON.stringify(root)},timeoutMs:10000});`}}) + const downgrade=await ctx.get('tools').execute({...request,callId:'ptc-bash-downgrade',arguments:{description:'Verify PTC permission downgrade',code:`return await tools.ops_bash({description:"Check programmatic Bash permissions",command:"printf blocked > ptc-readonly.txt",workdir:${JSON.stringify(root)},timeoutMs:10000});`}}) assert.notEqual(downgrade.isError,true,JSON.stringify(downgrade)) assert.equal(downgrade.value.result.restriction.code,'EXECUTION_RESTRICTED') assert.ok(!fs.existsSync(path.join(root,'ptc-readonly.txt'))) agent.session.mode='danger-full-access' - result=await bash.execute({command:"printf 'FULL_OK'",workdir:outside,timeoutMs:10000},exec) + result=await bash.execute({description:"Check session permission enforcement",command:"printf 'FULL_OK'",workdir:outside,timeoutMs:10000},exec) assert.equal(result.exitCode,0); assert.equal(result.sandbox.mode,'danger-full-access') const full=await ptcCall(ctx,worker,'batch',{root:outside,action:'run',operations:[{op:'copy',path:'keep.txt',to:'full-copy.txt'}]},exec,exec.signal) assert.equal(full.status,'completed',JSON.stringify(full)) diff --git a/test/policy.test.mjs b/test/policy.test.mjs index 8ac6f38f..d565be07 100644 --- a/test/policy.test.mjs +++ b/test/policy.test.mjs @@ -33,9 +33,9 @@ await test('the prompt states the real file-tool syntax and result contract (#44 assert.match(text, /`\*\.md` matches the top level only/) assert.match(text, /ops_grep has no `include`; ops_replace `pattern` is literal text/) assert.match(text, /ops file tools never throw: check `status` and `error\.code`/) - assert.equal(text.includes('ops_bash resolves with'), false) + assert.equal(text.includes('ops_bash requires description'), false) const bash = renderToolingPolicy({ published: [BASH_TOOL] }) - assert.match(bash, /ops_bash resolves with `status` \(completed\/failed\).*throws only for invalid arguments or an unavailable executor/) + assert.match(bash, /ops_bash requires description.*foreground, background or promoted.*job_output\/job_kill/) assert.equal(bash.includes('never throw'), false, 'no file-tool contract without file tools') assert.equal(bash.includes('include'), false) }) diff --git a/test/shells-host.test.mjs b/test/shells-host.test.mjs new file mode 100644 index 00000000..9aa68345 --- /dev/null +++ b/test/shells-host.test.mjs @@ -0,0 +1,221 @@ +import { assertSupportedJsonSchema, validateJsonSchemaValue } from '@deepseek-ai/dsh-tools' +import { bashToolDefinition } from '../lib/shells.js' +import { hostBashDefinition } from '../lib/shells/host-tool.js' +import { startSessionBash } from '../lib/shells/session-execution.js' +import { startBashJob, waitOnBashJob } from '../lib/shells/jobs.js' +import { sessionWorkdir } from '../lib/session-shells.js' +import { assert, report, test, TEST_HOME } from './lib/harness.mjs' + +function fixture({ mode = 'danger-full-access', jobs, approval, settle = true, exitCode = 0, file = process.execPath } = {}) { + const calls = [], pending = Promise.withResolvers() + const agent = { id: 'shell-host', session: { id: 'shell-host', header: { cwd: TEST_HOME } } } + const services = { jobs, approval, sandboxPolicy: { resolve: () => ({ mode, workspaceRoot: TEST_HOME }) }, + shellEnv: { collect: () => ({ DSH_TEST_FACT: 'current' }) }, + subprocess: { spawn(spec) { + calls.push(spec) + const stream = text => ({ readFrom: offset => ({ text: text.slice(offset), nextOffset: text.length, lossy: false }) }) + if (settle) pending.resolve({ exitCode, signal: null }) + else spec.signal.addEventListener('abort', () => pending.resolve({ exitCode: null, signal: 'SIGTERM' })) + return { done: pending.promise, collected: { stdout: stream('ok'), stderr: stream('warning') } } + } }, + } + const ctx = { get: name => services[name], logger: { warn() {} } } + const bash = { file, source: 'config' } + const base = bashToolDefinition({ ...bash, subprocess: services.subprocess }) + const definition = hostBashDefinition(ctx, base, (args, exec, standing, policy, env, options) => + startSessionBash({ ctx, bash, active: () => true }, args, exec, standing, policy, env, options), sessionWorkdir) + const exec = { agent, callId: 'shell-call', signal: new AbortController().signal } + return { definition, exec, services, calls, pending, setMode: next => { mode = next } } +} + +function jobRegistry({ promote = false, refuse = false } = {}) { + let hooks, declaration, state = 'running', outcome, removed = false + const offsets = { stdout: 0, stderr: 0 } + return { + start(spec) { + if (refuse) throw new Error('owner job limit') + declaration = spec + assert.equal(spec.owner, 'shell-host') + assert.deepEqual(spec.output.map(source => source.channel), ['stdout', 'stderr']) + hooks = spec.run({ id: 'bash-1' }) + hooks.done.then(value => { outcome = value; state = value.status }) + return 'bash-1' + }, + async wait(id, _timeout, owner) { + assert.equal(id, 'bash-1'); assert.equal(owner, 'shell-host') + if (promote && state === 'running') { await new Promise(resolve => setImmediate(resolve)); return { status: state } } + await hooks.done + return { status: state, detail: outcome.detail } + }, + read(id, owner) { + assert.equal(id, 'bash-1'); assert.equal(owner, 'shell-host') + return { chunks: declaration.output.map(source => { + const read = source.read(offsets[source.channel]); offsets[source.channel] = read.nextOffset + return { channel: source.channel, text: read.text } + }), lossy: false, job: { output: { spillPaths: [] } } } + }, + kill(_id, _owner, reason) { state = 'stopping'; hooks.cancel(reason) }, + remove() { removed = true }, + get declaration() { return declaration }, get hooks() { return hooks }, get removed() { return removed }, get outcome() { return outcome }, + } +} + +await test('host foreground calls validate and render the host result contract', async () => { + const f = fixture() + assertSupportedJsonSchema(f.definition.output.schema) + const value = await f.definition.execute({ command: 'echo ok', description: 'Print a short test result' }, f.exec) + assert.equal(value.kind, 'foreground'); assert.equal(value.exitCode, 0); assert.equal(value.signal, null) + assert.equal(value.aborted, false); assert.equal(value.status, 'completed'); assert.equal(value.timeoutMs, 120000) + assert.deepEqual(validateJsonSchemaValue(f.definition.output.schema, value, 'value'), []) + assert.match(f.definition.output.render({}, value)[0].text, /\[stderr\]\nwarning\n\[exit code: 0\]$/) + assert.equal(f.calls[0].argv[0], process.execPath) + assert.equal(f.calls[0].env.DSH_TEST_FACT, 'current') +}) + +await test('host descriptions are required and timeout overrides cap at ten minutes', async () => { + const f = fixture() + await assert.rejects(() => f.definition.execute({ command: 'true' }, f.exec), /invalid description/) + await assert.rejects(() => f.definition.execute({ command: 'true', description: 'x', timeoutMs: NaN }, f.exec), /invalid timeoutMs/) + assert.equal(f.calls.length, 0) + const value = await f.definition.execute({ command: 'true', description: 'Cap the command timeout', timeoutMs: 900000 }, f.exec) + assert.equal(value.timeoutMs, 600000) +}) + +await test('approval rejection and missing approval never execute or create jobs', async () => { + for (const result of ['rejected', 'cancelled', 'unavailable', 'unexpected']) { + const f = fixture({ mode: 'read-only', approval: { async request(req) { + assert.equal(req.toolName, 'ops_bash'); assert.equal(req.callId, 'shell-call'); return result + } } }) + const value = await f.definition.execute({ command: 'true', description: 'Check explicit wider access', sandbox_permissions: 'danger-full-access', justification: 'Need the requested command access.' }, f.exec) + assert.match(value.error, /APPROVAL_/); assert.equal(f.calls.length, 0) + } + const f = fixture({ mode: 'read-only' }) + const value = await f.definition.execute({ command: 'true', description: 'Check missing approval service', sandbox_permissions: 'danger-full-access', justification: 'Need wider access.' }, f.exec) + assert.match(value.error, /APPROVAL_UNAVAILABLE/); assert.equal(f.calls.length, 0) +}) + +await test('an allowed-once grant widens only one call and keeps the current environment', async () => { + let asks = 0 + const f = fixture({ mode: 'read-only', approval: { async request() { asks++; return 'allowed-once' } } }) + const args = { command: 'true', description: 'Run one approved command', sandbox_permissions: 'danger-full-access', justification: 'This command needs wider access.' } + const first = await f.definition.execute(args, f.exec) + assert.equal(first.status, 'completed'); assert.equal(first.sandbox.mode, 'danger-full-access') + const second = await f.definition.execute(args, f.exec) + assert.equal(second.status, 'completed'); assert.equal(asks, 2) + assert.equal(f.services.sandboxPolicy.resolve().mode, 'read-only') +}) + +await test('a session policy change during approval refuses the approved command', async () => { + const f = fixture({ mode: 'read-only' }) + f.services.approval = { async request() { f.setMode('workspace-write'); return 'allowed-once' } } + const value = await f.definition.execute({ command: 'true', description: 'Check a racing authority change', sandbox_permissions: 'danger-full-access', justification: 'Need wider access.' }, f.exec) + assert.match(value.error, /AUTHORITY_CHANGED/); assert.equal(f.calls.length, 0) +}) + +await test('escalation argument failures precede approval and execution', async () => { + let asks = 0 + const f = fixture({ approval: { async request() { asks++; return 'allowed-once' } } }) + for (const args of [{ justification: 'orphan' }, { sandbox_permissions: 'workspace-write', justification: 'narrow' }, { sandbox_permissions: 'unknown' }]) { + await assert.rejects(() => f.definition.execute({ command: 'true', description: 'Validate escalation fields', ...args }, f.exec), /invalid/) + } + assert.equal(asks, 0); assert.equal(f.calls.length, 0) +}) + +await test('completed foreground jobs leave the registry with their result', async () => { + const jobs = jobRegistry(), f = fixture({ jobs }) + const value = await f.definition.execute({ command: 'true', description: 'Run a foreground host job' }, f.exec) + assert.equal(value.kind, 'foreground'); assert.equal(jobs.removed, true) + assert.equal(jobs.outcome.detail, 'exit code: 0') +}) + +await test('timeout promotion returns the same job and consumes output exactly once', async () => { + const jobs = jobRegistry({ promote: true }), f = fixture({ jobs, settle: false }) + const value = await f.definition.execute({ command: 'true', description: 'Promote a long running command', timeoutMs: 1 }, f.exec) + assert.equal(value.kind, 'promoted'); assert.equal(value.jobId, 'bash-1') + assert.equal(value.output, 'ok\n[stderr]\nwarning') + assert.equal(jobs.read('bash-1', f.exec.agent.id).chunks.every(chunk => chunk.text === ''), true) + jobs.kill('bash-1', f.exec.agent.id, 'stop test') + await jobs.hooks.done + assert.equal(jobs.outcome.status, 'killed') +}) + +await test('background returns a host job id and cancellation stops work', async () => { + const jobs = jobRegistry(), f = fixture({ jobs, settle: false }) + const value = await f.definition.execute({ command: 'true', description: 'Start a cancellable background job', run_in_background: true }, f.exec) + assert.equal(value.kind, 'background') + jobs.kill(value.jobId, f.exec.agent.id, 'stop test') + await jobs.hooks.done + assert.equal(jobs.outcome.status, 'killed') +}) + +await test('job admission refusal falls back only for foreground and no hidden work starts', async () => { + const jobs = jobRegistry({ refuse: true }), f = fixture({ jobs }) + const foreground = await f.definition.execute({ command: 'true', description: 'Use foreground when admission refuses' }, f.exec) + assert.equal(foreground.kind, 'foreground'); assert.equal(f.calls.length, 1) + const background = await f.definition.execute({ command: 'true', description: 'Refuse unavailable background admission', run_in_background: true }, f.exec) + assert.match(background.error, /owner job limit/); assert.equal(f.calls.length, 1) +}) + + +await test('same-mode requests do not prompt and cancellation while approving prevents execution', async () => { + const f = fixture({ approval: { request() { throw new Error('must not ask') } } }) + const same = await f.definition.execute({ command: 'true', description: 'Retain the current permission mode', sandbox_permissions: 'danger-full-access', justification: '' }, f.exec) + assert.equal(same.status, 'completed') + const controller = new AbortController(), approved = Promise.withResolvers() + const pending = fixture({ mode: 'read-only', approval: { request: () => approved.promise } }) + const call = pending.definition.execute({ command: 'true', description: 'Cancel an approval pending command', sandbox_permissions: 'danger-full-access', justification: 'Request wider access.' }, { ...pending.exec, signal: controller.signal }) + controller.abort(new Error('approval was cancelled')); approved.resolve('allowed-once') + const value = await call + assert.equal(value.status, 'failed'); assert.equal(value.aborted, true); assert.match(value.error, /approval was cancelled/) + assert.equal(pending.calls.length, 0) +}) + +await test('without jobs the foreground deadline stops execution and background arguments fail', async () => { + const f = fixture({ settle: false }) + await assert.rejects(() => f.definition.execute({ command: 'true', description: 'Reject unavailable background work', run_in_background: true }, f.exec), /unknown key/) + assert.equal(f.calls.length, 0) + // Keep this test alive while the executor's deliberately unref'ed deadline runs. + const hold = setTimeout(() => {}, 2000) + try { + const value = await f.definition.execute({ command: 'true', description: 'Stop the command at expiry', timeoutMs: 5 }, f.exec) + assert.equal(value.kind, 'foreground'); assert.equal(value.timedOut, true) + assert.equal(value.status, 'failed'); assert.equal(f.calls[0].signal.aborted, true) + } finally { clearTimeout(hold) } +}) + +await test('nonzero exit completes the host job but preserves the failed command status', async () => { + const jobs = jobRegistry(), f = fixture({ jobs, exitCode: 7 }) + const value = await f.definition.execute({ command: 'exit 7', description: 'Report a nonzero command exit' }, f.exec) + assert.equal(jobs.outcome.status, 'completed'); assert.equal(jobs.outcome.detail, 'exit code: 7') + assert.equal(value.exitCode, 7); assert.equal(value.status, 'failed') +}) + +await test('job cancellation before asynchronous preparation prevents the starter from running', async () => { + const jobs = jobRegistry(), f = fixture() + let starts = 0 + const attached = startBashJob(jobs, { command: 'true' }, f.exec, () => { starts++; throw new Error('must not start') }) + assert.deepEqual(jobs.declaration.output[0].read(19), { text: '', nextOffset: 19, lossy: false }) + jobs.kill(attached.id, f.exec.agent.id, 'cancel before preparation') + await jobs.hooks.done + assert.equal(starts, 0); assert.equal(jobs.outcome.status, 'killed') +}) + +await test('an expiring pending preparation is stopped and never promoted', async () => { + const jobs = jobRegistry({ promote: true }), f = fixture() + const pending = Promise.withResolvers(); let killed = false + const attached = startBashJob(jobs, { command: 'true' }, f.exec, () => ({ + started: false, observed: { stdout: { readFrom() {} }, stderr: { readFrom() {} } }, + result: () => pending.promise, kill() { killed = true; pending.resolve({ cancelled: true }) }, + })) + const value = await waitOnBashJob(jobs, attached, f.exec, 1) + assert.equal(value.kind, 'foreground'); assert.equal(value.timedOut, true) + assert.equal(killed, true); assert.equal(jobs.removed, true) +}) + +await test('an unresolved executable is refused before the host can search PATH', async () => { + const f = fixture({ file: 'bash' }) + const value = await f.definition.execute({ command: 'true', description: 'Reject an unresolved executable path' }, f.exec) + assert.match(value.error, /resolved absolute executable path/); assert.equal(f.calls.length, 0) +}) + +report('shells-host') diff --git a/test/shells-session.test.mjs b/test/shells-session.test.mjs index acf89726..98607d4e 100644 --- a/test/shells-session.test.mjs +++ b/test/shells-session.test.mjs @@ -17,7 +17,8 @@ import Subprocess from '@deepseek-ai/dsh-subprocess-local' import { createScope } from '@deepseek-ai/dsh-scope' import { assert, report, scenarioDir, skipSuite, test, waitFor, withTempDir } from './lib/harness.mjs' import { bootHost, shutdown } from './lib/host.mjs' -import { resolveShells, withBashStatus } from '../lib/shells.js' +import { resolveShells } from '../lib/shells.js' +import { hostForeground } from '../lib/shells/host-contract.js' import { BASH_ARGUMENT_ERROR_CODE } from '../lib/shells/argument-error.js' import { bashRefusalValue, mountSessionBash } from '../lib/session-shells.js' @@ -78,7 +79,7 @@ async function relativeWorkdirRunsInTheSession() { ['retest-dir/../retest-dir', child], [child, child], ]) { - const value = await tool.execute({ command: 'pwd -P', workdir }, { agent, signal: signal() }) + const value = await tool.execute({ command: 'pwd -P', description: 'Check session working directory', workdir }, { agent, signal: signal() }) assert.equal(value.status, 'completed', JSON.stringify(value)) assert.equal(value.stdout.text.trim(), expected, 'workdir=' + JSON.stringify(workdir)) } @@ -165,12 +166,12 @@ await test('every value the session layer settles on satisfies its registered sc const { agent, scope, tool } = await publishOpsBash(ctx, 'shells-session-schema', workspace) try { for (const command of ['printf ok', 'exit 7']) { - const value = await tool.execute({ command }, { agent, signal: signal() }) + const value = await tool.execute({ command, description: 'Check the registered result schema' }, { agent, signal: signal() }) assert.deepEqual(validateJsonSchemaValue(tool.output.schema, value, 'value'), [], JSON.stringify(value)) } // The refusal shape the session layer folds a runtime failure into: this is the value that // must not fail the host's own validation. - const folded = withBashStatus(bashRefusalValue(new Error('EXECUTOR_UNAVAILABLE: probe'))) + const folded = hostForeground(bashRefusalValue(new Error('EXECUTOR_UNAVAILABLE: probe'))) assert.deepEqual(validateJsonSchemaValue(tool.output.schema, folded, 'value'), []) assert.equal(folded.status, 'failed') } finally { await scope.dispose() } @@ -178,4 +179,22 @@ await test('every value the session layer settles on satisfies its registered sc } finally { await shutdown(ctx) } }) + +await test('host jobs mount and teardown update the scoped background schema', async () => { + const { ctx } = await bootHost() + try { + await withTempDir('shells-session-jobs', async workspace => { + const { agent, scope } = await publishOpsBash(ctx, 'shells-session-jobs', workspace) + try { + const current = () => ctx.get('tools').get('ops_bash', agent) + assert.equal(current().parameters.properties.run_in_background, undefined) + const jobs = await ctx.plugin(inner => inner.provide('jobs', {})) + await waitFor(() => current()?.parameters.properties.run_in_background !== undefined, { what: 'background Bash schema publication' }) + await jobs.dispose() + await waitFor(() => current()?.parameters.properties.run_in_background === undefined, { what: 'background Bash schema removal' }) + } finally { await scope.dispose() } + }) + } finally { await shutdown(ctx) } +}) + report('shells-session') From e701dce32edf0179e2d150c56d153f1900e0abd8 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:44:02 +0800 Subject: [PATCH 038/118] feat(shell): manage Bash as an optional runtime --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 19 +++- NOTICE | 4 +- README.en.md | 4 +- README.md | 4 +- README.zh.md | 4 +- bin/build-bash-ipc.mjs | 4 +- bin/check-release.mjs | 3 +- bin/dsh-ops.mjs | 8 +- docs/CHANGELOG.md | 7 ++ docs/CONTRIBUTING.md | 2 +- docs/configuration-reference.md | 4 +- docs/usage.md | 17 +++- docs/verify-locally.md | 3 +- ...77\347\224\250\346\214\207\345\215\227.md" | 17 +++- ...41\347\214\256\346\214\207\345\215\227.md" | 2 +- ...15\347\275\256\345\217\202\350\200\203.md" | 2 +- package-lock.json | 14 --- package.json | 1 - scripts/build-platform-packages.mjs | 15 ++- scripts/lib/build/assemble.js | 12 +-- scripts/lib/build/publish-dir.js | 2 +- scripts/verify-gate.mjs | 15 ++- src/cli/bash-runtime.ts | 22 +++++ src/profile-install.js | 2 +- src/shells/manager.ts | 80 ++++++++++++++++ src/shells/pinned.ts | 31 ++++++ test/bash-ipc.test.mjs | 7 +- test/bundle-startup.test.mjs | 28 +++++- test/mount.test.mjs | 4 +- test/permissions.test.mjs | 2 +- test/run.mjs | 5 +- test/shells-manager.test.mjs | 94 +++++++++++++++++++ test/shells-session.test.mjs | 2 +- 34 files changed, 370 insertions(+), 72 deletions(-) create mode 100644 src/cli/bash-runtime.ts create mode 100644 src/shells/manager.ts create mode 100644 src/shells/pinned.ts create mode 100644 test/shells-manager.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec5db2e8..484ff427 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,7 +83,7 @@ jobs: # on POSIX (a runtime-absence skip is a failure, not a pass), refuses any skip outside this # platform's known set, fails when a suite file on disk was never collected, and checks the # packed artifact. It builds the plugin before its CLI, then builds/tests the worker and - # Windows IPC payloads. Build success is not behaviour. + # Windows IPC proxies using an explicitly downloaded pinned Bash fixture (or DSH_OPS_TEST_BASH). Build success is not behaviour. - name: Functional gate (skips, suite inventory, packed artifact) shell: bash run: node scripts/verify-gate.mjs --skip-install diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d533c4d7..1f95b221 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,8 +10,8 @@ name: Release # not be read as evidence that a tag produces a release. Enabling it is an admin step, # and the first run must be a dispatch on `main` — see docs/release-process.md. # -# WHAT IT PUBLISHES: the GitHub Release for the tag — title, generated notes, the three -# installable tarballs and `SHA256SUMS`. npm is a separate channel and is deliberately +# WHAT IT PUBLISHES: the GitHub Release for the tag — title, generated notes, the installable +# tarballs and `SHA256SUMS`. npm is a separate channel and is deliberately # NOT published here: `npm publish` needs an `NPM_TOKEN` secret that this repository # does not grant to Actions, and docs/PROVENANCE.md records that GitHub publication # does not automatically publish npm. Adding npm publication later needs an admin to @@ -99,6 +99,10 @@ jobs: shell: bash run: node bin/native-runtime.mjs stage --platform ${{ matrix.target }} --input native/target/${{ matrix.triple }}/release + - name: Acquire optional pinned Bash fixture + if: matrix.target == 'win32-x64' + run: node bin/dsh-ops.mjs bash-runtime install + - name: Build the Windows Bash IPC proxies # Only the Windows target carries the IPC proxies; their receipt is verified # against the committed native/bash-ipc sources by the gate below. Fails when @@ -117,8 +121,8 @@ jobs: gate: name: Verify the release closure and assemble the publish directory needs: native - # Assembly is Windows-only by design: the two platform payload packages this - # release publishes are Windows payloads. The gate reads every target's bytes and + # Assembly is Windows-only by design: the remaining platform payload this + # release publishes is a Windows payload. The gate reads every target's bytes and # does not need to execute them. runs-on: windows-latest timeout-minutes: 45 @@ -170,6 +174,11 @@ jobs: rustup default 1.97.1 cargo build --release --locked --manifest-path native/Cargo.toml + - name: Build plugin and acquire optional pinned Bash fixture + run: | + npm run build + node bin/dsh-ops.mjs bash-runtime install + - name: Release gate (manifest, suites, runtime closure) # Publishing must not start before this step is green — it is the only reason # the `publish` job sits behind `needs`. `verify:release` is @@ -181,7 +190,7 @@ jobs: run: npm run verify:release - name: Assemble and pack the publish directory - # Materializes the two Windows platform packages from their pinned upstream + # Materializes the remaining Windows platform package from its pinned upstream # assets, packs everything, and writes provenance.json + SHA256SUMS under # $env:DIST. A non-zero exit fails the run; nothing is published from here. run: node scripts/build-platform-packages.mjs --dist "$env:DIST" diff --git a/NOTICE b/NOTICE index 856ced5c..d5b479bd 100644 --- a/NOTICE +++ b/NOTICE @@ -32,12 +32,12 @@ attached to the GitHub Release for a tag. They are optional in the dependency sense, and neither of them is a payload at all: no upstream binary travels in this distribution. - * `@dsh-ops/bash--` — a PIN, not a redistribution. + * Bash is an explicit optional download, not an npm dependency or a release payload. This distribution does not contain, forward, repackage, or redistribute Git for Windows. The pin records the upstream release the shell is taken from (`PortableGit-2.56.0.2-64-bit.7z.exe`, Git for Windows `v2.56.0.windows.2`), the official URL that publishes it, - and its SHA-256; `dsh-ops provision-shells` reads the pin, downloads + and its SHA-256; `dsh-ops bash-runtime install` reads the pin, downloads that asset on the user's own machine, verifies the digest there, and unpacks it there. Git for Windows is distributed under the GNU General Public License version 2, so the bytes a user obtains that diff --git a/README.en.md b/README.en.md index d6dae176..4dd32450 100644 --- a/README.en.md +++ b/README.en.md @@ -28,7 +28,7 @@ Main features of this plugin: - A set of more advanced always-on tools written in Rust. Compared with the built-ins, these tools use fewer input and output tokens, run faster, cover more ground, and impose less mental load on the AI; they also carry brief hash checks, line/position info, and similar messages to reduce token overhead. - Planned on the [roadmap](docs/roadmap.md): everyday tools such as DOC/PDF operations, web search, and crawl4ai-style HTML-to-Markdown tools, added through progressive disclosure so the AI avoids writing one-off code or scripts to reach its goal. **The current release does not ship them yet.** - Provides an option to disable the built-in tools, to maximize savings on the AI's mental load and the fixed system prompt. -- On Windows it additionally provides the `ops_bash` tool — the AI is far more fluent with bash than with pwsh. You can use the independent **bash 5.3.15** bundled with ops, or point the `ops_bash` tool at Git Bash. +- On Windows it additionally provides the `ops_bash` tool — the AI is far more fluent with bash than with pwsh. Run `dsh-ops bash-runtime install` to acquire the verified optional Bash, or select an existing Git Bash through `bashPath`. Bash is no longer an npm dependency. Plugin traits: @@ -84,7 +84,7 @@ npx --yes dsh-ops@latest status --profile desktop npx --yes dsh-ops@latest uninstall --profile desktop ``` -Replace `desktop` with `web` or `tui` as needed. You can also uninstall from the marketplace, or run the official `dsh plugin --profile ... remove dsh-ops`. Binaries are managed as part of the profile's plugin dependencies; uninstalling removes the dependency reference without deleting the system shell, copies in other profiles, or the package manager's shared cache. +Replace `desktop` with `web` or `tui` as needed. You can also uninstall from the marketplace, or run the official `dsh plugin --profile ... remove dsh-ops`. Profile uninstall removes plugin dependency references. Remove separately downloaded Bash with `dsh-ops bash-runtime remove --yes`; this does not delete the system shell, copies in other profiles, or the package manager's shared cache. Old files explicitly provisioned by earlier versions under `/dsh-ops/` (provisioned shells, and the FastCtx runtime earlier versions could provision) need separate cleanup: `npx --yes dsh-ops@latest uninstall --yes`. The `~/.fastctx/` state left by the removed background component is not deleted by default; add `--purge-fastctx` when needed. diff --git a/README.md b/README.md index ef363858..88844929 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ - 提供一组更高级的tools常驻工具,由rust编写。这些tools工具相对于原版,输入和输出token都更少,运行速度更快,且覆盖面更广,给ai造成的心智负担更低,同时附带简短的哈希校验、行数位置等消息以减少 - 已在[路线图](docs/roadmap.md)中规划:以渐进式披露的形式补上 doc/pdf 操作、网页搜索、html 转 markdown 类 crawl4ai 工具等日常常用的 tools 工具,让 ai 不必重新编写代码或脚本来实现自己的目标。**当前版本尚未提供这些工具。** - 提供关闭原版tools以最大化节省ai心智负担和系统固定提示词的可选项 -- 在windows平台下额外提供tools:ops_bash的支持——ai使用bash远比ai使用pwsh更流畅。可选择使用ops自带的独立 **bash 5.3.15** ,也可以将ops_bash工具指定为git bash +- 在windows平台下额外提供tools:ops_bash的支持——ai使用bash远比ai使用pwsh更流畅。可运行 `dsh-ops bash-runtime install` 单独下载经过校验的 Bash,或通过 `bashPath` 选择已有 Git Bash;Bash 不再作为 npm 依赖安装 插件特点: @@ -89,7 +89,7 @@ npx --yes dsh-ops@latest status --profile desktop npx --yes dsh-ops@latest uninstall --profile desktop ``` -把 `desktop` 换成 `web` 或 `tui` 即可。也可在市场卸载,或执行官方 `dsh plugin --profile ... remove dsh-ops`。二进制随 profile 的插件依赖管理;卸载移除依赖引用,不误删系统 shell、其它 profile 的副本或包管理器共享缓存。 +把 `desktop` 换成 `web` 或 `tui` 即可。也可在市场卸载,或执行官方 `dsh plugin --profile ... remove dsh-ops`。插件依赖随 profile 管理;单独下载的 Bash 用 `dsh-ops bash-runtime remove --yes` 移除,不误删系统 shell、其它 profile 的副本或包管理器共享缓存。 旧版显式 provision 的 `/dsh-ops/` 文件(provision 的 shell,以及早期版本可 provision 的 FastCtx 运行时)需单独清理:`npx --yes dsh-ops@latest uninstall --yes`。已移除的后台组件留下的 `~/.fastctx/` 状态默认不删;需要时显式加 `--purge-fastctx`。 diff --git a/README.zh.md b/README.zh.md index 19a140c7..70f502be 100644 --- a/README.zh.md +++ b/README.zh.md @@ -28,7 +28,7 @@ - 提供一组更高级的tools常驻工具,由rust编写。这些tools工具相对于原版,输入和输出token都更少,运行速度更快,且覆盖面更广,给ai造成的心智负担更低,同时附带简短的哈希校验、行数位置等消息以减少 - 已在[路线图](docs/roadmap.md)中规划:以渐进式披露的形式补上 doc/pdf 操作、网页搜索、html 转 markdown 类 crawl4ai 工具等日常常用的 tools 工具,让 ai 不必重新编写代码或脚本来实现自己的目标。**当前版本尚未提供这些工具。** - 提供关闭原版tools以最大化节省ai心智负担和系统固定提示词的可选项 -- 在windows平台下额外提供tools:ops_bash的支持——ai使用bash远比ai使用pwsh更流畅。可选择使用ops自带的独立 **bash 5.3.15** ,也可以将ops_bash工具指定为git bash +- 在windows平台下额外提供tools:ops_bash的支持——ai使用bash远比ai使用pwsh更流畅。可运行 `dsh-ops bash-runtime install` 单独下载经过校验的 Bash,或通过 `bashPath` 选择已有 Git Bash;Bash 不再作为 npm 依赖安装 插件特点: @@ -86,7 +86,7 @@ npx --yes dsh-ops@latest status --profile desktop npx --yes dsh-ops@latest uninstall --profile desktop ``` -把 `desktop` 换成 `web` 或 `tui` 即可。也可在市场卸载,或执行官方 `dsh plugin --profile ... remove dsh-ops`。二进制随 profile 的插件依赖管理;卸载移除依赖引用,不误删系统 shell、其它 profile 的副本或包管理器共享缓存。 +把 `desktop` 换成 `web` 或 `tui` 即可。也可在市场卸载,或执行官方 `dsh plugin --profile ... remove dsh-ops`。插件依赖随 profile 管理;单独下载的 Bash 用 `dsh-ops bash-runtime remove --yes` 移除,不误删系统 shell、其它 profile 的副本或包管理器共享缓存。 旧版显式 provision 的 `/dsh-ops/` 文件(provision 的 shell,以及早期版本可 provision 的 FastCtx 运行时)需单独清理:`npx --yes dsh-ops@latest uninstall --yes`。已移除的后台组件留下的 `~/.fastctx/` 状态默认不删;需要时显式加 `--purge-fastctx`。 diff --git a/bin/build-bash-ipc.mjs b/bin/build-bash-ipc.mjs index e776f2bc..259e0f0c 100644 --- a/bin/build-bash-ipc.mjs +++ b/bin/build-bash-ipc.mjs @@ -6,10 +6,12 @@ import os from 'node:os' import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import { fileURLToPath } from 'node:url' +import { pinnedBashExecutable } from '../lib/shells/pinned.js' import { patchMsys, proxyDefinition } from '../lib/bash-pe.js' const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..') if(process.platform!=='win32'||process.arch!=='x64')throw Error('Build the Bash IPC proxies on Windows x64') -const source=path.join(root,'node_modules/@dsh-ops/bash-win32-x64/usr/bin/msys-2.0.dll') +const bash=pinnedBashExecutable({file:process.env.DSH_OPS_TEST_BASH}) +const source=path.resolve(path.dirname(bash),'../usr/bin/msys-2.0.dll') const {exports}=patchMsys(fs.readFileSync(source)) const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'ops-ipc-build-')) const destination=path.join(root,'runtime/win32-x64/bash-ipc') diff --git a/bin/check-release.mjs b/bin/check-release.mjs index 1e479df4..ea309791 100644 --- a/bin/check-release.mjs +++ b/bin/check-release.mjs @@ -22,7 +22,8 @@ if (!sameSet(pkg.os ?? [], PLATFORMS.map(target => target.split('-')[0]).filter( for (const id of ['dsh-ops-file', 'dsh-ops-shell']) { if (!sameSet(components.find(component => component.id === id)?.targets ?? [], PLATFORMS)) throw Error(`${id} target declarations disagree`) } -for (const kind of ['bash', 'pwsh']) { +if (pkg.optionalDependencies?.['@dsh-ops/bash-win32-x64'] || pkg.dependencies?.['@dsh-ops/bash-win32-x64']) throw Error('Bash must be an explicit optional acquisition, not a package dependency') +for (const kind of ['pwsh']) { const name = `@dsh-ops/${kind}-win32-x64` if (pkg.optionalDependencies?.[name] !== '0.2.1') throw Error(`${name} must remain the pinned Windows-only optional dependency`) } diff --git a/bin/dsh-ops.mjs b/bin/dsh-ops.mjs index e236671c..a7ede90c 100644 --- a/bin/dsh-ops.mjs +++ b/bin/dsh-ops.mjs @@ -20,6 +20,7 @@ import { profileCommand } from '../lib/profile-install.js' import { cargoBuild, findCargo } from '../lib/rust.js' import { commandLadder } from '../lib/cli/report.js' import { commandProvisionShells } from '../lib/cli/provision-shells.js' +import { commandBashRuntime } from '../lib/cli/bash-runtime.js' import { commandUninstall } from '../lib/cli/uninstall.js' /** @@ -47,6 +48,9 @@ Usage: dsh-ops provision-shells [--bash] [--pwsh] [--force] [--dry-run] [--pin ] Download the pinned upstream bash and/or PowerShell 7, verify their digests, and install them under /dsh-ops/shells + dsh-ops bash-runtime [status|install|remove|git|managed] [--yes] [--dry-run] + Manage optional Bash; git/managed prints a bashPath selection + for the host config editor. remove previews unless --yes. dsh-ops help This text Environment: @@ -98,7 +102,7 @@ async function main() { }, }) const command = positionals[0] ?? 'help' - if (positionals.length > 1) throw new Error('Unexpected positional arguments.') + if (positionals.length > (command === 'bash-runtime' ? 2 : 1)) throw new Error('Unexpected positional arguments.') if (command === 'install' || (['uninstall', 'status'].includes(command) && values.profile !== undefined)) { return profileCommand(command, { profile: values.profile, dshCli: values['dsh-cli'], dryRun: values['dry-run'] === true }) } @@ -107,6 +111,8 @@ async function main() { return 2 } switch (command) { + case 'bash-runtime': + return commandBashRuntime(positionals[1], { yes: values.yes, dryRun: values['dry-run'], force: values.force, configFile: values.config }) case 'ladder': return commandLadder({ json: values.json === true, configFile: values.config }) case 'build-ptc': { diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cf9ac3cc..3a2fcc53 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,13 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Make Windows Bash an explicit optional runtime (#261) + +- Add Bash status/install/remove and Git/managed selection commands. Removal previews by default, + removes only receipt-marked managed versions, and refuses linked managed directories. +- Remove the automatic Bash npm payload dependency and release payload assembly. Build, CI and + release gates explicitly acquire a pinned fixture, or validate an operator-provided offline one. + ### Reuse host Bash approvals and background jobs (#261) - Require command descriptions and return host foreground/background/promoted result kinds, with diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 7f585751..ed5961f7 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -76,7 +76,7 @@ npm run build:ptc npm pack ``` -On Windows, also run `npm run build:bash-ipc` before packing. Install the resulting +On Windows, first run `node bin/dsh-ops.mjs bash-runtime install`, then `npm run build:bash-ipc` before packing. Bash is not installed by npm. For an offline build, set `DSH_OPS_TEST_BASH` to the absolute pinned `bin/bash.exe`; the build/test helper verifies its digest. The gate acquires its own temporary fixture unless this variable is provided. Install the resulting `dsh-ops-.tgz` with the DSH CLI for your target profile (see the README). `npm pack` runs the build through `prepack`, unless lifecycle scripts are disabled. An unbuilt Git checkout has no `lib/` and is not ready to load; there is no `prepare` hook. Keep development dependencies diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 03dbbd3f..80b1dd43 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -100,9 +100,9 @@ config: - `shellPolicy`: defaults to `advise`; `deny-host-shell` rejects host shells in `deniedHostTools` (default `[pwsh, bash, pwsh_persistent]`). This also disables third-layer pwsh; enable cautiously. - `allowSystemShellFallback`: defaults to `false`. Without a bundled/provisioned bash, do not discover system PATH or well-known installations (**on a POSIX host this key has no effect**: the shell component already resolves the host bash and there is no pinned payload to fall back from). Explicit `true` opts into an unpinned system bash that the operator must trust and maintain. Explicit `bashPath` is a separate operator selection, not an automatic fallback. See the update configuration modification area in the [usage guide](usage.md) for the first-start migration. - `shellPolicy` is independent: `deny-host-shell` masks/refuses host tools; disabling fallback constrains the plugin's bash resolver, not the host's own `pwsh`, and is not a filesystem sandbox. -- `binaryPath` (the native worker executable) / `bashPath`: optional explicit paths; unavailable paths cause an error rather than silently switching executors. Usually no configuration is needed. +- `binaryPath` (the native worker executable) / `bashPath`: optional explicit absolute paths; unavailable paths cause an error rather than silently switching executors. Use `dsh-ops bash-runtime git` or `managed` to print a Bash selection. - **On a POSIX host `ops_bash` already runs the distribution's own bash**: `bashPath` → `PATH` → `/bin/bash`, `/usr/bin/bash`, `/usr/local/bin/bash`, with no second shell shipped, so there is no payload to "fall back" from and `allowSystemShellFallback` does not affect it. In a confined mode (`workspace-write` / `read-only`) `bashPath` still takes effect, with the constraint enforced by a host sandbox (bwrap / Landlock); when no sandbox can confine the call it is **refused** rather than run unrestricted. -- `bashPath` **(Windows) works only in `danger-full-access`.** In read-only and workspace-write, `ops_bash` runs only the pinned MSYS whose IPC compatibility proxies were verified (the bundled payload or `dsh-ops provision-shells --bash`); any other `msys-2.0.dll`, including your own Git for Windows, returns `EXECUTOR_UNAVAILABLE` by design and is never run unconfined. Do not point `bashPath` at your own Git Bash to work around a restricted-mode problem. Check a selection with `dsh-ops ladder --config `, which reports `restricted: yes/no` for the resolved bash. See [Troubleshooting](troubleshooting.md#ops_bash-cannot-run-a-custom-bashpath-or-git-for-windows-in-restricted-modes). +- `bashPath` **(Windows) must match the pinned MSYS for restricted execution.** In read-only and workspace-write, `ops_bash` runs only the pinned MSYS whose IPC compatibility proxies were verified (`dsh-ops bash-runtime install`, or a compatible existing copy); any other `msys-2.0.dll`, including your own Git for Windows, returns `EXECUTOR_UNAVAILABLE` by design and is never run unconfined. Do not point `bashPath` at your own Git Bash to work around a restricted-mode problem. Check a selection with `dsh-ops ladder --config `, which reports `restricted: yes/no` for the resolved bash. See [Troubleshooting](troubleshooting.md#ops_bash-cannot-run-a-custom-bashpath-or-git-for-windows-in-restricted-modes). - `deniedHostTools`: the host tool names refused by the `deny-host-shell` policy. - `configUpdateVersion`: completion marker for the configuration migrations (currently `2`: update 1 retires the old implicit system-bash opt-in, update 2 removes the deprecated FastCtx keys). Do not remove it, or the self-check runs again. - `capture` (「启动数据检测」): defaults to `false`, and **off means nothing happens** — the row registers no listener and creates no directory. When on, every *new* conversation writes into its own working directory: diff --git a/docs/usage.md b/docs/usage.md index 02e94293..46c62a18 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -46,7 +46,22 @@ capability matrix and the known limitations. ### Official plugin marketplace -In the target DSH application's plugin marketplace, enter the npm package name **`dsh-ops`**, install, then enable. Installation belongs to the currently running profile, which is `desktop` for the desktop app. The published package depends on complete bash and PowerShell 7 payloads; it does not download them through install scripts or change the system PATH. +In the target DSH application's plugin marketplace, enter the npm package name **`dsh-ops`**, install, then enable. Installation belongs to the currently running profile, which is `desktop` for the desktop app. Bash is acquired separately through the runtime manager; PowerShell 7 remains an optional payload dependency in this slice. Installation runs no shell downloader and does not change system PATH. + +### Optional Bash runtime + +Bash is no longer an npm dependency. On Windows, install the verified managed copy explicitly, or select an existing Git Bash: + +```console +dsh-ops bash-runtime status +dsh-ops bash-runtime install +dsh-ops bash-runtime git +dsh-ops bash-runtime managed +dsh-ops bash-runtime remove # preview only +dsh-ops bash-runtime remove --yes # remove receipt-marked managed versions +``` + +`git` and `managed` print an absolute `bashPath` configuration for the host config editor; they do not edit a profile or PATH. Reload the shell component after applying the selection or installing/removing its runtime. Stop running Bash jobs before removal. Removal refuses links in the managed path and preserves unmarked versions, system Git and PowerShell. `install` reuses the pinned download and checksum checks from `provision-shells --bash`; `--dry-run` makes no download or change. Windows restricted modes still require the pinned MSYS IPC compatibility match. A different Git Bash remains a full-access choice; approval never repairs an incompatible restricted runtime. Linux/macOS continue using host Bash. ### One-command npm installation diff --git a/docs/verify-locally.md b/docs/verify-locally.md index 01507976..c9bb1321 100644 --- a/docs/verify-locally.md +++ b/docs/verify-locally.md @@ -34,9 +34,10 @@ There is no exit code for "green with a surprise in it" — that is `1`. | Step | Command | What it settles | | --- | --- | --- | | versions | `node --version`, `cargo --version` | The toolchain is the pinned one, before anything else can be blamed | -| install | `npm ci --ignore-scripts --registry=https://registry.npmjs.org` | Locked dependencies, and no install-time script runs (the Windows shell payloads are optional dependencies; `--ignore-scripts` is what keeps a Linux install from pretending to carry them) | +| install | `npm ci --ignore-scripts --registry=https://registry.npmjs.org` | Locked dependencies, and no install-time script runs (Bash is acquired explicitly for Windows tests; no install hook downloads it) | | worker unit tests | `cargo test --workspace --locked --manifest-path native/Cargo.toml` | The Rust worker in isolation | | stage | `npm run build:ptc` | Builds the worker **and stages it with its provenance receipt** into `runtime/-/`. Skipping this is the most common way to get a misleading failure — see the traps below | +| Bash fixture | `node bin/dsh-ops.mjs bash-runtime install` (Windows) | Explicit pinned fixture acquisition; `DSH_OPS_TEST_BASH` can name a verified offline copy | | platform payloads | `npm run build:bash-ipc` (Windows) | The Windows IPC proxies | | manifest | `npm run validate:manifest` | The declaration layers agree; asserts a floor of `59` (currently `62`) | | suites | `DSH_HOME= DSH_OPS_REQUIRE_RUNTIME=1 npm run verify` | Every suite on this host. `DSH_OPS_REQUIRE_RUNTIME=1` turns a runtime-absence skip into a failure, and `DSH_HOME` must be a temporary directory or `test/lib/harness.mjs` refuses to run | diff --git "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" index 1a65e3fa..d67b1788 100644 --- "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -43,7 +43,22 @@ PowerShell 7 在 macOS 上不可用。能力矩阵与已知限制见 [macOS arm6 ### 官方插件市场 -在目标 DSH 应用的插件市场中输入 npm 包名 **`dsh-ops`**,安装后选择启用。安装归属于当前运行的 profile,桌面端为 `desktop`。发布包依赖完整 bash 和 PowerShell 7 载荷;不使用安装脚本下载,不改系统 PATH。 +在目标 DSH 应用的插件市场中输入 npm 包名 **`dsh-ops`**,安装后选择启用。安装归属于当前运行的 profile,桌面端为 `desktop`。Bash 通过独立运行时管理器获取;本切片中 PowerShell 7 暂时保留为可选载荷依赖。安装不运行 shell 下载脚本,不改系统 PATH。 + +### 可选 Bash 运行时 + +Bash 不再作为 npm 依赖安装。Windows 上可显式安装经过校验的托管副本,或选择已有的 Git Bash: + +```console +dsh-ops bash-runtime status +dsh-ops bash-runtime install +dsh-ops bash-runtime git +dsh-ops bash-runtime managed +dsh-ops bash-runtime remove # 只预览 +dsh-ops bash-runtime remove --yes # 只移除有托管收据的版本 +``` + +`git` 和 `managed` 输出供宿主配置编辑器使用的绝对 `bashPath`,不改 profile 或 PATH。应用选择、安装或移除运行时后重新加载 shell 组件。移除前先停止 Bash jobs;移除会拒绝托管路径中的链接,并保留没有收据的目录、系统 Git 与 PowerShell。`install` 复用 `provision-shells --bash` 的固定下载与校验流程;`--dry-run` 不下载、不写入。Windows 受限模式仍要求固定 MSYS 与 IPC 兼容性匹配;其它 Git Bash 只能用于完全访问模式,审批无法修复不兼容的受限运行时。Linux/macOS 继续使用宿主 Bash。 ### npm 一键安装 diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 4665b8cd..1bd9a077 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -60,7 +60,7 @@ npm run build:ptc npm pack ``` -Windows 上还需在打包前运行 `npm run build:bash-ipc`。使用目标 profile 对应的 DSH CLI 安装生成的 `dsh-ops-.tgz`(见 README)。除非禁用生命周期脚本,`npm pack` 会通过 `prepack` 执行构建。未构建的 Git 检出没有 `lib/`,不能直接加载;项目没有 `prepare` 钩子。构建时须保留开发依赖,其中包含编译器。发布包包含编译后的 `lib/` 及其资源,不包含 `src/`,使用者无需编译插件。 +Windows 上打包前先运行 `node bin/dsh-ops.mjs bash-runtime install`,再运行 `npm run build:bash-ipc`;npm 不再安装 Bash。离线构建可把 `DSH_OPS_TEST_BASH` 设置为固定版本 `bin/bash.exe` 的绝对路径,构建/测试会核验摘要。未提供该变量时,门禁会获取自己的临时测试副本。使用目标 profile 对应的 DSH CLI 安装生成的 `dsh-ops-.tgz`(见 README)。除非禁用生命周期脚本,`npm pack` 会通过 `prepack` 执行构建。未构建的 Git 检出没有 `lib/`,不能直接加载;项目没有 `prepare` 钩子。构建时须保留开发依赖,其中包含编译器。发布包包含编译后的 `lib/` 及其资源,不包含 `src/`,使用者无需编译插件。 ## 构建原生 worker diff --git "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" index bce76e2f..0a78c3b4 100644 --- "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" +++ "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" @@ -95,7 +95,7 @@ config: - `shellPolicy` 与此开关独立:`deny-host-shell` 遮蔽/拒绝宿主工具;禁止系统回退只约束插件自己的 bash 解析,不关闭宿主 `pwsh`,也不提供文件系统沙箱。 - `binaryPath`(原生 worker 可执行文件)/ `bashPath`:可选显式路径;不可用时报错,不悄悄换执行器。一般无需配置。 - **POSIX 主机上 `ops_bash` 本就用宿主发行版的 bash**:解析顺序为 `bashPath` → `PATH` → `/bin/bash`、`/usr/bin/bash`、`/usr/local/bin/bash`,随包不提供第二份 shell,因此没有需要"回退"的载荷,`allowSystemShellFallback` 也不影响它。受限模式(`workspace-write` / `read-only`)下 `bashPath` 同样生效,约束由宿主沙箱(bwrap / Landlock)落实;沙箱不可用或无法约束时**拒绝执行**,不会退化为无约束运行。 -- `bashPath` **(Windows)仅在 `danger-full-access` 下生效。** 只读与工作区内修改模式下,`ops_bash` 只运行已校验 IPC 兼容代理的固定摘要 MSYS(随包载荷或 `dsh-ops provision-shells --bash`);其它任何 `msys-2.0.dll`(包括你自己的 Git for Windows)都会按设计返回 `EXECUTOR_UNAVAILABLE`,且绝不裸执行。不要把 `bashPath` 指向自带 Git Bash 来规避受限模式问题。可用 `dsh-ops ladder --config <文件>` 检查所选 bash,报告中的 `restricted: yes/no` 即受限模式能否运行。见 [常见问题](常见问题.md#受限模式下-ops_bash-无法运行自定义-bashpath-或-git-for-windows)。 +- `bashPath` **(Windows)在受限模式下必须匹配固定 MSYS。** 只读与工作区内修改模式下,`ops_bash` 只运行已校验 IPC 兼容代理的固定摘要 MSYS(`dsh-ops bash-runtime install` 或兼容的已有副本);其它任何 `msys-2.0.dll`(包括你自己的 Git for Windows)都会按设计返回 `EXECUTOR_UNAVAILABLE`,且绝不裸执行。不要把 `bashPath` 指向自带 Git Bash 来规避受限模式问题。可用 `dsh-ops ladder --config <文件>` 检查所选 bash,报告中的 `restricted: yes/no` 即受限模式能否运行。见 [常见问题](常见问题.md#受限模式下-ops_bash-无法运行自定义-bashpath-或-git-for-windows)。 - `deniedHostTools`:`deny-host-shell` 策略下拒绝的宿主工具名列表。 - `configUpdateVersion`:配置迁移的完成标记(当前为 `2`:更新 1 撤销旧的隐式系统 bash 选择,更新 2 删除已弃用的 FastCtx 键)。不要删除迁移标记,否则会重新自检。 - `capture`(「启动数据检测」):默认 `false`,**关就是什么都不做**——该行不注册监听、不建目录。打开后,每个**新对话**写入它自己的工作目录: diff --git a/package-lock.json b/package-lock.json index 57960e2e..68a5da52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,6 @@ "node": "^22.19 || >=24" }, "optionalDependencies": { - "@dsh-ops/bash-win32-x64": "0.2.1", "@dsh-ops/pwsh-win32-x64": "0.2.1" }, "peerDependencies": { @@ -734,19 +733,6 @@ "@standard-schema/spec": "^1.1.0" } }, - "node_modules/@dsh-ops/bash-win32-x64": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@dsh-ops/bash-win32-x64/-/bash-win32-x64-0.2.1.tgz", - "integrity": "sha512-qK7Pm8ioRkjf+UBAqPOhogB0iPX/tBUmBhQSMdrBVJKOQjeLDHluRDOx1JdLHfxhW+oUJdu19TG/HXxDa9o7zw==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@dsh-ops/pwsh-win32-x64": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@dsh-ops/pwsh-win32-x64/-/pwsh-win32-x64-0.2.1.tgz", diff --git a/package.json b/package.json index 9dbb25bc..23d47186 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,6 @@ } }, "optionalDependencies": { - "@dsh-ops/bash-win32-x64": "0.2.1", "@dsh-ops/pwsh-win32-x64": "0.2.1" }, "devDependencies": { diff --git a/scripts/build-platform-packages.mjs b/scripts/build-platform-packages.mjs index 35cd41d1..07cdae45 100644 --- a/scripts/build-platform-packages.mjs +++ b/scripts/build-platform-packages.mjs @@ -1,10 +1,9 @@ #!/usr/bin/env node /** - * Build and verify the two platform packages this plugin publishes. + * Build and verify the remaining PowerShell platform package. Bash is acquired separately. * - * Both packages carry ready-to-run Windows payloads: bash and pwsh are complete, - * digest-verified upstream runtime trees with their licenses preserved. Both are - * injected as exact optional deps. Downloads happen during release assembly, + * The PowerShell package carries the complete, digest-verified Windows runtime + * with its licenses preserved. It is injected as an exact optional dependency. Downloads happen during release assembly, * never in an install lifecycle hook. * * `--verify-upstream` keeps the packages honest: it downloads the pinned assets, @@ -22,13 +21,13 @@ * * PIPELINE. Each step is re-runnable on its own: * - * assemble the two package directories, from the committed skeletons + * assemble the package directory, from the committed skeletons * in `packages/--/` plus the verified * upstream tree and a per-package `provenance.json` * pack `npm pack` each directory, then write `SHA256SUMS` and the * release-wide `provenance.json` * publish-dir copy the main package into a temporary publish directory, - * inject both platform payload packages and pack it + * inject the remaining platform payload and pack it * check verify every artifact without downloading or building * fetch download the pinned upstream assets and keep them * verify-upstream download them, verify the digests, delete them again @@ -65,8 +64,8 @@ import { printArtifactTable, stepPack } from './lib/build/pack.js' import { stepPublishDir } from './lib/build/publish-dir.js' import { REPO_ROOT, log } from './lib/build/util.js' -/** The two packages one release publishes, in publish order. */ -const KINDS = Object.freeze(['bash', 'pwsh']) +/** The packages this release publishes, in publish order. */ +const KINDS = Object.freeze(['pwsh']) // --------------------------------------------------------------------------- // Step: fetch and verify-upstream diff --git a/scripts/lib/build/assemble.js b/scripts/lib/build/assemble.js index 129bad75..27673188 100644 --- a/scripts/lib/build/assemble.js +++ b/scripts/lib/build/assemble.js @@ -1,7 +1,7 @@ /** * Step: assemble — what one release builds and the packages it stages. * - * The two package directories are built from the committed skeletons in + * The remaining package directory is built from the committed skeletons in * `packages/--/` plus the verified upstream tree and a * per-package `provenance.json`. * @@ -41,16 +41,6 @@ export function shellPackageName(kind, platform, arch) { export function describePackages(platform, arch, dist) { const version = mainManifest().version return [ - { - kind: 'bash', - name: shellPackageName('bash', platform, arch), - version, - platform, - arch, - dist, - executable: `bin/${PLATFORM_PACKAGES.bash.executable}`, - payload: { type: 'archive', pin: resolveShellPin('bash', platform, arch) }, - }, { kind: 'pwsh', name: shellPackageName('pwsh', platform, arch), diff --git a/scripts/lib/build/publish-dir.js b/scripts/lib/build/publish-dir.js index 30c66bee..36277030 100644 --- a/scripts/lib/build/publish-dir.js +++ b/scripts/lib/build/publish-dir.js @@ -44,7 +44,7 @@ export function printInjection(context) { console.log(` ${own ? '+' : ' '} ${name}: ${range}`) } console.log(' (+ marks a platform package this release injects; the rest came from the committed manifest)') - console.log(' (both platform packages carry ready-to-run Windows binaries)\n') + console.log(' (the remaining platform package carries ready-to-run Windows binaries)\n') } /** diff --git a/scripts/verify-gate.mjs b/scripts/verify-gate.mjs index 0158a96d..dce31da7 100644 --- a/scripts/verify-gate.mjs +++ b/scripts/verify-gate.mjs @@ -184,7 +184,18 @@ async function main() { await run('native worker unit tests', 'cargo', ['test', '--workspace', '--locked', '--manifest-path', 'native/Cargo.toml']) await run('build and stage the native worker', 'npm', ['run', 'build:ptc']) - if (TARGET === 'win32-x64') await run('Windows IPC payloads', 'npm', ['run', 'build:bash-ipc']) + const runtimeEnv = {} + if (TARGET === 'win32-x64') { + const { pinnedBashExecutable } = await import('../lib/shells/pinned.js') + let fixture = process.env.DSH_OPS_TEST_BASH + if (!fixture) { + const fixtureEnv = { DSH_HOME: mkdtempSync(path.join(tmpdir(), 'dsh-ops-bash-fixture-')) } + await run('Acquire optional pinned Bash fixture', 'node', ['bin/dsh-ops.mjs', 'bash-runtime', 'install'], { env: fixtureEnv }) + fixture = pinnedBashExecutable({ env: fixtureEnv }) + } + runtimeEnv.DSH_OPS_TEST_BASH = pinnedBashExecutable({ file: fixture }) + await run('Windows IPC payloads', 'npm', ['run', 'build:bash-ipc'], { env: runtimeEnv }) + } const manifest = await run('manifest contract', 'npm', ['run', 'validate:manifest']) const manifestChecks = parseManifest(manifest.output) @@ -196,7 +207,7 @@ async function main() { // script would never be green there. Windows keeps its protection through the skip set below // instead — a skip outside `ALLOWED_SKIPS` fails, and the suites that would betray a worker // that was never built (`permissions`, `ptc`) are not in it. - const env = { DSH_HOME: mkdtempSync(path.join(tmpdir(), 'dsh-ops-gate-')) } + const env = { ...runtimeEnv, DSH_HOME: mkdtempSync(path.join(tmpdir(), 'dsh-ops-gate-')) } if (TARGET !== 'win32-x64') env.DSH_OPS_REQUIRE_RUNTIME = '1' // Keep the external-checkout suites as skips: `DSH_OPS_REQUIRE_RUNTIME=1` would otherwise // count them as failures and make this gate permanently red on a machine without that checkout. diff --git a/src/cli/bash-runtime.ts b/src/cli/bash-runtime.ts new file mode 100644 index 00000000..53cae003 --- /dev/null +++ b/src/cli/bash-runtime.ts @@ -0,0 +1,22 @@ +/** Optional Bash acquisition and lifecycle CLI; never edits a profile or ambient PATH. */ +import { commandProvisionShells } from './provision-shells.js' +import { readRowConfig } from './report.js' +import { resolveConfig } from '../config.js' +import { resolveShells } from '../shells.js' +import { gitBashCandidates, managedBashInventory, removeManagedBash, selectBashRuntime } from '../shells/manager.js' + +interface BashRuntimeCommandOptions { force?: boolean; dryRun?: boolean; yes?: boolean; configFile?: string } + +export function commandBashRuntime(action = 'status', options: BashRuntimeCommandOptions = {}): number { + let result + if (action === 'install') return commandProvisionShells({ kinds: ['bash'], force: options.force ?? false, dryRun: options.dryRun ?? false, pinFile: undefined }) + if (action === 'remove') result = removeManagedBash(options) + else if (action === 'git' || action === 'managed') result = selectBashRuntime(action) + else if (action === 'status') result = { selected: resolveShells(resolveConfig(readRowConfig(options.configFile))).bash, + managed: managedBashInventory(), git: gitBashCandidates() } + else throw new Error('Expected bash-runtime status, install, remove, git or managed.') + console.log(JSON.stringify(result, null, 2)) + if (action === 'git' || action === 'managed') console.log('Apply the config object to dsh-ops/shell with the host config editor, then reload the component. No profile was changed.') + if (action === 'remove' && 'action' in result && result.action === 'preview') console.log('Pass --yes to remove only these managed Bash versions. Stop running Bash jobs before removal.') + return 0 +} diff --git a/src/profile-install.js b/src/profile-install.js index f6f55de1..940cbfa0 100644 --- a/src/profile-install.js +++ b/src/profile-install.js @@ -94,7 +94,7 @@ export function profileCommand(operation, { profile, dshCli, dryRun = false }) { if (result.error) throw result.error if (result.status !== 0) return result.status ?? 1 console.log(operation === 'install' - ? 'Installed through the official profile manager. Reload/restart the target application if needed. Binaries are profile dependencies; no postinstall downloads.' + ? 'Installed through the official profile manager. Reload/restart the target application if needed. Bash is an explicit optional download: dsh-ops bash-runtime install. No postinstall downloads.' : 'Removed through the official profile manager, including its runtime dependency references. Package-manager shared caches are not deleted.') return 0 } diff --git a/src/shells/manager.ts b/src/shells/manager.ts new file mode 100644 index 00000000..91d14b3f --- /dev/null +++ b/src/shells/manager.ts @@ -0,0 +1,80 @@ +/** Operator-owned Bash installation inventory, selection and receipt-scoped removal. */ +import fs from 'node:fs' +import path from 'node:path' +import { dshHome } from '../paths.js' +import { provisionedShellStore, shellUpstreamPin, shellExecutable } from './pins.js' +import { isSpawnableFile, wellKnownCandidates } from './paths.js' +import { restrictedCompatibility } from '../bash-ipc.js' + +export interface BashRuntimeOptions { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; arch?: string } +export interface ManagedBashVersion { directory: string; version: string; owned: boolean; sha256?: string } +export interface BashRemovalOptions { env?: NodeJS.ProcessEnv; yes?: boolean; dryRun?: boolean } +const missing = (error: unknown): boolean => typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' +const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) + +function assertPhysicalDirectory(directory: string, anchor: string): void { + const relative = path.relative(anchor, directory) + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Bash removal target must be inside DSH_HOME') + let current = anchor + for (const part of relative.split(path.sep)) { + current = path.join(current, part) + let stat + try { stat = fs.lstatSync(current) } catch (error) { if (missing(error)) return; throw error } + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Managed Bash directories must not traverse links: ' + current) + } +} + +export function managedBashInventory({ env = process.env }: BashRuntimeOptions = {}): ManagedBashVersion[] { + const home = dshHome(env), store = path.join(provisionedShellStore({ env }), 'bash') + assertPhysicalDirectory(store, home) + let entries + try { entries = fs.readdirSync(store, { withFileTypes: true }) } catch (error) { if (missing(error)) return []; throw error } + return entries.map(entry => { + const directory = path.join(store, entry.name) + assertPhysicalDirectory(directory, home) + const receiptFile = path.join(directory, '.provisioned.json') + let receipt: unknown + try { + if (!fs.lstatSync(receiptFile).isFile() || fs.lstatSync(receiptFile).isSymbolicLink()) throw new Error('not a regular receipt') + receipt = JSON.parse(fs.readFileSync(receiptFile, 'utf8')) + } catch { return { directory, version: entry.name, owned: false } } + if (!record(receipt)) return { directory, version: entry.name, owned: false } + return { directory, version: entry.name, owned: receipt.schemaVersion === 1 && receipt.name === 'bash' + && receipt.version === entry.name && typeof receipt.sha256 === 'string' && /^[a-f0-9]{64}$/i.test(receipt.sha256), + ...(typeof receipt.sha256 === 'string' ? { sha256: receipt.sha256 } : {}) } + }) +} + +/** Reports first; deletes only receipt-marked version directories after explicit operator confirmation. */ +export function removeManagedBash({ env = process.env, yes = false, dryRun = false }: BashRemovalOptions = {}): { action: 'removed' | 'preview'; versions: ManagedBashVersion[]; kept: ManagedBashVersion[] } { + const inventory = managedBashInventory({ env }) + const selected = inventory.filter(entry => entry.owned) + if (yes && !dryRun) { + // Validate the complete plan before the first deletion. Never touch an unmarked or system copy. + for (const entry of selected) assertPhysicalDirectory(entry.directory, dshHome(env)) + for (const entry of selected) fs.rmSync(entry.directory, { recursive: true, force: false }) + } + return { action: yes && !dryRun ? 'removed' : 'preview', versions: selected, kept: inventory.filter(entry => !entry.owned) } +} + +/** Only actual Git install layouts; no PATH launcher, WSL, shim or implicit shell switch. */ +export function gitBashCandidates({ env = process.env, platform = process.platform }: BashRuntimeOptions = {}): string[] { + if (platform !== 'win32') return [] + return [...new Set(wellKnownCandidates({ kind: 'bash', env, platform }) + .filter(file => /[\\/]git[\\/]/i.test(file) && isSpawnableFile(file)).map(file => path.resolve(file)))] +} + +/** The row config is a reviewable selection; the host config editor owns persisting and remounting it. */ +export function selectBashRuntime(source: 'managed' | 'git', options: BashRuntimeOptions = {}) { + const platform = options.platform ?? process.platform + const pin = shellUpstreamPin({ kind: 'bash', platform, arch: options.arch ?? process.arch }) + const relative = pin && 'executableRelativePath' in pin && typeof pin.executableRelativePath === 'string' + ? pin.executableRelativePath : path.join('bin', shellExecutable('bash', platform)) + const managed = source === 'managed' ? managedBashInventory(options).filter(entry => entry.owned) + .sort((a, b) => a.version < b.version ? 1 : a.version > b.version ? -1 : 0) + .map(entry => path.join(entry.directory, relative)).find(isSpawnableFile) : undefined + const file = source === 'managed' ? managed : source === 'git' ? gitBashCandidates(options)[0] : undefined + if (!file) throw new Error(source === 'managed' ? 'No managed Bash is installed; run dsh-ops bash-runtime install.' : 'No Git Bash installation found; set bashPath to an existing absolute executable.') + const absolute = path.resolve(file) + return { source, file: absolute, config: { bashPath: absolute }, restricted: restrictedCompatibility(absolute, options) } +} diff --git a/src/shells/pinned.ts b/src/shells/pinned.ts new file mode 100644 index 00000000..cac83d5f --- /dev/null +++ b/src/shells/pinned.ts @@ -0,0 +1,31 @@ +/** Build/test input only: find the pinned optional Bash without requiring an npm payload. */ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { createRequire } from 'node:module' +import { PACKAGE_ROOT } from '../paths.js' +import { shellUpstreamPin } from './pins.js' +import { provisionedShell } from './paths.js' + +interface PinnedBashOptions { file?: string; env?: NodeJS.ProcessEnv; bundleRoot?: string; required?: boolean } +export function pinnedBashExecutable(options: PinnedBashOptions & { required: false }): string | undefined +export function pinnedBashExecutable(options?: PinnedBashOptions): string +export function pinnedBashExecutable({ file, env = process.env, bundleRoot = PACKAGE_ROOT, required = true }: PinnedBashOptions = {}): string | undefined { + let candidate = file ?? provisionedShell({ kind: 'bash', env, platform: 'win32', arch: 'x64' })?.file + if (!candidate) { + // Older checkouts may still have the optional package. It is a fixture, never a new dependency. + try { candidate = path.join(path.dirname(createRequire(path.join(bundleRoot, 'package.json')).resolve('@dsh-ops/bash-win32-x64/package.json')), 'bin/bash.exe') } catch {} + } + if (!candidate) { + if (!required) return undefined + throw new Error('Pinned Bash is unavailable. Run dsh-ops bash-runtime install, or set DSH_OPS_TEST_BASH to a previously verified pinned bin/bash.exe for offline validation.') + } + if (!path.isAbsolute(candidate)) throw new Error('Pinned Bash input must be an absolute executable path') + const pin = shellUpstreamPin({ kind: 'bash', platform: 'win32', arch: 'x64' }) + if (!pin || !('executableSha256' in pin) || typeof pin.executableSha256 !== 'string') throw new Error('The Windows x64 Bash pin has no executable digest') + const hash = createHash('sha256').update(fs.readFileSync(candidate)).digest('hex') + if (hash !== pin.executableSha256) throw new Error('Bash input does not match the pinned executable SHA-256; install the pinned managed runtime') + const dll = path.resolve(path.dirname(candidate), '../usr/bin/msys-2.0.dll') + if (!fs.statSync(dll).isFile()) throw new Error('Pinned Bash input is missing its MSYS runtime') + return candidate +} diff --git a/test/bash-ipc.test.mjs b/test/bash-ipc.test.mjs index 6333ba52..8935eb4e 100644 --- a/test/bash-ipc.test.mjs +++ b/test/bash-ipc.test.mjs @@ -1,6 +1,7 @@ import fs from 'node:fs' import path from 'node:path' import { createHash } from 'node:crypto' +import { pinnedBashExecutable } from '../lib/shells/pinned.js' import { patchMsys, proxyDefinition } from '../lib/bash-pe.js' import { confinedBash, restrictedCompatibility } from '../lib/bash-ipc.js' import { assert, test, report, withTempDir } from './lib/harness.mjs' @@ -18,7 +19,7 @@ await test('proxy definitions never intercept file, token, or impersonation APIs }) await test('private runtime accepts only matching original MSYS, and never edits the original', async () => { if(process.platform!=='win32')return - const source=path.resolve('node_modules/@dsh-ops/bash-win32-x64') + const source=path.resolve(path.dirname(pinnedBashExecutable({file:process.env.DSH_OPS_TEST_BASH})),'..') const original=fs.readFileSync(path.join(source,'usr/bin/msys-2.0.dll')) const hash=b=>createHash('sha256').update(b).digest('hex') const patched=patchMsys(original) @@ -52,7 +53,7 @@ await test('restricted compatibility accepts the pinned payload and explains a m if(process.platform!=='win32')return const receipt=path.resolve('runtime/win32-x64/bash-ipc/provenance.json') if(fs.existsSync(receipt)) { - const pinned=restrictedCompatibility(path.resolve('node_modules/@dsh-ops/bash-win32-x64/bin/bash.exe')) + const pinned=restrictedCompatibility(pinnedBashExecutable({file:process.env.DSH_OPS_TEST_BASH})) assert.equal(pinned.compatible,true,pinned.reason) } await withTempDir('no-msys',async root=>{ @@ -66,7 +67,7 @@ await test('a tampered cached generation is rejected, quarantined and rebuilt', if(process.platform!=='win32')return const receipt=path.resolve('runtime/win32-x64/bash-ipc/provenance.json') if(!fs.existsSync(receipt))return - const source=path.resolve('node_modules/@dsh-ops/bash-win32-x64') + const source=path.resolve(path.dirname(pinnedBashExecutable({file:process.env.DSH_OPS_TEST_BASH})),'..') const bash=path.join(source,'usr/bin/bash.exe') if(!fs.existsSync(bash))return await withTempDir('bash-ipc-tamper',async home=>{ diff --git a/test/bundle-startup.test.mjs b/test/bundle-startup.test.mjs index b4522f6b..87351e6b 100644 --- a/test/bundle-startup.test.mjs +++ b/test/bundle-startup.test.mjs @@ -33,7 +33,9 @@ for (const mode of ['read-only', 'workspace-write', 'danger-full-access']) { for (const row of rows.filter(row => !row.disabled)) { const component = row.name.split('/')[1] const module = await import(`../lib/components/${component}/index.js`) - await ctx.plugin(module, row.config) + const config = component === 'shell' && process.env.DSH_OPS_TEST_BASH + ? { ...row.config, bashPath: process.env.DSH_OPS_TEST_BASH } : row.config + await ctx.plugin(module, config) } for (const presentation of ['native', 'ptc', 'both']) { const agent = { id: `${mode}-${presentation}`, session: { id: `${mode}-${presentation}`, mode, header: { cwd: root } } } @@ -65,4 +67,28 @@ for (const mode of ['read-only', 'workspace-write', 'danger-full-access']) { }) }) } +await test('a missing optional Bash leaves the file bundle available', async () => { + await withTempDir('bundle-no-bash', async root => { + const { ctx } = await bootHost() + try { + await ctx.plugin(Subprocess) + await ctx.plugin(Sandbox, {}) + await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: ({ session }) => ({ mode: session.mode, workspaceRoot: root, sessionId: session.id }) })) + for (const row of rows.filter(row => !row.disabled)) { + const component = row.name.split('/')[1] + const module = await import('../lib/components/' + component + '/index.js') + await ctx.plugin(module, component === 'shell' ? { ...row.config, bashPath: path.join(root, 'absent-bash.exe') } : row.config) + } + const agent = { id: 'no-bash', session: { id: 'no-bash', mode: 'workspace-write', header: { cwd: root } } } + agent.ctx = createScope(ctx, agent).ctx + await ctx.serial('agent/created', { agent, source: 'startup' }) + const names = () => ctx.get('tools').schemas(agent).map(tool => tool.name) + await waitFor(() => names().includes('ops_stat'), { what: 'file publication without optional Bash' }) + assert.equal(names().includes('ops_bash'), false) + const result = await ctx.get('tools').get('ops_stat', agent).execute({ path: path.join(root, 'absent') }, { agent, signal: new AbortController().signal }) + assert.equal(result.status, 'completed', JSON.stringify(result)) + assert.equal(result.exists, false) + } finally { await shutdown(ctx) } + }) +}) report('bundle-startup') diff --git a/test/mount.test.mjs b/test/mount.test.mjs index af081a52..576ebbd0 100644 --- a/test/mount.test.mjs +++ b/test/mount.test.mjs @@ -233,12 +233,12 @@ await test('bash is scoped to authorized sessions, responds to mode changes, and await ctx.serial('session/event', full.session, { type: 'sandbox/mode' }) await visible(ctx, full, [BASH_TOOL]) if (process.platform === 'win32') { - const restrictedResult = await definition.execute({ command: 'echo no' }, { agent: full, signal: new AbortController().signal }) + const restrictedResult = await definition.execute({ command: 'echo no', description: 'Verify the current scoped shell authority' }, { agent: full, signal: new AbortController().signal }) assert.equal(restrictedResult.status, 'failed') assert.match(restrictedResult.error, /EXECUTOR_UNAVAILABLE/) assert.equal(restrictedResult.permission, undefined) } else { - const value = await definition.execute({ command: 'echo no' }, { agent: full, signal: new AbortController().signal }) + const value = await definition.execute({ command: 'echo no', description: 'Verify the current scoped shell authority' }, { agent: full, signal: new AbortController().signal }) assert.equal(value.sandbox.mode, 'read-only') assert.match(value.error, /fixture does not execute/) } diff --git a/test/permissions.test.mjs b/test/permissions.test.mjs index 6b4801b8..5e11f2a7 100644 --- a/test/permissions.test.mjs +++ b/test/permissions.test.mjs @@ -349,7 +349,7 @@ await test('owned Modify-only workspace starts real DSH sandbox; three modes and assert.equal(noAudit.error.code,'POLICY_DENIED') const page=await ptcCall(ctx,worker,'result',{root,action:'page',id:batch.detailsRef},exec,exec.signal) assert.equal(page.items.length,1) - const shells=resolveShells({allowSystemShellFallback:true}) + const shells=resolveShells({allowSystemShellFallback:true,...(process.env.DSH_OPS_TEST_BASH?{bashPath:process.env.DSH_OPS_TEST_BASH}:{})}) assert.ok(shells.bash.available,'Real bash required for permission matrix') await ctx.plugin({inject:['tools'],apply(inner){mountSessionBash(inner,{publishBashTool:true},shells)}}) await ctx.serial('agent/created',{agent}) diff --git a/test/run.mjs b/test/run.mjs index 19efd0f3..470cbb49 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -51,6 +51,9 @@ function suites() { return selected.map((name) => path.join(testDir, name)) } +// Test homes stay isolated; only the explicitly acquired executable is shared read-only. +const bashFixture = process.platform === 'win32' + ? (await import('../lib/shells/pinned.js')).pinnedBashExecutable({ file: process.env.DSH_OPS_TEST_BASH, required: false }) : undefined const selected = suites() if (selected.length === 0) { console.error('dsh-ops test: no suites matched') @@ -102,7 +105,7 @@ async function runSuite(suite) { console.log(`\n=== ${label}`) const child = spawn(process.execPath, [suite], { cwd: root, - env: { ...process.env, DSH_HOME: home }, + env: { ...process.env, DSH_HOME: home, ...(bashFixture ? { DSH_OPS_TEST_BASH: bashFixture } : {}) }, stdio: 'inherit', }) let timedOut = false diff --git a/test/shells-manager.test.mjs b/test/shells-manager.test.mjs new file mode 100644 index 00000000..4fd66e16 --- /dev/null +++ b/test/shells-manager.test.mjs @@ -0,0 +1,94 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { managedBashInventory, removeManagedBash, selectBashRuntime, gitBashCandidates } from '../lib/shells/manager.js' +import { pinnedBashExecutable } from '../lib/shells/pinned.js' +import { assert, report, test, withTempDir, PACKAGE_ROOT } from './lib/harness.mjs' + +function seed(home, version, owned = true) { + const directory = path.join(home, 'dsh-ops/shells/bash', version) + fs.mkdirSync(path.join(directory, 'bin'), { recursive: true }) + fs.writeFileSync(path.join(directory, 'bin/bash.exe'), 'fixture') + if (owned) fs.writeFileSync(path.join(directory, '.provisioned.json'), JSON.stringify({ schemaVersion: 1, name: 'bash', version, sha256: 'a'.repeat(64) })) + return directory +} + +await test('managed removal previews, honors dry-run, and deletes only receipt-marked Bash versions', async () => { + await withTempDir('manager-remove', home => { + const owned = seed(home, '1.0'), other = seed(home, 'unowned', false) + const pwsh = path.join(home, 'dsh-ops/shells/pwsh/keep.txt') + fs.mkdirSync(path.dirname(pwsh), { recursive: true }); fs.writeFileSync(pwsh, 'keep') + const env = { DSH_HOME: home } + assert.equal(removeManagedBash({ env }).action, 'preview'); assert.equal(fs.existsSync(owned), true) + assert.equal(removeManagedBash({ env, yes: true, dryRun: true }).action, 'preview'); assert.equal(fs.existsSync(owned), true) + const result = removeManagedBash({ env, yes: true }) + assert.equal(result.action, 'removed'); assert.equal(result.versions.length, 1); assert.equal(result.kept.length, 1) + assert.equal(fs.existsSync(owned), false); assert.equal(fs.existsSync(other), true); assert.equal(fs.readFileSync(pwsh, 'utf8'), 'keep') + }) +}) + +await test('a linked managed ancestor is refused before any removal', async () => { + await withTempDir('manager-links', root => { + const home = path.join(root, 'home'), outside = path.join(root, 'outside') + fs.mkdirSync(home); fs.mkdirSync(path.join(outside, 'shells/bash/1.0'), { recursive: true }) + fs.writeFileSync(path.join(outside, 'shells/bash/1.0/keep'), 'keep') + fs.symlinkSync(outside, path.join(home, 'dsh-ops'), process.platform === 'win32' ? 'junction' : 'dir') + assert.throws(() => removeManagedBash({ env: { DSH_HOME: home }, yes: true }), /must not traverse links/) + assert.equal(fs.readFileSync(path.join(outside, 'shells/bash/1.0/keep'), 'utf8'), 'keep') + }) +}) + +await test('Git selection prints an absolute config and never chooses a PATH WSL launcher', async () => { + await withTempDir('manager-git', root => { + const file = path.join(root, 'programs/Git/bin/bash.exe'), wsl = path.join(root, 'system/System32/bash.exe') + for (const target of [file, wsl]) { fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, 'fixture') } + const env = { ProgramFiles: path.join(root, 'programs'), SystemRoot: path.join(root, 'system'), PATH: path.dirname(wsl), DSH_HOME: root } + assert.deepEqual(gitBashCandidates({ env, platform: 'win32' }).filter(value => value.startsWith(root)), [file]) + const selected = selectBashRuntime('git', { env, platform: 'win32' }) + assert.equal(selected.config.bashPath, file); assert.equal(selected.restricted.compatible, false) + assert.equal(fs.existsSync(path.join(root, 'config.json')), false) + assert.deepEqual(gitBashCandidates({ env, platform: 'linux' }), []) + }) +}) + +await test('managed selection retains the provisioned store layout and absence is actionable', async () => { + await withTempDir('manager-select', home => { + assert.deepEqual(managedBashInventory({ env: { DSH_HOME: home } }), []) + assert.throws(() => selectBashRuntime('managed', { env: { DSH_HOME: home }, platform: 'win32', arch: 'x64' }), /bash-runtime install/) + seed(home, '9.0', false) + const directory = seed(home, '2.0') + assert.equal(selectBashRuntime('managed', { env: { DSH_HOME: home }, platform: 'win32', arch: 'x64' }).file, path.join(directory, 'bin/bash.exe')) + }) +}) + +await test('offline build inputs require an absolute pinned executable digest', async () => { + await withTempDir('manager-pin', root => { + const file = path.join(root, 'bash.exe'); fs.writeFileSync(file, 'wrong bytes') + assert.throws(() => pinnedBashExecutable({ file: 'bash.exe' }), /absolute executable path/) + assert.throws(() => pinnedBashExecutable({ file }), /pinned executable SHA-256/) + }) +}) + +await test('the real CLI previews removal and applies it only with yes', async () => { + await withTempDir('manager-cli', home => { + const directory = seed(home, '1.0') + const call = args => spawnSync(process.execPath, [path.join(PACKAGE_ROOT, 'bin/dsh-ops.mjs'), 'bash-runtime', ...args], { encoding: 'utf8', env: { ...process.env, DSH_HOME: home } }) + const preview = call(['remove']); assert.equal(preview.status, 0, preview.stderr); assert.match(preview.stdout, /"action": "preview"/); assert.equal(fs.existsSync(directory), true) + const removed = call(['remove', '--yes']); assert.equal(removed.status, 0, removed.stderr); assert.equal(fs.existsSync(directory), false) + assert.notEqual(call(['invalid']).status, 0) + const install = call(['install', '--dry-run']) + assert.equal(install.status, process.platform === 'win32' ? 0 : 1, install.stderr) + if (process.platform === 'win32') assert.match(install.stdout, /dry run: no download, no writes/) + }) +}) + +await test('published dependency and release package plans do not acquire Bash automatically', async () => { + const manifest = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')) + const lock = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package-lock.json'), 'utf8')) + assert.equal(manifest.optionalDependencies?.['@dsh-ops/bash-win32-x64'], undefined) + assert.equal(lock.packages['node_modules/@dsh-ops/bash-win32-x64'], undefined) + const { describePackages } = await import('../scripts/lib/build/assemble.js') + assert.equal(describePackages('win32', 'x64', 'unused').some(item => item.kind === 'bash'), false) +}) + +report('shells-manager') diff --git a/test/shells-session.test.mjs b/test/shells-session.test.mjs index 98607d4e..94db89f0 100644 --- a/test/shells-session.test.mjs +++ b/test/shells-session.test.mjs @@ -22,7 +22,7 @@ import { hostForeground } from '../lib/shells/host-contract.js' import { BASH_ARGUMENT_ERROR_CODE } from '../lib/shells/argument-error.js' import { bashRefusalValue, mountSessionBash } from '../lib/session-shells.js' -const bash = resolveShells({}).bash +const bash = resolveShells(process.env.DSH_OPS_TEST_BASH ? { bashPath: process.env.DSH_OPS_TEST_BASH } : {}).bash if (!bash.available) { skipSuite('shells-session', 'this suite needs a real bash to run a command through the session layer: ' + bash.detail) } From a29a604856166fe51360be98db5cb18c07eda574 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:48:26 +0800 Subject: [PATCH 039/118] refactor(shell): type host execution boundaries --- docs/CHANGELOG.md | 6 ++ docs/CONTRIBUTING.md | 4 +- ...41\347\214\256\346\214\207\345\215\227.md" | 2 +- src/bash-ipc.js | 2 + src/shells/{approval.js => approval.ts} | 12 ++- src/shells/{execution.js => execution.ts} | 38 ++++--- .../{host-contract.js => host-contract.ts} | 14 +-- src/shells/{host-tool.js => host-tool.ts} | 36 ++++--- src/shells/{jobs.js => jobs.ts} | 24 +++-- ...sion-execution.js => session-execution.ts} | 18 ++-- src/shells/tool.js | 12 +-- src/shells/types.ts | 100 ++++++++++++++++++ test/shells-execution.test.mjs | 9 ++ test/shells-host.test.mjs | 7 ++ 14 files changed, 215 insertions(+), 69 deletions(-) rename src/shells/{approval.js => approval.ts} (75%) rename src/shells/{execution.js => execution.ts} (75%) rename src/shells/{host-contract.js => host-contract.ts} (75%) rename src/shells/{host-tool.js => host-tool.ts} (64%) rename src/shells/{jobs.js => jobs.ts} (70%) rename src/shells/{session-execution.js => session-execution.ts} (77%) create mode 100644 src/shells/types.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3a2fcc53..fb04a692 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,12 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Type the shell execution and host boundary (#261) + +- Migrate the new shell executor, host contract, approvals, jobs and session integration to strict + TypeScript. Validate the published definition against the pinned SDK with type-only imports; + retain existing emitted paths and explicit types for the remaining JavaScript adapters. + ### Make Windows Bash an explicit optional runtime (#261) - Add Bash status/install/remove and Git/managed selection commands. Removal previews by default, diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index ed5961f7..34f25728 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -65,7 +65,9 @@ be edited by hand. Package exports, component entries and tests continue to load Run `npm run build` after changing source or assets and before invoking a suite directly with `node test/run.mjs `. `npm test`, `npm run test:ptc` and `npm run verify` build first; `npm run typecheck` checks the compiler configuration and migrated TypeScript without emitting -files. JavaScript type checking remains disabled during the migration. +files. JavaScript type checking remains disabled during the migration. The shell execution handle, +host contract, approvals, jobs, session executor and runtime manager use strict TypeScript; older +resolver, IPC and mount modules remain JavaScript with typed boundaries while their migration is staged. For a local or Git-source installation, build and pack a checkout before installing it into DSH: diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 1bd9a077..827c6db1 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -49,7 +49,7 @@ npm run verify 插件源码在 `src/` 中修改。迁移期间 TypeScript 编译器兼容现有 JavaScript,并将 JavaScript 产物输出到 `lib/`;`npm run build` 会重建该目录,并复制组件 manifest、语言文件、图标和面板 HTML。`lib/` 已被 Git 忽略,不得手动修改。包导出、组件入口和测试仍然加载 `lib/`。 -修改源码或资源后,先运行 `npm run build`,再通过 `node test/run.mjs <过滤词>` 直接运行套件。`npm test`、`npm run test:ptc` 与 `npm run verify` 会先构建;`npm run typecheck` 检查编译器配置和已迁移的 TypeScript,不生成产物。迁移期间暂不启用 JavaScript 类型检查。 +修改源码或资源后,先运行 `npm run build`,再通过 `node test/run.mjs <过滤词>` 直接运行套件。`npm test`、`npm run test:ptc` 与 `npm run verify` 会先构建;`npm run typecheck` 检查编译器配置和已迁移的 TypeScript,不生成产物。迁移期间暂不启用 JavaScript 类型检查。Shell 执行句柄、宿主契约、审批、jobs、会话执行器和运行时管理器已使用严格 TypeScript;旧解析器、IPC 和挂载模块暂保留 JavaScript,并通过类型化边界分阶段迁移。 从本地目录或 Git 源码安装时,先在检出的仓库中构建并打包,再安装到 DSH: diff --git a/src/bash-ipc.js b/src/bash-ipc.js index a25d058b..1293fbe0 100644 --- a/src/bash-ipc.js +++ b/src/bash-ipc.js @@ -121,6 +121,8 @@ function sourceState(root, dll){ sourceMemo.set(root,state) return state } +/** @param {string} file + * @param {{home?: string, platform?: NodeJS.Platform, source?: string}} options */ export function confinedBash(file, {home=process.env.DSH_HOME ?? path.join(os.homedir(),'.dsh'), platform=process.platform, source}={}) { if(platform!=='win32')return {file} const root=msysRoot(file) diff --git a/src/shells/approval.js b/src/shells/approval.ts similarity index 75% rename from src/shells/approval.js rename to src/shells/approval.ts index 4c15084f..5293c525 100644 --- a/src/shells/approval.js +++ b/src/shells/approval.ts @@ -1,14 +1,16 @@ // Adapted from @deepseek-ai/dsh-tool-bash 0.2.0-rc.2 (MIT). See licenses/deepseek-harness/LICENSE. /** Per-call widening follows the pinned host approval vocabulary; grants are never cached. */ +import type { ShellContext, ShellCall, ShellPolicy } from './types.js' import { BashArgumentError } from './argument-error.js' import { BASH_TOOL_NAME } from './constants.js' -const modes = ['read-only', 'workspace-write', 'danger-full-access'] +const modes: ShellPolicy['mode'][] = ['read-only', 'workspace-write', 'danger-full-access'] +type EscalationArgs = Record & { sandbox_permissions?: 'workspace-write' | 'danger-full-access'; justification?: string } -export function validateBashEscalation(args, standing) { +export function validateBashEscalation(args: Record, standing: ShellPolicy): asserts args is EscalationArgs { const target = args.sandbox_permissions const reason = args.justification - if (target !== undefined && !modes.slice(1).includes(target)) { + if (target !== undefined && target !== 'workspace-write' && target !== 'danger-full-access') { throw new BashArgumentError('invalid sandbox_permissions: expected workspace-write or danger-full-access', 'sandbox_permissions') } if (reason !== undefined && typeof reason !== 'string') throw new BashArgumentError('invalid justification: expected a string', 'justification') @@ -21,13 +23,13 @@ export function validateBashEscalation(args, standing) { } } -export async function approveBashPolicy(ctx, args, exec, standing) { +export async function approveBashPolicy(ctx: ShellContext, args: Record, exec: ShellCall, standing: ShellPolicy): Promise { validateBashEscalation(args, standing) if (args.sandbox_permissions === undefined || args.sandbox_permissions === standing.mode) return standing const approval = ctx.get('approval') if (!exec?.agent || !approval?.request) throw new Error('APPROVAL_UNAVAILABLE: no command executed; approval is unavailable. Do not bypass the session policy.') const outcome = await approval.request({ agent: exec.agent, callId: exec.callId, toolName: BASH_TOOL_NAME, - reason: args.justification, signal: exec.signal }) + reason: args.justification!, signal: exec.signal }) if (outcome !== 'allowed-once') throw new Error(`APPROVAL_${String(outcome).toUpperCase().replaceAll('-', '_')}: no command executed; the requested wider mode was not granted. Do not retry or bypass this decision.`) exec.signal?.throwIfAborted() const current = ctx.get('sandboxPolicy')?.resolve({ session: exec.agent.session }) diff --git a/src/shells/execution.js b/src/shells/execution.ts similarity index 75% rename from src/shells/execution.js rename to src/shells/execution.ts index bcdd998e..a2becca3 100644 --- a/src/shells/execution.js +++ b/src/shells/execution.ts @@ -1,12 +1,14 @@ /** Live Bash command over the host subprocess service; no service registration. */ import { Buffer } from 'node:buffer' +import type { SubprocessHandle, SubprocessOutputReader, SubprocessOutputRead } from '@deepseek-ai/dsh-subprocess' +import type { ShellExecutorOptions, ShellExecutionSpec, ShellCall, ShellExecution, ShellResult, ShellStream } from './types.js' import { DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_MAX_SPILL_BYTES, shellEnvOverrides } from './constants.js' import { formatBashSpawnError } from './spawn-error.js' -const emptyRead = () => ({ text: '', nextOffset: 0, lossy: false }) +const emptyRead = (): SubprocessOutputRead => ({ text: '', nextOffset: 0, lossy: false }) /** Read the retained stream after settlement, preserving honest truncation facts. */ -export function settledStream(reader, includeMetadata = false) { +export function settledStream(reader: SubprocessOutputReader | undefined, includeMetadata = false): ShellStream { const read = reader?.readFrom?.(0) ?? emptyRead() const text = typeof read.text === 'string' ? read.text : '' return { @@ -26,33 +28,33 @@ export function settledStream(reader, includeMetadata = false) { * process containment, collection and disposal. A job can observe output without consuming it. * `onExpiry: none` disables only this executor's deadline, never caller cancellation. */ -export function startBashExecution({ file, source, subprocess, prepareArgv, env = {}, projectResult }, spec, exec = {}) { +export function startBashExecution({ file, source, subprocess, prepareArgv, env = {}, projectResult }: ShellExecutorOptions, spec: ShellExecutionSpec, exec: ShellCall = {}): ShellExecution { const controller = new AbortController() const callerSignal = exec.signal const startedAt = performance.now() - let cause - let handle - let timer - const stop = (kind, reason) => { + let cause: 'cancelled' | 'timeout' | undefined + let handle: SubprocessHandle | undefined + let timer: ReturnType | undefined + const stop = (kind: 'cancelled' | 'timeout', reason: unknown) => { if (controller.signal.aborted) return cause = kind controller.abort(reason) } - const onCallerAbort = () => stop('cancelled', callerSignal.reason) + const onCallerAbort = () => stop('cancelled', callerSignal?.reason) if (callerSignal?.aborted) onCallerAbort() else callerSignal?.addEventListener('abort', onCallerAbort, { once: true }) if (spec.onExpiry !== 'none' && !controller.signal.aborted) { timer = setTimeout(() => stop('timeout', new Error(`command timed out after ${spec.timeoutMs}ms`)), spec.timeoutMs) timer.unref?.() } - const reader = stream => ({ readFrom: offset => handle?.collected?.[stream]?.readFrom(offset) ?? { ...emptyRead(), nextOffset: offset } }) - const process = { + const reader = (stream: 'stdout' | 'stderr'): SubprocessOutputReader => ({ readFrom: offset => handle?.collected?.[stream]?.readFrom(offset) ?? { ...emptyRead(), nextOffset: offset } }) + const process: ShellExecution = { status: 'running', get started() { return handle !== undefined }, observed: { stdout: reader('stdout'), stderr: reader('stderr') }, kill: reason => stop('cancelled', new Error(reason ?? 'bash execution cancelled')), result: () => result, - done: undefined, + done: Promise.resolve(), } const flags = () => ({ timedOut: cause === 'timeout', @@ -63,16 +65,16 @@ export function startBashExecution({ file, source, subprocess, prepareArgv, env executor: { kind: 'subprocess', source }, } } : {}), }) - const run = async () => { - let value + const run = async (): Promise => { + let value: ShellResult try { controller.signal.throwIfAborted() - const argv = spec.command === undefined ? [file, spec.scriptPath] : [file, '-c', spec.command] + const argv = spec.command === undefined ? [file, spec.scriptPath!] : [file, '-c', spec.command] // Race preparation so an unavailable provider cannot strand a cancelled job. let preparedArgv = argv if (prepareArgv) { - let abort - const cancelled = new Promise((_, reject) => { + let abort!: () => void + const cancelled = new Promise((_, reject) => { abort = () => reject(controller.signal.reason) controller.signal.addEventListener('abort', abort, { once: true }) }) @@ -80,9 +82,11 @@ export function startBashExecution({ file, source, subprocess, prepareArgv, env finally { controller.signal.removeEventListener('abort', abort) } } controller.signal.throwIfAborted() + if (!subprocess) throw new Error('Host subprocess service is unavailable') handle = subprocess.spawn({ argv: preparedArgv, - cwd: spec.workdir, + // Legacy direct executor callers can omit cwd; scoped host calls resolve it before this seam. + cwd: spec.workdir!, stdio: { stdin: 'ignore', stdout: { maxBytes: DEFAULT_MAX_OUTPUT_BYTES, spill: { maxBytes: DEFAULT_MAX_SPILL_BYTES } }, diff --git a/src/shells/host-contract.js b/src/shells/host-contract.ts similarity index 75% rename from src/shells/host-contract.js rename to src/shells/host-contract.ts index af1d09de..808b5068 100644 --- a/src/shells/host-contract.js +++ b/src/shells/host-contract.ts @@ -1,9 +1,11 @@ // Adapted from @deepseek-ai/dsh-tool-bash 0.2.0-rc.2 (MIT). See licenses/deepseek-harness/LICENSE. /** DSH 0.2.0-rc.2 shell DTO and rendering, with explicit OPS compatibility fields. */ +import type { JsonSchemaNode, JsonSchemaType } from '@deepseek-ai/dsh-tools' +import type { ShellResult, HostForeground, HostBashResult } from './types.js' import { BASH_OUTPUT_SCHEMA, renderBashResult, withBashStatus } from './tool.js' -const nullable = type => ({ oneOf: [{ type }, { type: 'null' }] }) -export const HOST_BASH_OUTPUT_SCHEMA = { +const nullable = (type: JsonSchemaType): JsonSchemaNode => ({ oneOf: [{ type }, { type: 'null' }] }) +export const HOST_BASH_OUTPUT_SCHEMA: JsonSchemaNode = { oneOf: [ { type: 'object', additionalProperties: false, required: ['kind', 'jobId'], properties: { kind: { type: 'string', const: 'background' }, jobId: { type: 'string' }, @@ -11,7 +13,7 @@ export const HOST_BASH_OUTPUT_SCHEMA = { { type: 'object', additionalProperties: false, required: ['kind', 'jobId', 'timeoutMs', 'output'], properties: { kind: { type: 'string', const: 'promoted' }, jobId: { type: 'string' }, timeoutMs: { type: 'number' }, output: { type: 'string' }, } }, - { ...BASH_OUTPUT_SCHEMA, required: [...BASH_OUTPUT_SCHEMA.required, 'kind', 'exitCode', 'signal', 'aborted'], properties: { + { ...BASH_OUTPUT_SCHEMA, required: [...(BASH_OUTPUT_SCHEMA.required ?? []), 'kind', 'exitCode', 'signal', 'aborted'], properties: { ...BASH_OUTPUT_SCHEMA.properties, kind: { type: 'string', const: 'foreground' }, exitCode: nullable('integer'), signal: nullable('string'), aborted: { type: 'boolean' }, stopped: { type: 'string' }, @@ -19,17 +21,17 @@ export const HOST_BASH_OUTPUT_SCHEMA = { ], } -export function hostForeground(value) { +export function hostForeground(value: ShellResult): HostForeground { return { ...withBashStatus(value), kind: 'foreground', exitCode: value.exitCode ?? null, signal: value.signal ?? null, aborted: value.cancelled === true } } -export function hostFailure(message, timeoutMs = 120_000) { +export function hostFailure(message: string, timeoutMs = 120_000) { return hostForeground({ error: message, timedOut: false, timeoutMs, stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false } }) } -export function renderHostBash(value) { +export function renderHostBash(value: HostBashResult): string { if (value.kind === 'background') return `started background job ${value.jobId}` if (value.kind === 'promoted') return `${value.output || '(no output)'}\n[still running after ${value.timeoutMs}ms; moved to background job ${value.jobId}]\nUse job_output to collect output and job_kill to stop the command.` const { exitCode, signal, ...rest } = value diff --git a/src/shells/host-tool.js b/src/shells/host-tool.ts similarity index 64% rename from src/shells/host-tool.js rename to src/shells/host-tool.ts index 5ddcca8a..11d9af1e 100644 --- a/src/shells/host-tool.js +++ b/src/shells/host-tool.ts @@ -1,5 +1,9 @@ // Adapted from @deepseek-ai/dsh-tool-bash 0.2.0-rc.2 (MIT). See licenses/deepseek-harness/LICENSE. /** Host-compatible consumer over an OPS-owned executor; host services stay scoped and borrowed. */ +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import type { ShellContext, ShellBase, ShellCall, ShellPolicy, CreateShellExecution, StartShell, HostBashResult, HostShellDefinition } from './types.js' +import { errorMessage } from './types.js' import { BashArgumentError, isBashArgumentError } from './argument-error.js' import { approveBashPolicy } from './approval.js' import { HOST_BASH_OUTPUT_SCHEMA, hostFailure, hostForeground, renderHostBash } from './host-contract.js' @@ -7,9 +11,10 @@ import { startBashJob, waitOnBashJob } from './jobs.js' export const HOST_MAX_BASH_TIMEOUT_MS = 600_000 -export function hostBashDefinition(ctx, base, createExecution, resolveWorkdir) { +export function hostBashDefinition(ctx: ShellContext, base: ShellBase, createExecution: CreateShellExecution, + resolveWorkdir: (args: Record, session: NonNullable['session'] | undefined) => unknown): HostShellDefinition { const jobs = ctx.get('jobs') - const parameters = { ...base.parameters, required: ['description'], properties: { + const parameters: JsonSchemaNode & { properties: Record } = { ...base.parameters, required: ['description'], properties: { ...base.parameters.properties, description: { type: 'string', description: 'Required: clear active-voice description of this command, 5–10 words.' }, timeoutMs: { type: 'number', description: 'Defaults to 120000 ms; capped at 600000 ms. With host jobs, expiry hands the running command to job_output/job_kill; otherwise it stops the command.' }, @@ -20,18 +25,20 @@ export function hostBashDefinition(ctx, base, createExecution, resolveWorkdir) { return { ...base, description: 'Execute a Bash command in a fresh shell using the host sandbox, approvals and jobs. Relative workdir uses the current session workspace. Output is bounded with spill paths. Use command for host-compatible calls; script_path is an OPS extension for an existing script file. Verify resolved absolute targets before delete or move. Never bypass a sandbox denial by switching shells.', - parameters, - output: { schema: HOST_BASH_OUTPUT_SCHEMA, render: (_args, value) => [{ type: 'text', text: renderHostBash(value) }] }, - async execute(args, exec = {}) { + parameters: { ...parameters }, + // The host validates this value against the declared schema before invoking its renderer. + output: { schema: HOST_BASH_OUTPUT_SCHEMA, render: (_args, value) => [{ type: 'text', text: renderHostBash(value as unknown as HostBashResult) }] }, + async execute(input: unknown, exec: ShellCall = {}): Promise { let timeoutMs = 120_000 - let policy + let policy: ShellPolicy | undefined try { - if (!args || typeof args !== 'object' || Array.isArray(args)) throw new BashArgumentError('invalid arguments: expected an object', 'arguments') + if (!input || typeof input !== 'object' || Array.isArray(input)) throw new BashArgumentError('invalid arguments: expected an object', 'arguments') + const args = input as Record const unknown = Object.keys(args).find(key => !Object.hasOwn(parameters.properties, key)) if (unknown !== undefined) throw new BashArgumentError(`invalid arguments: unknown key ${JSON.stringify(unknown)}; this tool accepts ${Object.keys(parameters.properties).join(', ')}`, unknown) if (args.run_in_background !== undefined && typeof args.run_in_background !== 'boolean') throw new BashArgumentError('invalid run_in_background: expected a boolean', 'run_in_background') const { run_in_background, sandbox_permissions, justification, ...local } = args - if (local.timeoutMs !== undefined && (!Number.isFinite(local.timeoutMs) || local.timeoutMs <= 0)) throw new BashArgumentError('invalid timeoutMs: expected a positive finite number', 'timeoutMs') + if (local.timeoutMs !== undefined && (typeof local.timeoutMs !== 'number' || !Number.isFinite(local.timeoutMs) || local.timeoutMs <= 0)) throw new BashArgumentError('invalid timeoutMs: expected a positive finite number', 'timeoutMs') timeoutMs = Math.min(local.timeoutMs ?? 120_000, HOST_MAX_BASH_TIMEOUT_MS) local.timeoutMs = timeoutMs local.workdir = resolveWorkdir(local, exec.agent?.session) @@ -40,25 +47,26 @@ export function hostBashDefinition(ctx, base, createExecution, resolveWorkdir) { if (typeof args.description !== 'string' || !args.description.trim()) throw new BashArgumentError('invalid description: expected a non-empty string', 'description') if (!exec.agent?.session?.id) return hostFailure('EXECUTOR_UNAVAILABLE: an authoritative session is required.', timeoutMs) const standing = ctx.get('sandboxPolicy')?.resolve({ session: exec.agent.session }) - if (!['read-only', 'workspace-write', 'danger-full-access'].includes(standing?.mode)) return hostFailure('EXECUTOR_UNAVAILABLE: authoritative session policy is unavailable.', timeoutMs) + if (!standing || !['read-only', 'workspace-write', 'danger-full-access'].includes(standing.mode)) return hostFailure('EXECUTOR_UNAVAILABLE: authoritative session policy is unavailable.', timeoutMs) policy = await approveBashPolicy(ctx, args, exec, standing) exec.signal?.throwIfAborted() const dshEnv = ctx.get('shellEnv')?.collect(exec) ?? {} - const start = (execution, options) => createExecution(local, execution, standing, policy, dshEnv, options) + const approvedPolicy = policy + const start: StartShell = (execution, options) => createExecution(local, execution, standing, approvedPolicy, dshEnv, options) if (jobs && ctx.get('jobs') === jobs) { let attached - try { attached = startBashJob(jobs, args, exec, start) } + try { attached = startBashJob(jobs, args as { command?: string; script_path?: string }, exec, start) } catch (error) { if (run_in_background) throw error - ctx.logger?.warn?.(`Bash job admission refused; using the foreground deadline: ${error.message}`) + ctx.logger?.warn?.(`Bash job admission refused; using the foreground deadline: ${errorMessage(error)}`) } if (attached) return run_in_background ? { kind: 'background', jobId: attached.id } : await waitOnBashJob(jobs, attached, exec, timeoutMs) } else if (run_in_background) return hostFailure('EXECUTOR_UNAVAILABLE: the host job registry is unavailable.', timeoutMs) return hostForeground(await (await start(exec, {})).result()) } catch (error) { if (isBashArgumentError(error)) throw error - return { ...hostFailure(String(error?.message ?? error), timeoutMs), ...(exec.signal?.aborted ? { aborted: true, cancelled: true } : {}), ...(policy ? { sandbox: { mode: policy.mode, denied: false } } : {}) } + return { ...hostFailure(errorMessage(error), timeoutMs), ...(exec.signal?.aborted ? { aborted: true, cancelled: true } : {}), ...(policy ? { sandbox: { mode: policy.mode, denied: false } } : {}) } } }, - } + } satisfies ToolDefinition } diff --git a/src/shells/jobs.js b/src/shells/jobs.ts similarity index 70% rename from src/shells/jobs.js rename to src/shells/jobs.ts index 6831a99c..b0b5e5ce 100644 --- a/src/shells/jobs.js +++ b/src/shells/jobs.ts @@ -1,17 +1,19 @@ // Adapted from @deepseek-ai/dsh-tool-bash 0.2.0-rc.2 (MIT). See licenses/deepseek-harness/LICENSE. /** Adapt one OPS execution to the host job registry without replacing its lifecycle. */ +import type { ShellJobs, ShellCall, StartShell, ShellExecution, ShellJobView, HostBashResult } from './types.js' +import { errorMessage } from './types.js' import { hostForeground } from './host-contract.js' -const running = view => ['running', 'stopping'].includes(view.status) +const running = (view: ShellJobView): boolean => ['running', 'stopping'].includes(view.status) -export function startBashJob(jobs, args, exec, start) { - let process - let stopped - const output = ['stdout', 'stderr'].map(stream => ({ +export function startBashJob(jobs: ShellJobs, args: { command?: string; script_path?: string }, exec: ShellCall, start: StartShell) { + let process: ShellExecution | undefined + let stopped: string | undefined + const output = (['stdout', 'stderr'] as const).map(stream => ({ channel: stream, - read: offset => process?.observed[stream].readFrom(offset) ?? { text: '', nextOffset: offset, lossy: false }, + read: (offset: number) => process?.observed[stream].readFrom(offset) ?? { text: '', nextOffset: offset, lossy: false }, })) - const id = jobs.start({ kind: 'bash', label: args.command ?? args.script_path, + const id = jobs.start({ kind: 'bash', label: args.command ?? args.script_path ?? 'Bash', ...(exec.agent ? { owner: exec.agent.id } : {}), output, run: () => { const cancellation = new AbortController() @@ -20,21 +22,21 @@ export function startBashJob(jobs, args, exec, start) { process = await start({ ...exec, signal: cancellation.signal }, { onExpiry: 'none' }) if (cancellation.signal.aborted) process.kill(stopped) const value = await process.result() - const state = value.cancelled || value.signal || value.timedOut ? 'killed' : value.error ? 'failed' : 'completed' + const state: 'killed' | 'failed' | 'completed' = value.cancelled || value.signal || value.timedOut ? 'killed' : value.error ? 'failed' : 'completed' const detail = value.error ?? (value.signal ? `signal: ${value.signal}` : `exit code: ${value.exitCode ?? 0}`) const sandbox = value.sandbox?.runnerFailed ? '; sandbox runner failed before the command ran' : value.sandbox?.denied ? `; sandbox denied file access under ${value.sandbox.mode} mode` : '' return { status: state, detail: detail + sandbox } - }).catch(error => ({ status: cancellation.signal.aborted ? 'killed' : 'failed', detail: String(error?.message ?? error) })) + }).catch((error: unknown): { status: 'killed' | 'failed'; detail: string } => ({ status: cancellation.signal.aborted ? 'killed' : 'failed', detail: errorMessage(error) })) return { done, cancel: reason => { stopped = reason; cancellation.abort(new Error(reason)); process?.kill(reason) } } }, }) return { id, process: () => process, stopped: () => stopped } } -export async function waitOnBashJob(jobs, attached, exec, timeoutMs) { +export async function waitOnBashJob(jobs: ShellJobs, attached: ReturnType, exec: ShellCall, timeoutMs: number): Promise { const owner = exec.agent?.id - const stop = async reason => { + const stop = async (reason: string) => { jobs.kill(attached.id, owner, reason) const settled = await jobs.wait(attached.id, timeoutMs, owner) if (!running(settled)) jobs.remove(attached.id, owner) diff --git a/src/shells/session-execution.js b/src/shells/session-execution.ts similarity index 77% rename from src/shells/session-execution.js rename to src/shells/session-execution.ts index 627e24e0..3a28b88d 100644 --- a/src/shells/session-execution.js +++ b/src/shells/session-execution.ts @@ -1,11 +1,15 @@ /** Resolve and confine every invocation using the current session authority. */ import path from 'node:path' +import type { ConfinedArgv } from '@deepseek-ai/dsh-sandbox' +import type { ShellContext, ShellCall, ShellPolicy, ExecutionOptions, ShellExecution, ShellResult } from './types.js' import { bashToolDefinition, withBashStatus } from './tool.js' import { confinedBash } from '../bash-ipc.js' import { prepareWorkspace } from '../workspace-preparation.js' import { executionRestriction } from '../permissions.js' -export function startSessionBash({ ctx, bash, active }, args, exec, standing, policy, dshEnv, options) { +export function startSessionBash({ ctx, bash, active }: { ctx: ShellContext; bash: { file: string; source: string }; active(): boolean }, + args: Record, exec: ShellCall, standing: ShellPolicy, policy: ShellPolicy, dshEnv: NodeJS.ProcessEnv, options: ExecutionOptions): ShellExecution { + if (!exec.agent?.session) throw new Error('EXECUTOR_UNAVAILABLE: an authoritative session is required.') const session = exec.agent.session const assertAuthority = () => { const current = ctx.get('sandboxPolicy')?.resolve({ session }) @@ -15,13 +19,13 @@ export function startSessionBash({ ctx, bash, active }, args, exec, standing, po } assertAuthority() if (!path.isAbsolute(bash.file)) throw new Error('EXECUTOR_UNAVAILABLE: Bash requires a resolved absolute executable path; configure an absolute bashPath. No command executed.') - let runtime + let runtime: { file: string; env?: NodeJS.ProcessEnv } try { runtime = policy.mode === 'danger-full-access' ? { file: bash.file } : confinedBash(bash.file, { source: bash.source }) } catch { throw new Error('EXECUTOR_UNAVAILABLE: the restricted Bash runtime is unavailable or incompatible; this is not a user authorization denial. Restore the verified pinned Bash payload and IPC runtime; changing the DSH selector is not a repair. Do not retry or bypass confinement.') } - let confinement + let confinement: ConfinedArgv | undefined const definition = bashToolDefinition({ file: runtime.file, source: bash.source, env: { ...runtime.env, ...dshEnv }, subprocess: ctx.get('subprocess'), - prepareArgv: async (argv, signal) => { + prepareArgv: async (argv: string[], signal: AbortSignal) => { signal.throwIfAborted() assertAuthority() await prepareWorkspace(policy) @@ -29,17 +33,17 @@ export function startSessionBash({ ctx, bash, active }, args, exec, standing, po if (policy.mode !== 'danger-full-access') { const sandbox = ctx.get('sandbox') if (!sandbox?.confine) throw new Error('SANDBOX_UNAVAILABLE: confinement is unavailable; no command executed.') - confinement = await sandbox.confine(argv, policy, signal) + confinement = await sandbox.confine(argv, { ...policy, mode: policy.mode }, signal) if (!['full', 'partial'].includes(confinement?.enforcement) || !confinement.argv?.length) throw new Error('SANDBOX_UNAVAILABLE: invalid confinement result; no command executed.') } signal.throwIfAborted() assertAuthority() return confinement?.argv ?? argv }, - projectResult: value => { + projectResult: (value: ShellResult) => { const stderr = `${value.stderr?.text ?? ''}\n${value.error ?? ''}` const runnerFailed = value.exitCode !== 0 && confinement?.runnerFailureRules?.some(rule => - (!rule.allowedExitCodes || rule.allowedExitCodes.includes(value.exitCode)) && + (!rule.allowedExitCodes || (value.exitCode != null && rule.allowedExitCodes.includes(value.exitCode))) && stderr.split(/\r?\n/).filter(line => !rule.informationalLines?.some(info => info.toLowerCase() === line.toLowerCase())) .some(line => rule.fatalSignatures?.some(signature => line.toLowerCase().includes(signature.toLowerCase())))) const unavailable = runnerFailed || /couldn't create signal pipe|fatal error.*win32 error|sandbox_unavailable/i.test(stderr) diff --git a/src/shells/tool.js b/src/shells/tool.js index 61f3e041..10f81f91 100644 --- a/src/shells/tool.js +++ b/src/shells/tool.js @@ -123,6 +123,7 @@ export function renderBashResult(value) { * value that does not satisfy it is what the host reports as "returned invalid * output". */ +/** @type {import('@deepseek-ai/dsh-tools').JsonSchemaNode} */ export const BASH_OUTPUT_SCHEMA = Object.freeze({ type: 'object', additionalProperties: false, @@ -172,8 +173,8 @@ export const BASH_OUTPUT_SCHEMA = Object.freeze({ * Stamp one settled result with its one-word outcome, so ops_bash shares the * `status` field the file tools use (#44). Only a clean zero exit is * `completed`; any error, restriction, signal, cancellation or timeout is `failed`. - * @param {object} value - the canonical result value, without `status`. - * @returns {object} the same value with `status` (re)computed. + * @param {import('./types.js').ShellResult} value - the canonical result value, without `status`. + * @returns {import('./types.js').SettledShellResult} the same value with `status` (re)computed. */ export function withBashStatus(value) { const completed = value.exitCode === 0 && value.error === undefined && value.permission === undefined && value.restriction === undefined @@ -210,11 +211,8 @@ export function bashToolDescription() { * reaching a host service through `ctx.get` needs no import, and it buys the * harness's real credential scrub and process governance instead of a * hand-rolled spawn. - * @param {object} options - the tool inputs. - * @param {string} options.file - the resolved bash executable. - * @param {string} options.source - where that executable came from, for diagnostics. - * @param {{spawn: (spec: object) => object}} options.subprocess - the harness subprocess service. - * @returns {object} the definition `ctx.tools.register()` accepts. + * @param {import('./types.js').ShellExecutorOptions} options - the typed host executor inputs. + * @returns {import('./types.js').LegacyShellDefinition} the legacy executor definition wrapped by the host projection. */ export function bashToolDefinition({ file, source, subprocess, prepareArgv, env = {}, projectResult = withBashStatus }) { const definition = { diff --git a/src/shells/types.ts b/src/shells/types.ts new file mode 100644 index 00000000..bd35f970 --- /dev/null +++ b/src/shells/types.ts @@ -0,0 +1,100 @@ +/** Typed OPS shell boundary; host classes are borrowed through services, never imported as values. */ +import type { ToolDefinition, ToolRunContext } from '@deepseek-ai/dsh-tools' +import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import type { SandboxExecutionPolicy, SandboxProvider } from '@deepseek-ai/dsh-sandbox' +import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import type { SubprocessRuntime, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' + +export type ShellCall = Partial> +export type ShellPolicy = SandboxExecutionPolicy +export interface ShellStream { text: string; truncated: boolean; spillPath?: string; bytes?: number; bytesTotal?: number; lines?: number } +export interface ShellResult { + exitCode?: number | null + signal?: string | null + error?: string + timedOut: boolean + timeoutMs: number + stdout: ShellStream + stderr: ShellStream + cancelled?: boolean + stopped?: string + diagnostics?: { durationMs: number; executor: { kind: string; source: string } } + permission?: { code: string; message: string; nextAction: string } + restriction?: { code: string; message: string; nextAction: string } + sandbox?: { mode: ShellPolicy['mode']; denied: boolean; enforcement?: 'full' | 'partial'; runnerFailed?: boolean } +} +export type SettledShellResult = ShellResult & { status: 'completed' | 'failed' } +export type HostForeground = SettledShellResult & { kind: 'foreground'; exitCode: number | null; signal: string | null; aborted: boolean } +export type HostBashResult = HostForeground | { kind: 'background'; jobId: string } + | { kind: 'promoted'; jobId: string; timeoutMs: number; output: string } +export interface ShellExecution { + status: 'running' | 'completed' | 'killed' + readonly started: boolean + observed: { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } + kill(reason?: string): void + result(): Promise + done: Promise + exitCode?: number | null + signal?: string | null + sandbox?: ShellResult['sandbox'] +} +export interface ExecutionOptions { onExpiry?: 'none' } +export interface ShellExecutionSpec extends ExecutionOptions { + command?: string + scriptPath?: string + workdir?: string + timeoutMs: number + graceMs: number + includeDiagnostics: boolean +} +export interface ShellExecutorOptions { + file: string + source: string + subprocess?: Pick + env?: NodeJS.ProcessEnv + prepareArgv?: (argv: string[], signal: AbortSignal) => Promise + projectResult?: (value: ShellResult) => ShellResult | Promise +} +export type StartShell = (exec: ShellCall, options: ExecutionOptions) => ShellExecution | Promise +export type CreateShellExecution = (args: Record, exec: ShellCall, standing: ShellPolicy, + policy: ShellPolicy, env: NodeJS.ProcessEnv, options: ExecutionOptions) => ShellExecution | Promise +export interface ShellBase { + name: string + description: string + parameters: JsonSchemaNode & { properties: Record } + resolve(args: Record): unknown +} +export interface LegacyShellDefinition extends ShellBase { + start(args: Record, exec?: ShellCall, options?: ExecutionOptions): ShellExecution + execute(args: Record, exec?: ShellCall): Promise +} +export type HostShellDefinition = Omit & { + execute(args: unknown, exec?: ShellCall): Promise +} + +/** Minimal structural projections of the pinned optional host services, which are not npm dependencies. */ +export interface ShellJobView { status: string; detail?: string } +export interface ShellJobs { + start(spec: { kind: string; label: string; owner?: string; + output: { channel: string; read: SubprocessOutputReader['readFrom'] }[]; + run(): { done: Promise<{ status: 'completed' | 'failed' | 'killed'; detail: string }>; cancel(reason: string): void } }): string + wait(id: string, timeoutMs: number, owner?: string, signal?: AbortSignal): Promise + kill(id: string, owner: string | undefined, reason: string): unknown + remove(id: string, owner?: string): unknown + read(id: string, owner?: string): { chunks: { channel: string; text: string }[]; lossy: boolean; job: { output: { spillPaths?: string[] } } } +} +interface ShellServices { + jobs: ShellJobs + approval: { request(input: { agent: NonNullable; callId?: ShellCall['callId']; toolName: string; reason: string; signal?: AbortSignal }): Promise } + subprocess: Pick + sandbox: Pick + sandboxPolicy: Pick + shellEnv: { collect(exec: ShellCall): NodeJS.ProcessEnv } +} +export interface ShellContext { + get(name: K): ShellServices[K] | undefined + logger?: { warn?(message: string): void } +} +export function errorMessage(error: unknown): string { + return String(typeof error === 'object' && error !== null && 'message' in error ? error.message ?? error : error) +} diff --git a/test/shells-execution.test.mjs b/test/shells-execution.test.mjs index d519e483..03e05fd4 100644 --- a/test/shells-execution.test.mjs +++ b/test/shells-execution.test.mjs @@ -83,4 +83,13 @@ await test('the first stop cause wins when timeout and caller cancellation race' assert.equal(value.cancelled, undefined) }) +await test('an unavailable subprocess service settles without starting a command', async () => { + const definition = bashToolDefinition({ file: process.execPath, source: 'config' }) + const execution = definition.start({ command: 'true' }) + const value = await execution.result() + assert.equal(execution.started, false) + assert.equal(value.status, 'failed') + assert.match(value.error, /Host subprocess service is unavailable/) +}) + report('shells-execution') diff --git a/test/shells-host.test.mjs b/test/shells-host.test.mjs index 9aa68345..8f641d7a 100644 --- a/test/shells-host.test.mjs +++ b/test/shells-host.test.mjs @@ -218,4 +218,11 @@ await test('an unresolved executable is refused before the host can search PATH' assert.match(value.error, /resolved absolute executable path/); assert.equal(f.calls.length, 0) }) +await test('a session executor without an authoritative agent refuses before consulting services', () => { + const ctx = { get() { throw new Error('unexpected service lookup') } } + const policy = { mode: 'danger-full-access', workspaceRoot: TEST_HOME } + assert.throws(() => startSessionBash({ ctx, bash: { file: process.execPath, source: 'config' }, active: () => true }, + { command: 'true' }, {}, policy, policy, {}, {}), /EXECUTOR_UNAVAILABLE: an authoritative session is required/) +}) + report('shells-host') From ab2f2f1319daa0a4e913dc84474618a9428c5eeb Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:12:59 +0800 Subject: [PATCH 040/118] test(native): verify progressive resident tool contracts --- docs/CHANGELOG.md | 6 ++++++ docs/component-reference.md | 7 ++++--- docs/discovery.md | 8 +++---- ...45\345\205\267\345\217\221\347\216\260.md" | 8 +++---- ...04\344\273\266\345\217\202\350\200\203.md" | 7 ++++--- scripts/tools-trial.mjs | 13 +++++++++--- src/capabilities/discovery.ts | 6 +++--- src/capabilities/file.ts | 6 +++--- src/components/file/locale/en.json | 2 +- src/components/file/locale/zh.json | 2 +- src/components/file/package.json | 2 +- test/capabilities.test.mjs | 1 + test/discovery.test.mjs | 2 +- test/native-result.test.mjs | 15 +++++++++---- test/policy.test.mjs | 2 +- test/ptc-sdk.test.mjs | 13 +++++++----- test/resident-tools.test.mjs | 21 ++++++++++--------- 17 files changed, 74 insertions(+), 47 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b3cfaae6..683f58ad 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,12 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Keep Native startup to the bounded multi-file reader (#261, 9/16) + +- Publish `ops_read_many` from startup; make the single-file/hex reader, search, mutations and Native result paging discoverable. Routing describes only published tools, and the trial explicitly loads its advanced operations. +- Measure compact file/discovery schemas at 241 tokens and the current full surface with Bash at 610 tokens; the full 400-token BM-03 gate remains unresolved pending the redesigned shell contract. +- Native 启动仅常驻多文件读取器;单文件/hex、搜索、修改与引用分页按需加载。路由仅描述已发布工具;完整工具面含 Bash 仍超过 400-token 门禁,保留真实测量而不宣称通过。 + ### Expose canonical PTC outcomes and generated declarations (#261, 7/16) - Wrap PTC canonical data in discriminated completed/partial/failure outcomes with stable effects diff --git a/docs/component-reference.md b/docs/component-reference.md index 4bb09581..0966651e 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -7,7 +7,7 @@ What each of the six components publishes, with parameters and permission requir | Row `name` | Description | Default | Published tools | | --- | --- | --- | --- | | `dsh-ops/shell` | Prefer bash as the terminal, with PowerShell 7 for operations bash cannot cover | On | `ops_bash`; host `pwsh` uses bundled PowerShell 7 | -| `dsh-ops/file` | Faster, higher-performance Rust file retrieval with more concise, token-efficient output | On | `ops_inspect_local_file`; advanced tools load through discovery | +| `dsh-ops/file` | Faster, higher-performance Rust file retrieval with more concise, token-efficient output | On | `ops_read_many`; advanced tools load through discovery | | `dsh-ops/ptc` | Independent Rust safe batches and bounded result references, only in pure PTC sessions | Off | `ops_batch`, `ops_result` | | `dsh-ops/panel` | Data capture, off at the switch: new conversations write `/.dsh-ops/panel/.json` plus the viewer `panel.html` | Row on, `capture: false` | None; see the [configuration reference](configuration-reference.md) | | `dsh-ops/tui` | Read-only current-session OPS sidebar | Off | None; see [sidebar guide](tui-panel.md) | @@ -21,6 +21,7 @@ The former `dsh-ops/background` component (FastCtx background jobs: `ops_run`, ` | Tool | Purpose | Full-access requirement | | --- | --- | --- | +| `ops_read_many` | Resident bounded UTF-8 ranges from 1-32 files | No command permission gate¹ | | `ops_inspect_local_file` | Bounded UTF-8 text lines and hex | No command permission gate¹ | | `ops_grep` | Rust regex search, multiple file filters, counts/summaries | No command permission gate¹ | | `ops_glob` | Multiple path patterns with exclusions | No command permission gate¹ | @@ -36,9 +37,9 @@ The former `dsh-ops/background` component (FastCtx background jobs: `ops_run`, ` ## `dsh-ops/file` — file tools -Only the bounded reader is resident. The advanced contracts below are activated through [tool discovery](discovery.md) or explicit always-loaded choices. Routing lists only the tools currently published. +Only the bounded multi-file reader `ops_read_many` is resident; `ops_inspect_local_file`, including hex reading, is discoverable. The advanced contracts below are activated through [tool discovery](discovery.md) or explicit always-loaded choices. Routing lists only the tools currently published. -All file tools resolve relative paths against the DSH session cwd, accept a single `path`, and expose no `root`, `workspace`, `cwd` or `file_path` alias. All are UTF-8 first with bounded output; glob and grep do not traverse symlinks, junctions, reparse points, hidden files or `.git`. +File paths resolve relative to the DSH session cwd. `ops_read_many` takes a `files` array of paths with independent 1-based offsets and limits; single-file operations take `path`. The Native reference pager accepts the original reference `root`. No file tool accepts `workspace`, `cwd` or `file_path` aliases. All are UTF-8 first with bounded output; glob and grep do not traverse symlinks, junctions, reparse points, hidden files or `.git`. When the host provides its `fs` service, `ops_inspect_local_file` reads and real `ops_replace` writes are recorded in the host's read-before-edit table (`fs/observed`) exactly like host `read`/`edit`: a file read with ops can be edited with host `edit` directly, and an ops write does not make the host's earlier read look like an external change. `ops_grep`, `ops_glob` and `dryRun` record nothing. diff --git a/docs/discovery.md b/docs/discovery.md index f0d39659..7ceceef9 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -11,7 +11,7 @@ The component is exported now. The seven-row bundle installation and 0.3 profile config: enabled: true maxResults: 3 - allowedTools: [read_many, edit_many, result, glob, grep, replace, write_file, edit_file, diff, stat, copy, move, remove] + allowedTools: [inspect_local_file, edit_many, result, glob, grep, replace, write_file, edit_file, diff, stat, copy, move, remove] alwaysLoaded: [] ``` @@ -32,14 +32,14 @@ Only this plugin's scoped `ctx.tools.register`, `restrict`, prompt sections and ## Measurement boundaries -The deterministic entry measurement uses `js-tiktoken@1.0.21` with `cl100k_base`, encoding compact JSON of the pinned host `ToolSchema` fields (`name`, `description`, `parameters`). The discovery entry is **120 tokens / 552 UTF-8 bytes**. This is a reproducible schema measurement, not provider billing, and excludes tool-message framing, prompt guidance and other OPS components. The tokenizer is a test-only dependency published on 2025-08-09; its locked `base64-js` dependency is also test-only. +The deterministic entry measurement uses `js-tiktoken@1.0.21` with `cl100k_base`, encoding compact JSON of the pinned host `ToolSchema` fields (`name`, `description`, `parameters`). The discovery entry is **94 tokens / 410 UTF-8 bytes**. This is a reproducible schema measurement, not provider billing, and excludes tool-message framing, prompt guidance and other OPS components. The tokenizer is a test-only dependency published on 2025-08-09; its locked `base64-js` dependency is also test-only. -The unchanged fourteen-tool Native file surface plus discovery measures **3183 tokens / 14942 bytes** by the same method; this slice does not claim that whole surface passes 400. The next slice reduces the resident file set and records its own complete file/discovery baseline. Shell/PTC or operator always-loaded choices must be measured separately, not silently excluded from a global claim. +The default file/discovery surface is now `ops_read_many` plus `ops_discover`: **241 tokens / 1094 bytes** in actual host assembly order. The reader accepts multiple file ranges in one startup call; `ops_inspect_local_file` (including hex), search, mutations and Native result paging are discoverable. Routing names only published tools. Adding the current Bash contract gives **610 tokens / 2915 bytes**, so the full default BM-03 gate remains unresolved. These figures preserve numeric bounds, defaults, path bases and error contracts. The redesigned Bash contract must be measured again when integrated; operator always-loaded choices also expand the initial surface. BM-04 runs through the real Cordis tool/prompt services and native worker: an implemented `stat` capability is absent before discovery, remains absent through current-step assembly, appears after the next pre-step boundary, and executes successfully. PDF is unimplemented, so this is the lifecycle gate, not a claim that PDF acceptance passed. The browser configuration multi-select is separate work: plain configuration validation does not automatically generate a host selector. Its UI must use the pinned host's browser plugin configuration APIs. -Overlapping discovery rows use the nearest owning scope; the last row mounted in the same scope wins until it is unloaded. File rows may mount before or after discovery. Only exact OPS-owned Native registrations are yielded or masked; unrelated external registrations remain visible. PTC Native shadows retain their exact origin, and discovered activations cannot be inherited by another agent. Native `result` is page/release only and may be discovered in read-only mode; the worker still rejects forbidden release actions. `read_many` and `edit_many` retain their existing per-file completeness and CAS contracts. +Overlapping discovery rows use the nearest owning scope; the last row mounted in the same scope wins until it is unloaded. File rows may mount before or after discovery. Only exact OPS-owned Native registrations are yielded or masked; unrelated external registrations remain visible. PTC Native shadows retain their exact origin, and discovered activations cannot be inherited by another agent. Native `result` is page/release only and may be discovered in read-only mode; the worker still rejects forbidden release actions. Resident `read_many` and discoverable `edit_many` retain their existing per-file completeness and CAS contracts. BM-03 remains a full default Native-surface gate, including Windows Bash when integrated. The entry-only measurement above does not satisfy that gate. diff --git "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" index 9d44de5c..4848c920 100644 --- "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" +++ "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" @@ -11,7 +11,7 @@ English: [Advanced tools on demand](discovery.md). config: enabled: true maxResults: 3 - allowedTools: [read_many, edit_many, result, glob, grep, replace, write_file, edit_file, diff, stat, copy, move, remove] + allowedTools: [inspect_local_file, edit_many, result, glob, grep, replace, write_file, edit_file, diff, stat, copy, move, remove] alwaysLoaded: [] ``` @@ -32,14 +32,14 @@ English: [Advanced tools on demand](discovery.md). ## 测量边界 -确定性的入口测量固定 `js-tiktoken@1.0.21` 和 `cl100k_base`,编码固定宿主 `ToolSchema` 字段(`name`、`description`、`parameters`)的紧凑 JSON。发现入口为 **120 tokens / 552 UTF-8 字节**。这是可复现的 schema 测量,不是供应商账单;不含消息封装、提示词指引和其它 OPS 组件。分词器是测试依赖,发布于 2025-08-09;锁定的 `base64-js` 也仅供测试使用。 +确定性的入口测量固定 `js-tiktoken@1.0.21` 和 `cl100k_base`,编码固定宿主 `ToolSchema` 字段(`name`、`description`、`parameters`)的紧凑 JSON。发现入口为 **94 tokens / 410 UTF-8 字节**。这是可复现的 schema 测量,不是供应商账单;不含消息封装、提示词指引和其它 OPS 组件。分词器是测试依赖,发布于 2025-08-09;锁定的 `base64-js` 也仅供测试使用。 -未精简的十四项 Native 文件工具加发现入口,同口径为 **3183 tokens / 14942 字节**;本切片不宣称整体通过 400 上限。下一切片精简常驻文件集合并记录完整文件/发现基线。Shell、PTC 或用户始终加载的选择必须单独计量,不能在全局节省声明中悄悄排除。 +默认文件/发现工具面现在是 `ops_read_many` 加 `ops_discover`,按真实宿主组装顺序为 **241 tokens / 1094 字节**。读取器从启动起即可一次读取多个文件范围;`ops_inspect_local_file`(含 hex)、搜索、修改和 Native 引用分页均按需发现。路由仅列出实际已发布工具。加入当前 Bash 契约后为 **610 tokens / 2915 字节**,因此完整默认工具面的 BM-03 门禁仍未通过。这些测量保留数值边界、默认值、路径基准和错误契约;重新设计的 Bash 契约整合后必须重测,用户的始终加载选择也会扩大初始工具面。 BM-04 使用真实 Cordis 工具/提示词服务及原生 worker:已实现的 `stat` 在发现前不可见,在当前步骤组装时仍不可见,下一 pre-step 边界后出现并能执行。PDF 尚未实现,因此这是生命周期门禁,并不代表 PDF 验收已通过。 浏览器配置多选界面属于独立工作:普通配置校验不会自动生成宿主选择器,界面必须使用固定宿主版本的浏览器插件配置 API。 -重叠的发现行由最近作用域拥有;同一作用域最后挂载的行生效,卸载后恢复前一行。文件行可在发现组件之前或之后挂载。仅让出或屏蔽确切属于 OPS 的 Native 注册,外部注册保持可见。PTC 的 Native 投影保留确切来源,发现后的激活项不能被其它 agent 继承。Native `result` 仅含 page/release,可在只读模式发现;worker 仍会拒绝不允许的 release。`read_many` 和 `edit_many` 保留原有逐文件完整性和 CAS 契约。 +重叠的发现行由最近作用域拥有;同一作用域最后挂载的行生效,卸载后恢复前一行。文件行可在发现组件之前或之后挂载。仅让出或屏蔽确切属于 OPS 的 Native 注册,外部注册保持可见。PTC 的 Native 投影保留确切来源,发现后的激活项不能被其它 agent 继承。Native `result` 仅含 page/release,可在只读模式发现;worker 仍会拒绝不允许的 release。常驻 `read_many` 和按需 `edit_many` 保留原有逐文件完整性和 CAS 契约。 BM-03 仍是完整默认 Native 工具面的门禁,整合 Windows Bash 后也必须计入。上述仅入口测量不代表该门禁通过。 diff --git "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" index ac508eeb..34f6fc8d 100644 --- "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" +++ "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" @@ -7,7 +7,7 @@ | 配置行 `name` | 介绍 | 默认 | 发布的工具 | | --- | --- | --- | --- | | `dsh-ops/shell` | 将默认终端改为 bash,以及 bash 覆盖不到时提供 PowerShell 7 | 开 | `ops_bash`;宿主 `pwsh` 使用随包 PowerShell 7 | -| `dsh-ops/file` | 更快、更高性能、输出更精简更省 token 的 Rust 文件检索 | 开 | `ops_inspect_local_file`;高级工具通过发现加载 | +| `dsh-ops/file` | 更快、更高性能、输出更精简更省 token 的 Rust 文件检索 | 开 | `ops_read_many`;高级工具通过发现加载 | | `dsh-ops/ptc` | 独立 Rust 安全批处理和有界结果引用,仅纯 PTC 会话加载 | 关 | `ops_batch`、`ops_result` | | `dsh-ops/panel` | 数据采集,开关默认关:新对话写入 `/.dsh-ops/panel/.json` 与读取它的 `panel.html` | 行开,`capture: false` | 无;见[配置参考](配置参考.md) | | `dsh-ops/tui` | 当前会话的只读 OPS 侧栏 | 关 | 无;见 [侧栏说明](tui-panel.zh.md) | @@ -21,6 +21,7 @@ | 工具 | 用途 | 完全权限要求 | | --- | --- | --- | +| `ops_read_many` | 常驻读取 1-32 个文件的有界 UTF-8 范围 | 无命令权限门¹ | | `ops_inspect_local_file` | 有界 UTF-8 文本行与 hex | 无命令权限门¹ | | `ops_grep` | Rust 正则搜索、多文件过滤、计数/摘要 | 无命令权限门¹ | | `ops_glob` | 多模式找路径,支持排除 | 无命令权限门¹ | @@ -36,9 +37,9 @@ ## `dsh-ops/file` —— 文件工具 -仅有界读取器常驻。下述高级工具契约通过[工具发现](工具发现.md)或显式常驻选择激活;路由说明只列出当前实际发布的工具。 +仅有界多文件读取器 `ops_read_many` 常驻;含 hex 读取的 `ops_inspect_local_file` 按需发现。下述高级工具契约通过[工具发现](工具发现.md)或显式常驻选择激活;路由说明只列出当前实际发布的工具。 -全部文件工具都以 DSH 会话 cwd 为基准解析相对路径,只接受一个 `path`,不提供 `root`、`workspace`、`cwd` 或 `file_path` 别名。全部为 UTF-8 优先且有界输出;glob 与 grep 不遍历符号链接、junction、重解析点、隐藏文件和 `.git`。 +文件路径以 DSH 会话 cwd 为基准解析。`ops_read_many` 接受 `files` 数组,每项有独立路径、从 1 开始的行偏移和行数上限;单文件操作接受 `path`。Native 引用分页接受原始引用的 `root`。文件工具不接受 `workspace`、`cwd` 或 `file_path` 别名。全部为 UTF-8 优先且有界输出;glob 与 grep 不遍历符号链接、junction、重解析点、隐藏文件和 `.git`。 宿主提供 `fs` 服务时,`ops_inspect_local_file` 的读取与 `ops_replace` 的实际写入会像宿主 `read`/`edit` 一样登记到宿主的「先读后改」观测表(`fs/observed`):用 OPS 读过的文件可直接用宿主 `edit`,OPS 写过之后宿主也不会把它误判为外部改动。`ops_grep`、`ops_glob` 与 `dryRun` 不登记。 diff --git a/scripts/tools-trial.mjs b/scripts/tools-trial.mjs index 54098422..f502f0bc 100644 --- a/scripts/tools-trial.mjs +++ b/scripts/tools-trial.mjs @@ -28,7 +28,7 @@ const foreignHome = process.env.DSH_HOME !== undefined && !path.resolve(process.env.DSH_HOME).startsWith(path.resolve(tempRoot)) const ownedHome = process.env.DSH_HOME === undefined || foreignHome if (ownedHome) process.env.DSH_HOME = mkdtempSync(path.join(tempRoot, 'dsh-ops-trial-home-')) -const { bootHost, callTool, mountPlugin, publicToolNames, shutdown } = await import('../test/lib/host.mjs') +const { bootHost, callTool, mountPlugin, scopedToolNames, shutdown } = await import('../test/lib/host.mjs') const REPORT = 'tools-trial-report.md' const results = [] @@ -71,9 +71,15 @@ try { record('ops_bash', 'skipped', `shell component did not mount: ${error.message.split('\n')[0]}`) } - published = publicToolNames(ctx) - process.stdout.write(` published: ${published.join(', ') || '(none)'}\n`) const agent = { id: 'trial', session: { id: 'trial', mode: 'danger-full-access', header: { cwd: workspace } } } + const { createScope } = await import('@deepseek-ai/dsh-scope') + agent.ctx = createScope(ctx, agent).ctx + const discovery = await import('../lib/components/discovery/index.js') + const advanced = ['inspect_local_file', 'grep', 'glob', 'replace'] + await ctx.plugin(discovery, { required: true, enabled: false, allowedTools: advanced, alwaysLoaded: advanced }) + await ctx.serial('agent/created', { agent, source: 'startup' }) + published = scopedToolNames(ctx, agent) + process.stdout.write(' published: ' + (published.join(', ') || '(none)') + '\n') /** Call one tool, then judge the whole structured answer — field names differ per tool. */ const attempt = async (raw, args, expect, note, absent = 'not published by this build') => { @@ -104,6 +110,7 @@ try { } } + await attempt('read_many', { files: [{ path: 'alpha.txt' }, { path: 'beta.txt' }] }, 'alpha line', 'read two real files in one call') await attempt('inspect_local_file', { path: 'alpha.txt' }, 'alpha line', 'read a real file') await attempt('grep', { pattern: 'gamma', path: '.' }, 'gamma', 'found a match in a nested file') await attempt('glob', { pattern: ['*.txt'], path: '.' }, 'alpha.txt', 'listed the top-level glob') diff --git a/src/capabilities/discovery.ts b/src/capabilities/discovery.ts index f0d0fc7a..7e37aab8 100644 --- a/src/capabilities/discovery.ts +++ b/src/capabilities/discovery.ts @@ -20,11 +20,11 @@ export const DISCOVERY_TERMS = Object.freeze({ export const DISCOVERY_PARAMETERS: ParameterSchema = { type: 'object', additionalProperties: false, required: ['query'], properties: { - query: { type: 'string', description: 'Needed capability, or select:ops_name. Nonempty; at most 512 characters.' }, - max_results: { type: 'integer', default: 3, description: '1-5; default 3, capped by the configured per-call limit.' }, + query: { type: 'string', description: 'Capability or select:ops_name; 1-512 characters.' }, + max_results: { type: 'integer', default: 3, description: '1-5; default 3, capped by config.' }, }, } -export const DISCOVERY_DESCRIPTION = 'Find and activate allowed advanced OPS tools for the next model step in this scope. Describe a capability or use select:ops_name. Discovery never performs the requested file action.' +export const DISCOVERY_DESCRIPTION = 'Select allowed OPS capabilities for the next model step in this scope; no file action.' export const DISCOVERY_OUTPUT: JsonSchemaNode = { type: 'object', properties: { status: { type: 'string' }, activated: { type: 'array', items: { type: 'string' } }, diff --git a/src/capabilities/file.ts b/src/capabilities/file.ts index f2551106..c21bbd9c 100644 --- a/src/capabilities/file.ts +++ b/src/capabilities/file.ts @@ -16,8 +16,8 @@ const existingRevision: JsonSchemaNode = { type: 'string', description: '64 lowe const edits = bounded({ path: string, expectedRevision: existingRevision, edits: { type: 'array', description: '1-100 nonoverlapping edits against the same original bytes.', items: bounded({ oldText: { ...string, description: 'Nonempty text that occurs exactly once in the original.' }, newText: string }, ['oldText', 'newText']) }, dryRun: { type: 'boolean' } }, ['path', 'expectedRevision', 'edits']) export const PARAMETERS = Object.freeze({ - read_many: bounded({ files: { type: 'array', description: '1-32 UTF-8 file ranges.', items: bounded({ - path: string, offset: { type: 'integer', description: '1-based line; minimum 1, default 1.' }, limit: range(1, 10000, 'Default 200.'), + read_many: bounded({ files: { type: 'array', description: '1-32 file ranges.', items: bounded({ + path: string, offset: { type: 'integer', description: 'Line >=1; default 1.' }, limit: range(1, 10000, 'Default 200.'), }, ['path']) } }, ['files']), edit_many: edits, inspect_local_file: bounded({ @@ -114,7 +114,7 @@ export function fileOutput(rawName: keyof typeof PARAMETERS): JsonSchemaNode { const RESULT = ' Failures resolve as {status:"error"|"rejected", error.code}; they are not thrown.' const EDIT_DESCRIPTION = 'Precisely patch one UTF-8 file using 1-100 oldText/newText edits against the SAME original bytes. Every nonempty oldText must occur exactly once; overlapping edits reject all. Required expectedRevision from inspect/stat. Atomic CAS; dryRun previews. Darwin nonempty extended ACLs fail closed with METADATA_UNSUPPORTED. Preserve CRLF/BOM explicitly in text. Windows preserves owner/group/ordered DACL and supported basic attributes/timestamps; readonly overwrite is refused and unsupported streams/EA/special storage fail with METADATA_UNSUPPORTED. SACL/audit labels are not preserved.' + RESULT export const DESCRIPTIONS = Object.freeze({ - read_many: 'Read UTF-8 ranges in one call. Paths use the current session cwd. Check each file complete/error; nextOffset continues its lines, nextIndex only resumes pending files. Binary data requires a binary reader.' + RESULT, + read_many: 'Read UTF-8 ranges relative to session cwd. Check each file complete/error; nextOffset continues lines, nextIndex resumes pending files. Binary needs a binary reader. Failures return status:error|rejected and error.code; never throw.', inspect_local_file: 'Read bounded UTF-8 text lines or a hex view from path relative to the DSH session cwd. Directories are rejected; use ops_glob or ops_grep.' + RESULT, glob: 'Find regular files below path relative to the current session cwd. pattern is an ARRAY of globs matched against paths relative to path: ["**/*.md","!vendor/**"]; "*.md" matches the top level only, "!" excludes, at least one positive entry. Symlinks, reparse points, hidden files and dependency/build directories are excluded. limit defaults to 100; paging uses 0-based offset/nextOffset. Check scanComplete and pageComplete; matchedTotal exists only after a complete scan. Prefer continuation with unchanged options; SEARCH_STALE means restart. Incomplete scans require a narrower query.' + RESULT, grep: 'Search UTF-8 files below path relative to the current session cwd. pattern is ONE Rust regex string; filter files with glob, an ARRAY of relative globs ("!" excludes). There is no include field. Use summary for aggregate file/match totals, count for per-file counts, files_with_matches for paths, content for text (default); limit defaults to 100 items. Optional line context. Paging uses 0-based offset/nextOffset; prefer continuation with unchanged options. Each page rescans; SEARCH_STALE means restart. Check scanComplete and pageComplete; matchedTotal exists only after a complete scan, matchedObserved is a lower bound otherwise. skipReasons explains gaps. Narrow incomplete scans instead of blindly paging.' + RESULT, diff --git a/src/components/file/locale/en.json b/src/components/file/locale/en.json index 271deebd..890cd27f 100644 --- a/src/components/file/locale/en.json +++ b/src/components/file/locale/en.json @@ -1,6 +1,6 @@ { "meta": { "title": "dsh-ops-file", - "description": "Bounded Native file reading; advanced operations load through tool discovery." + "description": "Bounded Native multi-file reading; single-file/hex and advanced operations load through discovery." } } diff --git a/src/components/file/locale/zh.json b/src/components/file/locale/zh.json index 8cff154e..282b82ac 100644 --- a/src/components/file/locale/zh.json +++ b/src/components/file/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { "title": "dsh-ops-file", - "description": "有界原生文件读取;高级操作通过工具发现按需加载。" + "description": "有界原生多文件读取;单文件/hex 及高级操作通过发现按需加载。" } } diff --git a/src/components/file/package.json b/src/components/file/package.json index c7cbdad1..26f1512d 100644 --- a/src/components/file/package.json +++ b/src/components/file/package.json @@ -1,6 +1,6 @@ { "name": "dsh-ops-file", - "description": "Bounded Native file reading; advanced operations load through tool discovery.", + "description": "Bounded Native multi-file reading; single-file/hex and advanced operations load through discovery.", "icon": "./icon.png", "type": "module" } diff --git a/test/capabilities.test.mjs b/test/capabilities.test.mjs index 6b35f5fa..4ac03c75 100644 --- a/test/capabilities.test.mjs +++ b/test/capabilities.test.mjs @@ -34,6 +34,7 @@ await test('metadata separates the Native resident core from discoverable capabi for (const entry of [...FILE_CAPABILITIES, ...SHELL_CAPABILITIES]) assert.deepEqual(entry.presentations, ['native', 'ptc', 'both']) for (const entry of PTC_CAPABILITIES) assert.deepEqual(entry.presentations, entry.rawName === 'result' ? ['native', 'ptc', 'both'] : ['ptc']) assert.deepEqual(NATIVE_RESIDENT_FILE_TOOLS, ['read_many']) + assert.equal(capability('result').nativeProjection.availability, 'discoverable') assert.deepEqual(FILE_CAPABILITIES.filter(entry => entry.availability === 'discoverable').map(entry => entry.rawName).sort(), DISCOVERABLE_TOOLS.filter(name => name !== 'result').sort()) for (const entry of CAPABILITIES) { assert.equal(entry.availability, entry.component === 'file' && entry.rawName !== 'read_many' ? 'discoverable' : 'resident') diff --git a/test/discovery.test.mjs b/test/discovery.test.mjs index 491fc0c4..aa5bf8ba 100644 --- a/test/discovery.test.mjs +++ b/test/discovery.test.mjs @@ -32,7 +32,7 @@ await test('discovery-entry schema has a reproducible pinned token measurement, const wire = JSON.stringify([{ name: publicToolName('discover'), description: DISCOVERY_DESCRIPTION, parameters: DISCOVERY_PARAMETERS }]) const tokens = getEncoding('cl100k_base').encode(wire).length console.log(` discovery schema: ${tokens} cl100k_base tokens, ${Buffer.byteLength(wire)} UTF-8 bytes; js-tiktoken 1.0.21; compact JSON name/description/parameters; not provider billing or the whole OPS surface`) - assert.equal(tokens, 120) + assert.equal(tokens, 94) }) await test('scoped file cancellation aborts active workers and disposal waits for their completion', () => withTempDir('discovery-cancel', async dir => { let spawned, complete diff --git a/test/native-result.test.mjs b/test/native-result.test.mjs index 3203f025..7f3dc891 100644 --- a/test/native-result.test.mjs +++ b/test/native-result.test.mjs @@ -10,6 +10,8 @@ import { bootHost, mountPlugin, shutdown } from './lib/host.mjs' import { WORKER, requireWorker } from './lib/boundary.mjs' import { invoke } from '../lib/ptc/worker.js' import * as ptc from '../lib/ptc/index.js' +import * as discovery from '../lib/discovery/index.js' +const hostAgents = new WeakMap() import { mountNativeCatalogue } from './lib/native-catalogue.mjs' import { resultStorage } from '../lib/kernel/result-storage.js' import { resultDefinition } from '../lib/file/result.js' @@ -29,6 +31,8 @@ async function agent(ctx, cwd, presentation = 'native', parent) { const value = { id, session: { id, mode: 'workspace-write', header: { cwd } } } value.ctx = createScope(parent?.ctx ?? ctx, value).ctx await value.ctx.plugin({ inject: ['tools'], apply(inner) { value.liftPresentation = inner.tools.presentAs(presentation) } }) + if (!hostAgents.has(ctx)) { hostAgents.set(ctx, []); ctx.provide('agents', { list: () => hostAgents.get(ctx) }) } + hostAgents.get(ctx).push(value) await ctx.serial('agent/created', { agent: value }) return value } @@ -132,9 +136,11 @@ await test('Native pager denies hidden producers, other sessions, narrower autho const record = JSON.parse(fs.readFileSync(blob, 'utf8')); record.producerTool = 'grep'; fs.writeFileSync(blob, JSON.stringify(record)) const grepId = stored.id.replace('/result/', '/grep/') assert.equal((await page(ctx, owner, { action: 'page', id: grepId })).value.complete, true) - let restore - await owner.ctx.plugin({ inject: ['tools'], apply(inner) { restore = inner.tools.restrict({ deny: ['ops_grep'] }) } }) - assert.equal((await page(ctx, owner, { action: 'page', id: grepId })).value.error.code, 'POLICY_DENIED'); restore() + const restricted = await owner.ctx.plugin(discovery, { binaryPath: WORKER, required: true, enabled: false, allowedTools: ['result'], alwaysLoaded: ['result'] }) + await waitFor(() => ctx.get('tools').get('ops_result', owner) && !ctx.get('tools').get('ops_grep', owner)) + assert.equal((await page(ctx, owner, { action: 'page', id: grepId })).value.error.code, 'POLICY_DENIED') + await restricted.dispose() + await waitFor(() => ctx.get('tools').get('ops_result', owner) && ctx.get('tools').get('ops_grep', owner)) const retained = ctx.get('tools').get('ops_result', owner) await file.fiber.dispose() assert.equal((await retained.execute({ action: 'page', id: grepId }, { agent: owner })).error.code, 'POLICY_DENIED') @@ -190,7 +196,7 @@ await test('agent-scoped file rows isolate sibling pagers and yield only their e const rightStored = await put(ctx, right, root, [{ right: true }]) assert.deepEqual((await page(ctx, right, { action: 'page', id: rightStored.id })).value.items, [{ right: true }]) left.liftPresentation() - await left.ctx.plugin({ inject: ['tools'], apply(inner) { inner.tools.presentAs('ptc') } }) + await left.ctx.plugin({ inject: ['tools'], apply(inner) { left.liftPresentation = inner.tools.presentAs('ptc') } }) await waitFor(() => current(left)?.parameters.properties.action.enum.includes('put'), { timeoutMs: 5000, what: 'exact-layer PTC takeover' }) const leftStored = await current(left).execute({ action: 'put', items: [{ left: true }] }, { agent: left, signal: new AbortController().signal }) assert.equal(leftStored.status, 'completed', JSON.stringify(leftStored)) @@ -200,6 +206,7 @@ await test('agent-scoped file rows isolate sibling pagers and yield only their e const lateFile = await mount(late) await waitFor(() => !ctx.get('tools').get('ops_grep', late) && current(late)?.parameters.properties.action.enum.includes('put'), { timeoutMs: 5000, what: 'file mounted after PTC' }) await component.dispose() + for (const value of [left, late]) { value.liftPresentation(); await value.ctx.plugin({ inject: ['tools'], apply(inner) { value.liftPresentation = inner.tools.presentAs('native') } }) } await waitFor(() => current(left)?.parameters.properties.action.enum.length === 2 && current(late)?.parameters.properties.action.enum.length === 2, { timeoutMs: 5000, what: 'Native peer resumes on PTC unload' }) await leftFile.dispose(); assert.equal(current(left), undefined) assert.equal(current(right).parameters.properties.action.enum.length, 2) diff --git a/test/policy.test.mjs b/test/policy.test.mjs index 6f9d8d04..5828461e 100644 --- a/test/policy.test.mjs +++ b/test/policy.test.mjs @@ -45,7 +45,7 @@ await test('partial tool availability never advertises missing tools', () => { for (const name of files.slice(1)) assert.equal(text.includes(name), false) }) await test('resident-only routing omits unavailable operation hints and includes discovery only when published', () => { - const reader = renderToolingPolicy({ published: [publicToolName('inspect_local_file')], shellComponent: false }) + const reader = renderToolingPolicy({ published: [publicToolName('read_many')], shellComponent: false }) for (const text of ['glob', 'grep', 'copy/move', 'ops_stat', 'ops_discover']) assert.equal(reader.includes(text), false) const discovery = renderToolingPolicy({ published: [publicToolName('discover')], shellComponent: false }) assert.ok(discovery.includes(publicToolName('discover'))) diff --git a/test/ptc-sdk.test.mjs b/test/ptc-sdk.test.mjs index a01ecb9b..80bf4565 100644 --- a/test/ptc-sdk.test.mjs +++ b/test/ptc-sdk.test.mjs @@ -18,6 +18,7 @@ let serial = 0 const visible = (ctx, agent) => ctx.get('tools').schemas(agent).map(tool => tool.name).filter(name => name.startsWith('ops_')).sort() async function setup(root, { native = false, config = {}, real = false } = {}) { const { ctx } = await bootHost() + const agents = []; ctx.provide('agents', { list: () => agents }) await ctx.plugin(Subprocess); await ctx.plugin(Sandbox); await ctx.plugin(FileSystem) await ctx.plugin(inner => { inner.provide('sandboxPolicy', { resolve: ({ session } = {}) => ({ mode: session?.mode ?? 'workspace-write', workspaceRoot: root, sessionId: session?.id }) }) @@ -32,6 +33,7 @@ async function setup(root, { native = false, config = {}, real = false } = {}) { const value = { id, session: { id, mode: 'workspace-write', header: { cwd: root }, append() {}, read() { return [] } } } value.ctx = createScope(parent?.ctx ?? ctx, value, parent ? { parent } : undefined).ctx await value.ctx.plugin({ inject: ['tools'], apply(inner) { value.lift = inner.tools.presentAs(mode) } }) + agents.push(value) await ctx.serial('agent/created', { agent: value }) return value } @@ -96,11 +98,12 @@ await test('Native/PTC child scopes preserve projections and same-layer file row try { const parent = await agent(), child = await agent('native', parent), both = await agent('both'), independent = await agent() assert.deepEqual(visible(ctx, parent), ['ops_inspect_local_file', 'ops_result', 'ops_sdk']) - for (const scope of [child, both]) { assert.ok(visible(ctx, scope).includes('ops_grep')); assert.equal(visible(ctx, scope).includes('ops_sdk'), false) } + for (const scope of [child, both]) { await waitFor(() => visible(ctx, scope).includes('ops_grep'), { timeoutMs: 5000, what: 'configured Native advanced tools' }); assert.equal(visible(ctx, scope).includes('ops_sdk'), false) } assert.equal(ctx.get('tools').get('ops_result', child).parameters.properties.action.enum.includes('put'), false) const previousNative = ctx.get('tools').get('ops_stat', child) const scopedFile = await mountFile(parent.ctx) await waitFor(() => visible(ctx, parent).length === 3 && ctx.get('tools').get('ops_stat', child) !== previousNative, { timeoutMs: 5000, what: 'own-layer handoff' }) + await step(ctx, parent) // A real provider call begins after this public reconciliation boundary. const loaded = await direct(ctx, parent, 'ops_sdk', { tools: ['grep'] }); assert.equal(loaded.status, 'completed', JSON.stringify(loaded)) await step(ctx, parent); assert.ok(visible(ctx, parent).includes('ops_grep')) assert.equal(visible(ctx, independent).includes('ops_grep'), false) @@ -110,7 +113,7 @@ await test('Native/PTC child scopes preserve projections and same-layer file row const assembled = await ctx.get('systemPrompt').assemble({ scope: parent }) assert.equal(assembled.tools.some(tool => tool.name === 'ops_sdk'), false) assert.equal((await retained.execute({ path: root, pattern: 'x' }, { agent: parent, signal: new AbortController().signal })).error.code, 'POLICY_DENIED') - await component.dispose(); assert.ok(visible(ctx, parent).includes('ops_grep')); assert.equal(visible(ctx, child).includes('ops_sdk'), false) + await component.dispose(); await waitFor(() => visible(ctx, parent).includes('ops_grep'), { timeoutMs: 5000, what: 'Native discovery restoration after PTC unload' }); assert.equal(visible(ctx, child).includes('ops_sdk'), false) await scopedFile.dispose(); assert.equal(Object.hasOwn(ctx.get('tools'), 'register'), false) } finally { await shutdown(ctx) } }) @@ -149,11 +152,11 @@ await test('Native child restoration honors external ancestor masks and late par await waitFor(() => visible(ctx, child).length === 0, { timeoutMs: 5000, what: 'late provider disposal' }) await mountFile(ctx) await waitFor(() => visible(ctx, child).includes('ops_grep'), { timeoutMs: 5000, what: 'global Native provider' }) - const mask = await parent.ctx.plugin({ inject: ['tools'], apply(scoped) { scoped.tools.restrict({ deny: ['ops_grep'] }) } }) - await waitFor(() => !visible(ctx, child).includes('ops_grep'), { timeoutMs: 5000, what: 'external ancestor restriction' }) + const mask = await parent.ctx.plugin({ inject: ['tools'], apply(scoped) { scoped.tools.restrict({ deny: ['ops_read_many'] }) } }) + await waitFor(() => !visible(ctx, child).includes('ops_read_many'), { timeoutMs: 5000, what: 'external ancestor restriction' }) assert.ok(visible(ctx, child).includes('ops_stat')) await mask.dispose() - await waitFor(() => visible(ctx, child).includes('ops_grep'), { timeoutMs: 5000, what: 'external restriction release' }) + await waitFor(() => visible(ctx, child).includes('ops_read_many'), { timeoutMs: 5000, what: 'external restriction release' }) } finally { await shutdown(ctx) } }) }) diff --git a/test/resident-tools.test.mjs b/test/resident-tools.test.mjs index e41fedcf..9a6fd577 100644 --- a/test/resident-tools.test.mjs +++ b/test/resident-tools.test.mjs @@ -11,7 +11,7 @@ import { assert, test, report, withTempDir, waitFor } from './lib/harness.mjs' import { bootDiscoveryHost, mintDiscoveryAgent, discoveryNextStep } from './lib/discovery.mjs' import { shutdown, scopedToolNames, registryNames, callTool } from './lib/host.mjs' -const INSPECT = publicToolName('read_many'), DISCOVER = publicToolName('discover') +const READ = publicToolName('read_many'), DISCOVER = publicToolName('discover') const measure = tools => { const wire = JSON.stringify(tools.map(({ name, description, parameters }) => ({ name, description, parameters }))) return { tokens: getEncoding('cl100k_base').encode(wire).length, bytes: Buffer.byteLength(wire) } @@ -19,19 +19,20 @@ const measure = tools => { async function mount(config) { const host = await bootDiscoveryHost(config) const fileFiber = await host.ctx.plugin(file, { required: true }) - await waitFor(() => registryNames(host.ctx).includes(INSPECT)) + await waitFor(() => registryNames(host.ctx).includes(READ)) return { ...host, fileFiber } } await test('the resident file row publishes one bounded reader even with the discovery entry disabled', () => withTempDir('resident', async dir => { const { ctx } = await mount({ enabled: false }) try { const agent = await mintDiscoveryAgent(ctx, 'resident-disabled', dir) - assert.deepEqual(registryNames(ctx), [INSPECT]) - assert.deepEqual(scopedToolNames(ctx, agent), [INSPECT]) + assert.deepEqual(registryNames(ctx), [READ]) + assert.deepEqual(scopedToolNames(ctx, agent), [READ]) fs.writeFileSync(path.join(dir, 'read.txt'), 'bounded reader') - const read = await callTool(ctx, INSPECT, { files: [{ path: 'read.txt' }] }, undefined, agent) + fs.writeFileSync(path.join(dir, 'second.txt'), 'second file') + const read = await callTool(ctx, READ, { files: [{ path: 'read.txt' }, { path: 'second.txt' }] }, undefined, agent) assert.equal(read.value.status, 'completed') - assert.match(read.text, /1 \| bounded reader/) + assert.match(read.text, /1 \| bounded reader/); assert.match(read.text, /1 \| second file/) const absent = await ctx.get('tools').execute({ agent, name: publicToolName('stat'), arguments: { path: 'read.txt' }, callId: 'absent', signal: new AbortController().signal }) assert.equal(absent.isError, true) } finally { await shutdown(ctx) } @@ -41,16 +42,16 @@ await test('measure the full default Native surface including Bash and retain th try { const agent = await mintDiscoveryAgent(ctx, 'resident-budget', dir) const assembly = await ctx.get('systemPrompt').assemble({ scope: agent }) - assert.deepEqual(assembly.tools.map(tool => tool.name).sort(), [INSPECT, DISCOVER].sort()) + assert.deepEqual(assembly.tools.map(tool => tool.name).sort(), [READ, DISCOVER].sort()) const core = measure(assembly.tools) const discovery = assembly.tools.find(tool => tool.name === DISCOVER) const full = measure([...NATIVE_FILE_TOOLS.map(raw => new NativeFileTools({ctx:{},config:{}}).definition(raw)), discovery]) const shell = bashToolDefinition({ file: 'fixture', source: 'test', subprocess: {} }) const withShell = measure([...assembly.tools, shell]) console.log(' cl100k_base/js-tiktoken1.0.21 compact host-schema JSON:', JSON.stringify({ core, fullFileCatalogue: full, corePlusBash: withShell })) - assert.equal(core.tokens, 291, JSON.stringify(core)) + assert.equal(core.tokens, 241, JSON.stringify(core)) assert.ok(full.tokens > 400 && full.tokens > core.tokens, 'retain honest full-surface measurement') - assert.equal(withShell.tokens, 659, 'BM-03 remains unresolved until the full default, including Bash, fits400') + assert.equal(withShell.tokens, 610, 'BM-03 remains unresolved until the full default, including Bash, fits400') const text = assembly.sections.map(section => section.text).join('\n') for (const raw of FILE_TOOLS.filter(raw => raw !== 'read_many')) assert.equal(text.includes(publicToolName(raw)), false) assert.ok(text.includes(DISCOVER)) @@ -67,7 +68,7 @@ await test('always-loaded choices explicitly expand the initial budget and survi try { const agent = await mintDiscoveryAgent(ctx, 'resident-expanded', dir) const assembly = await ctx.get('systemPrompt').assemble({ scope: agent }) - assert.deepEqual(assembly.tools.map(tool => tool.name).sort(), [INSPECT, DISCOVER, publicToolName('glob'), publicToolName('grep')].sort()) + assert.deepEqual(assembly.tools.map(tool => tool.name).sort(), [READ, DISCOVER, publicToolName('glob'), publicToolName('grep')].sort()) assert.ok(measure(assembly.tools).tokens > 400) await fileFiber.dispose() assert.deepEqual(scopedToolNames(ctx, agent), [DISCOVER, publicToolName('glob'), publicToolName('grep')].sort()) From 3e767b6e883d56680c4c369b3e6b42d697a48382 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:23:49 +0800 Subject: [PATCH 041/118] test(native): accept the compact failure value contract --- test/file-contract.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/file-contract.test.mjs b/test/file-contract.test.mjs index 014838a5..7e37498c 100644 --- a/test/file-contract.test.mjs +++ b/test/file-contract.test.mjs @@ -32,7 +32,7 @@ await test('descriptions name the array syntax, the missing include and literal await test('every file tool states that failures are values, not thrown errors', () => { for (const tool of Object.keys(PARAMETERS)) { - assert.match(DESCRIPTIONS[tool], /Failures resolve as \{status:"error"\|"rejected", error\.code\}; they are not thrown\.$/, tool) + assert.match(DESCRIPTIONS[tool], /Failures (?:resolve as \{status:"error"\|"rejected", error\.code\}; they are not thrown|return status:error\|rejected and error\.code; never throw)\.$/, tool) } assert.doesNotMatch(PTC_DESCRIPTIONS.result, /legacy ops_\*/) assert.match(PTC_DESCRIPTIONS.result, /not thrown errors/) From ef7ba77d03693533fbd824c97cffec749880e53e Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:26:49 +0800 Subject: [PATCH 042/118] fix(discovery): publish before the next provider snapshot --- docs/CHANGELOG.md | 6 ++ ...11\351\234\200\345\212\240\350\275\275.md" | 2 +- docs/discovery.md | 4 +- docs/ptc-sdk-loading.md | 2 +- ...45\345\205\267\345\217\221\347\216\260.md" | 4 +- package-lock.json | 43 ++++++++++ package.json | 1 + src/discovery/index.ts | 24 +++++- src/presentation.ts | 18 ++-- src/ptc/index.ts | 25 +++++- test/lib/agent-loop.mjs | 64 ++++++++++++++ test/progressive-loop.test.mjs | 86 +++++++++++++++++++ 12 files changed, 264 insertions(+), 15 deletions(-) create mode 100644 test/lib/agent-loop.mjs create mode 100644 test/progressive-loop.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b3cfaae6..97705fa9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,12 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Publish progressive tools before the next real provider snapshot (#261, 7–8/16) + +- Activate pending Native and PTC selections at the owning session’s committed step end, before the pinned host captures the next provider schema. Drain owned publication during ordinary assembly without replacing host methods; mark internal probes to avoid recursive waits. +- Verify Native startup, immediately following discovery, same-call SDK isolation and next-request PTC loading through the real AgentLoop with a scripted provider. The exact test-only loop dependency `0.2.0-rc.2` was published 2026-09-29. +- 修复真实宿主先组装后 pre-step 导致多延迟一轮的问题;会话提交步骤结束后发布,在下一供应商请求前完成本插件队列,保持当前 SDK 快照与作用域隔离。 + ### Expose canonical PTC outcomes and generated declarations (#261, 7/16) - Wrap PTC canonical data in discriminated completed/partial/failure outcomes with stable effects diff --git "a/docs/PTC\346\214\211\351\234\200\345\212\240\350\275\275.md" "b/docs/PTC\346\214\211\351\234\200\345\212\240\350\275\275.md" index fb56fcf2..9ebb6cc3 100644 --- "a/docs/PTC\346\214\211\351\234\200\345\212\240\350\275\275.md" +++ "b/docs/PTC\346\214\211\351\234\200\345\212\240\350\275\275.md" @@ -2,7 +2,7 @@ PTC 组件独立于 Native 文件组件提供文件 API。PTC-only 代理初始只包含 ops_inspect_local_file、ops_result 和 ops_sdk。宿主根据已注册的参数与 Outcome 输出 schema 生成类型化 SDK;Native 与 both 模式不显示 SDK 加载器。 -在 run_code 内调用 tools.ops_sdk({ tools: ['grep', 'read_many'] })。成功 Outcome 的 data.tools 列出请求名称,data.nextTurn 为 true。结束当前代码调用后,请求的 API 才会在下一个代理 pre-step 发布,当前冻结的 SDK 快照无法调用新 API。系统提示组装、诊断组装和工具注册通知都不会提前激活排队请求。 +在 run_code 内调用 tools.ops_sdk({ tools: ['grep', 'read_many'] })。成功 Outcome 的 data.tools 列出请求名称,data.nextTurn 为 true。当前工具轮次结束、所属会话提交 step/end 后,请求的 API 会在紧接着的供应商请求中发布;当前冻结的 SDK 快照无法调用新 API。该边界前的系统提示组装、诊断组装以及工具注册通知不会提前激活排队请求。组装会等待本插件发布并按需通过公开服务获取新快照;内部呈现探测跳过此屏障,防止递归等待。 PTC 行配置由统一配置入口验证: diff --git a/docs/discovery.md b/docs/discovery.md index f0d39659..25cdda55 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -2,7 +2,7 @@ 中文:[工具发现](工具发现.md). -`dsh-ops/discovery` publishes `ops_discover` in each eligible Native scope. Describe a capability (`metadata revision hash`) or select an exact public name (`select:ops_stat`). Discovery only chooses tools; it performs no file action. A successful selection is pending until the next admitted model step (`agent/pre-step`), before that step's prompt is assembled. Current and diagnostic assemblies cannot activate it. Newly published tools remain scoped to their owner; parents, children and siblings must discover independently. +`dsh-ops/discovery` publishes `ops_discover` in each eligible Native scope. Describe a capability (`metadata revision hash`) or select an exact public name (`select:ops_stat`). Discovery only chooses tools; it performs no file action. A successful selection remains pending through the current tool round. Its owning session's committed `step/end` publishes it for the next provider request. The pinned host assembles before `agent/pre-step`; queued publication is therefore drained and the public prompt service reassembles a stale snapshot. Diagnostic assemblies before that committed boundary cannot activate pending names. Newly published tools remain scoped to their owner; parents, children and siblings must discover independently. The component is exported now. The seven-row bundle installation and 0.3 profile migration are a separate release slice. A manually added row can use: @@ -36,7 +36,7 @@ The deterministic entry measurement uses `js-tiktoken@1.0.21` with `cl100k_base` The unchanged fourteen-tool Native file surface plus discovery measures **3183 tokens / 14942 bytes** by the same method; this slice does not claim that whole surface passes 400. The next slice reduces the resident file set and records its own complete file/discovery baseline. Shell/PTC or operator always-loaded choices must be measured separately, not silently excluded from a global claim. -BM-04 runs through the real Cordis tool/prompt services and native worker: an implemented `stat` capability is absent before discovery, remains absent through current-step assembly, appears after the next pre-step boundary, and executes successfully. PDF is unimplemented, so this is the lifecycle gate, not a claim that PDF acceptance passed. +BM-04 runs through the real Cordis tool/prompt services and native worker: an implemented `stat` capability is absent before discovery, remains absent through current-step assembly, appears in the immediately following real AgentLoop provider request, and executes successfully. PDF is unimplemented, so this is the lifecycle gate, not a claim that PDF acceptance passed. The browser configuration multi-select is separate work: plain configuration validation does not automatically generate a host selector. Its UI must use the pinned host's browser plugin configuration APIs. diff --git a/docs/ptc-sdk-loading.md b/docs/ptc-sdk-loading.md index b8199fe5..34594d7e 100644 --- a/docs/ptc-sdk-loading.md +++ b/docs/ptc-sdk-loading.md @@ -2,7 +2,7 @@ The PTC row owns its file APIs independently of the Native file row. A PTC-only agent starts with `ops_inspect_local_file`, `ops_result` and `ops_sdk`. The host generates the typed SDK from the registered argument and Outcome output schemas. Native and both presentations receive no SDK loader. -Call `tools.ops_sdk({ tools: ['grep', 'read_many'] })` inside `run_code`. A completed Outcome contains `data.tools` and `data.nextTurn: true`. Finish that code call; the requested APIs become visible at the next agent pre-step. They are absent from the current frozen SDK snapshot. Prompt assembly, diagnostic assembly and registry notifications never activate queued names. +Call `tools.ops_sdk({ tools: ['grep', 'read_many'] })` inside `run_code`. A completed Outcome contains `data.tools` and `data.nextTurn: true`. Finish the current tool round; the owning session's committed `step/end` makes the requested APIs visible in the immediately following provider request. They are absent from the current frozen SDK snapshot. Prompt and diagnostic assembly before that boundary, and registry notifications, never activate queued names. Assembly waits for owned publication and obtains a fresh public snapshot when needed; internal presentation probes bypass that barrier to avoid recursion. The PTC row accepts these settings through central config validation: diff --git "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" index 9d44de5c..e8528f48 100644 --- "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" +++ "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" @@ -2,7 +2,7 @@ English: [Advanced tools on demand](discovery.md). -`dsh-ops/discovery` 在每个符合条件的 Native 作用域中发布 `ops_discover`。可描述所需能力(`metadata revision hash`),或指定完整公开名称(`select:ops_stat`)。发现仅选择工具,不执行文件操作。成功选择会保持待激活状态,直到下一次被允许的模型步骤(`agent/pre-step`),在其提示词组装前挂载。当前步骤和诊断性提示词组装都不会激活工具。注册只属于调用者;父、子和同级作用域都需独立发现。 +`dsh-ops/discovery` 在每个符合条件的 Native 作用域中发布 `ops_discover`。可描述所需能力(`metadata revision hash`),或指定完整公开名称(`select:ops_stat`)。发现仅选择工具,不执行文件操作。成功选择在当前工具轮次中保持待激活状态;所属会话提交 `step/end` 后,为紧接着的供应商请求发布。固定版本宿主先组装提示再触发 `agent/pre-step`,因此组装时等待本插件发布队列完成,并通过公开提示服务重新组装过期快照。提交边界前的诊断组装不会激活排队名称。注册只属于调用者;父、子和同级作用域都需独立发现。 当前已导出组件。七行 bundle 安装和 0.3 profile 迁移属于后续发布切片。手动增加的行可使用: @@ -36,7 +36,7 @@ English: [Advanced tools on demand](discovery.md). 未精简的十四项 Native 文件工具加发现入口,同口径为 **3183 tokens / 14942 字节**;本切片不宣称整体通过 400 上限。下一切片精简常驻文件集合并记录完整文件/发现基线。Shell、PTC 或用户始终加载的选择必须单独计量,不能在全局节省声明中悄悄排除。 -BM-04 使用真实 Cordis 工具/提示词服务及原生 worker:已实现的 `stat` 在发现前不可见,在当前步骤组装时仍不可见,下一 pre-step 边界后出现并能执行。PDF 尚未实现,因此这是生命周期门禁,并不代表 PDF 验收已通过。 +BM-04 使用真实 Cordis 工具/提示词服务及原生 worker:已实现的 `stat` 在发现前不可见,在当前步骤组装时仍不可见,紧接着的真实 AgentLoop 供应商请求中出现并能执行。PDF 尚未实现,因此这是生命周期门禁,并不代表 PDF 验收已通过。 浏览器配置多选界面属于独立工作:普通配置校验不会自动生成宿主选择器,界面必须使用固定宿主版本的浏览器插件配置 API。 diff --git a/package-lock.json b/package-lock.json index 57960e2e..dfa961bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "4.0.4", + "@deepseek-ai/dsh-agent-loop": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-local": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-observation-policy": "0.2.0-rc.2", "@deepseek-ai/dsh-ptc-runtime-node": "0.2.0-rc.2", @@ -117,6 +118,31 @@ } } }, + "node_modules/@deepseek-ai/dsh-agent-loop": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-loop/-/dsh-agent-loop-0.2.0-rc.2.tgz", + "integrity": "sha512-Ayi1L7s1LIJAq3iCCRFDCGM9Y2lriGlV6nblIYXhKrq+J8bUFQtOdfBYNE1N39WcYb3CwSvTNHDtzZ4oW6MZ6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-brand": "0.2.0-rc.2", + "@deepseek-ai/dsh-util-values": "0.2.0-rc.2", + "@deepseek-ai/schemastery": "~3.18.4", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-agent": "0.2.0-rc.2", + "@deepseek-ai/dsh-invariants": "0.2.0-rc.2", + "@deepseek-ai/dsh-llm": "0.2.0-rc.2", + "@deepseek-ai/dsh-scope": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2", + "@deepseek-ai/dsh-session-persistence": "0.2.0-rc.2", + "@deepseek-ai/dsh-session-projection": "0.2.0-rc.2", + "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2", + "@deepseek-ai/dsh-tools": "0.2.0-rc.2" + } + }, "node_modules/@deepseek-ai/dsh-attachment": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-attachment/-/dsh-attachment-0.2.0-rc.2.tgz", @@ -385,6 +411,23 @@ "@deepseek-ai/dsh-scope": "0.2.0-rc.2" } }, + "node_modules/@deepseek-ai/dsh-session-persistence": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-persistence/-/dsh-session-persistence-0.2.0-rc.2.tgz", + "integrity": "sha512-2OoRHZYWi5Vy0+1bPU37Y/NTh6uQiZ3VdpvDmW1oCgsapQr2JS/Lbody1gXOMJ1KXglCSLmePgTMC00uKw/UIg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/dsh-util-values": "0.2.0-rc.2" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-brand": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2", + "@deepseek-ai/dsh-timeout": "0.2.0-rc.2" + } + }, "node_modules/@deepseek-ai/dsh-session-projection": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-projection/-/dsh-session-projection-0.2.0-rc.2.tgz", diff --git a/package.json b/package.json index 9dbb25bc..b4da96a1 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "4.0.4", + "@deepseek-ai/dsh-agent-loop": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-local": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-observation-policy": "0.2.0-rc.2", "@deepseek-ai/dsh-ptc-runtime-node": "0.2.0-rc.2", diff --git a/src/discovery/index.ts b/src/discovery/index.ts index e4bfd045..a423a7fd 100644 --- a/src/discovery/index.ts +++ b/src/discovery/index.ts @@ -7,7 +7,7 @@ import { resolveDiscoveryConfig } from '../config.js' import { NativeFileTools } from '../file/runtime.js' import { nativeProviders, registerNativeProvider, claimNativeSelection } from '../file/result-provider.js' import { ownScopes } from '../scoped-ownership.js' -import { isNativePresentation } from '../presentation.js' +import { isNativePresentation, isPresentationProbe } from '../presentation.js' import { mountResultLifecycle } from '../kernel/result-lifecycle.js' import { failure, resolveWorker } from '../file/worker.js' import { capability } from '../capabilities/registry.js' @@ -48,6 +48,12 @@ export function apply(root: Context, raw: unknown): void { mountResultLifecycle(ctx) let ownership: Awaited> | undefined let stopped = false, mutating = false, epoch = 0, chain = Promise.resolve() + let settled = chain + const drain = async () => { + let current: Promise + do { current = chain; await current } while (current !== chain) + settled = current + } const clear = (entry: Entry) => { entry.owned.clear() for (const dispose of entry.disposers.splice(0)) { @@ -162,6 +168,13 @@ export function apply(root: Context, raw: unknown): void { }) ctx.on('tools/change', () => { if (!ownership?.publishing()) void reconcile() }) ctx.on('system-prompt/assemble', async (_assembly, context, next) => { + // The pinned host assembles BEFORE agent/pre-step. Wait for publication + // queued by the previous committed step, then obtain a fresh public assembly. + if (!isPresentationProbe(context)) { + const entry = entries.get(context.scope as Agent), before = chain, pending = chain !== settled + await entry?.ready; await drain() + if (pending || before !== chain) return prompt.assemble(context) + } const entry = entries.get(context.scope as Agent) // Snapshot ownership before yielding: another assembly may withdraw it while // this request still contains an already-rendered Native SDK section. @@ -176,6 +189,15 @@ export function apply(root: Context, raw: unknown): void { return prompt.assemble(context) }) ctx.on('session/event', (session, event) => { + if (event.type === 'step/end') { + let changed = false + for (const [agent, entry] of entries) if (agent.session === session && ownership?.owns(agent) && entry.pending.size) { + for (const tool of entry.pending) if (permitted(agent, tool)) entry.selected.add(tool) + entry.pending.clear(); changed = true + } + if (changed) void reconcile() + return + } if (event.type !== 'sandbox/mode') return mutating = true try { diff --git a/src/presentation.ts b/src/presentation.ts index 8a24a53a..4a3d60da 100644 --- a/src/presentation.ts +++ b/src/presentation.ts @@ -1,14 +1,18 @@ -/** Inspect public presentation before publishing a replacement. This result is - * only a boolean; the inspected assembly never becomes a provider request. */ -import type { AssembleContext, SystemPrompt } from '@deepseek-ai/dsh-system-prompt' +/** Inspect public presentation before publishing a replacement. Inspection is + * marked by exact context identity and never becomes a provider request. */ +import type { AssembleContext, PromptAssembly, SystemPrompt } from '@deepseek-ai/dsh-system-prompt' const probes = new WeakSet() export function isPresentationProbe(context: AssembleContext): boolean { return probes.has(context) } -export async function isNativePresentation(prompt: Pick, scope: NonNullable): Promise { - const context: AssembleContext = { scope } +export async function inspectPresentation(prompt: Pick, scope: NonNullable, signal?: AbortSignal): Promise { + const context: AssembleContext = { scope, signal } probes.add(context) try { - const assembly = await prompt.assemble(context) - return !assembly.sections.some(section => section.name === 'tools:ptc-only' && Boolean(section.text.trim())) + return await prompt.assemble(context) } finally { probes.delete(context) } } + +export async function isNativePresentation(prompt: Pick, scope: NonNullable): Promise { + const assembly = await inspectPresentation(prompt, scope) + return !assembly.sections.some(section => section.name === 'tools:ptc-only' && Boolean(section.text.trim())) +} diff --git a/src/ptc/index.ts b/src/ptc/index.ts index 6e7d431e..542f662a 100644 --- a/src/ptc/index.ts +++ b/src/ptc/index.ts @@ -4,6 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { ToolDefinition, ToolRunContext } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-sandbox-policy' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' +import { inspectPresentation, isPresentationProbe } from '../presentation.js' import { WORKER_TIMEOUT_MS } from '../worker-protocol.js' import { FILE_TOOLS, PTC_TOOLS } from '../capabilities/registry.js' import { PTC_SECTION, definition } from './contract.js' @@ -45,6 +46,12 @@ export function apply(root: Context, rawConfig: unknown): void { const entries = new Map() const active = new Map }>() let stopped = false, mutating = false, epoch = 0, chain = Promise.resolve(), lastSnapshot = '' + let settled = chain + const drain = async () => { + let current: Promise + do { current = chain; await current } while (current !== chain) + settled = current + } const ids = new WeakMap(); let sequence = 0 const snapshot = (): string => JSON.stringify([...entries.keys()].map(agent => tools.schemas(agent).map(schema => { const def = tools.get(schema.name, agent) @@ -57,7 +64,7 @@ export function apply(root: Context, rawConfig: unknown): void { try { dispose() } catch (error) { ctx.logger.warn('dsh-ops: PTC teardown failed; continuing:', error) } } } - const ptcOnly = async (agent: Agent, signal?: AbortSignal): Promise => Boolean(ctx.get('ptcRuntime')) && isPtc(await prompt.assemble({ scope: agent, signal })) + const ptcOnly = async (agent: Agent, signal?: AbortSignal): Promise => Boolean(ctx.get('ptcRuntime')) && isPtc(await inspectPresentation(prompt, agent, signal)) const abortOwner = (agent: Agent, reason: string) => { for (const [controller, work] of active) if (work.owner === agent) controller.abort(new Error(reason)) } @@ -169,6 +176,13 @@ export function apply(root: Context, rawConfig: unknown): void { }) ctx.on('tools/change', () => { if (!ownership?.publishing()) void reconcile(true) }) ctx.on('system-prompt/assemble', async (_assembly, context, next) => { + // The pinned host assembles BEFORE agent/pre-step. Wait for publication + // queued by the previous committed step, then obtain a fresh public assembly. + if (!isPresentationProbe(context)) { + const entry = entries.get(context.scope as Agent), before = chain, pending = chain !== settled + await entry?.ready; await drain() + if (pending || before !== chain) return prompt.assemble(context) + } const entry = entries.get(context.scope as Agent), heldPtc = Boolean(entry?.ptc && entry.owned.size) const result = await next() if (!entry || !heldPtc || isPtc(result)) return result @@ -179,6 +193,15 @@ export function apply(root: Context, rawConfig: unknown): void { return prompt.assemble(context) }) ctx.on('session/event', (session, event) => { + if (event.type === 'step/end') { + let changed = false + for (const [agent, entry] of entries) if (agent.session === session && ownership?.owns(agent) && entry.pending.size) { + for (const tool of entry.pending) if (entry.ptc) entry.selected.add(tool) + entry.pending.clear(); changed = true + } + if (changed) void reconcile(true) + return + } if (event.type !== 'sandbox/mode') return for (const [agent, entry] of entries) if (agent.session === session) { entry.pending.clear(); abortOwner(agent, 'Sandbox authority changed') } void reconcile(true) diff --git a/test/lib/agent-loop.mjs b/test/lib/agent-loop.mjs new file mode 100644 index 00000000..1f3eb957 --- /dev/null +++ b/test/lib/agent-loop.mjs @@ -0,0 +1,64 @@ +/** Actual AgentLoop and Session with a scripted provider; no network model requests. */ +import { Context } from '@deepseek-ai/cordis' +import Agents from '@deepseek-ai/dsh-agent' +import Sessions from '@deepseek-ai/dsh-session' +import Projections from '@deepseek-ai/dsh-session-projection' +import Prompt from '@deepseek-ai/dsh-system-prompt' +import Tools from '@deepseek-ai/dsh-tools' +import Loop from '@deepseek-ai/dsh-agent-loop' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import Policy, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import Sandbox from '@deepseek-ai/dsh-sandbox-local' +import FileSystem from '@deepseek-ai/dsh-fs-local' +import NodePtc from '@deepseek-ai/dsh-ptc-runtime-node' +import * as file from '../../lib/components/file/index.js' +import * as discovery from '../../lib/components/discovery/index.js' +import * as ptc from '../../lib/components/ptc/index.js' +import { assert } from './harness.mjs' +import { WORKER } from './boundary.mjs' +let identity = 0 +export async function bootLoop(root, { ptcMode = false, discoveryConfig = {} } = {}) { + const ctx = new Context(); ctx.logger.level = 0 + const queues = new Map(), requests = [], results = [], errors = [] + for (const plugin of [Agents, Sessions, Projections, Prompt]) await ctx.plugin(plugin, {}) + await ctx.plugin(Tools, { mode: ptcMode ? 'ptc' : 'native' }) + await ctx.plugin(Policy, { mode: 'workspace-write', workspaceRoot: root }) + await ctx.plugin(Subprocess); await ctx.plugin(Sandbox); await ctx.plugin(FileSystem, { cwd: root }) + if (ptcMode) await ctx.plugin(NodePtc, {}) + await ctx.plugin(inner => { + inner.provide('llm', { async prepareCall(config) { return { config, adapterDefaults: {}, stream: async function* (request) { + assert.equal(request.purpose, undefined) + const queue = queues.get(request.sessionId) + assert.ok(queue?.length, 'unscripted provider request is forbidden') + const script = queue.shift(), calls = typeof script === 'function' ? await script(request) : script + requests.push({ sessionId: request.sessionId, tools: request.tools, messages: request.messages, toolCalls: calls.length }) + for (const [index, call] of calls.entries()) yield { type: 'tool-call-delta', index, id: 'loop-call-' + ++identity, name: call.name, argumentsDelta: JSON.stringify(call.args) } + if (!calls.length) yield { type: 'text-delta', index: 0, text: 'Fixture complete.' } + yield { type: 'finish', reason: { kind: 'stop' } } + } } } }) + inner.on('tools/execute', async (exec, next) => { const result = await next(); results.push({ exec, result }); return result }) + inner.on('agent/error', ({ error }) => errors.push(String(error?.stack ?? error))) + }) + await ctx.plugin(file, { binaryPath: WORKER, required: true }) + await ctx.plugin(discovery, { binaryPath: WORKER, required: true, ...discoveryConfig }) + const ptcFiber = ptcMode ? await ctx.plugin(ptc, { binaryPath: WORKER, required: true }) : undefined + await ctx.plugin(Loop, { agents: [], maxParallelToolCalls: 3 }) + async function agent(parentAgent) { + const { agent } = await ctx.get('agents').create({ sessionId: 'real-loop-' + ++identity, meta: { cwd: root }, parentAgent, + agentOptions: { provider: 'fixture', model: 'scripted' } }) + // Exercise a real committed mode event immediately before the first assembly. + setSandboxMode(agent.session, 'workspace-write') + return agent + } + async function run(agent, scripts) { + const start = { requests: requests.length, results: results.length, errors: errors.length } + queues.set(agent.session.id, [...scripts]) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Run the deterministic fixture.' }] })) + await agent.whenIdle() + assert.deepEqual(errors.slice(start.errors), []) + assert.equal(queues.get(agent.session.id).length, 0) + return { requests: requests.slice(start.requests), results: results.slice(start.results) } + } + return { ctx, agent, run, ptcFiber, close: () => ctx.fiber.dispose() } +} diff --git a/test/progressive-loop.test.mjs b/test/progressive-loop.test.mjs new file mode 100644 index 00000000..4b0bc9bd --- /dev/null +++ b/test/progressive-loop.test.mjs @@ -0,0 +1,86 @@ +import fs from 'node:fs' +import path from 'node:path' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { requireWorker } from './lib/boundary.mjs' +import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { bootLoop } from './lib/agent-loop.mjs' +requireWorker('progressive-loop') +const names = request => request.tools.map(tool => tool.name) +const text = request => JSON.stringify(request.messages) +const sdkCall = code => ({ name: 'run_code', args: { description: 'Exercise next-step SDK publication', code } }) + +await test('real Native loop exposes discovery at startup and activates exactly in the next provider request', () => withTempDir('native-next-step', async root => { + fs.writeFileSync(path.join(root, 'note'), 'fixture\n') + const host = await bootLoop(root) + try { + const owner = await host.agent(), sibling = await host.agent() + let diagnostic = false + host.ctx.on('tools/execute', async (exec, next) => { + const result = await next() + if (exec.name === 'ops_discover') { + const assembly = await host.ctx.get('systemPrompt').assemble({ scope: owner }) + assert.equal(assembly.tools.some(tool => tool.name === 'ops_stat'), false, 'current-step diagnostic assembly cannot activate pending tools') + diagnostic = true + } + return result + }) + const run = await host.run(owner, [request => { + assert.ok(names(request).includes('ops_discover'), 'discovery must be visible in the first real provider request') + assert.equal(names(request).includes('ops_stat'), false) + return [{ name: 'ops_discover', args: { query: 'select:ops_stat' } }] + }, request => { + assert.ok(names(request).includes('ops_stat'), 'the second request must already include the selected schema') + assert.equal(host.ctx.get('tools').get('ops_stat', sibling), undefined) + return [{ name: 'ops_stat', args: { path: 'note' } }] + }, []]) + assert.equal(diagnostic, true); assert.equal(run.requests.length, 3) + const stat = run.results.find(value => value.exec.name === 'ops_stat').result + assert.equal(stat.isError, false); assert.equal(stat.value.exists, true) + } finally { await host.close() } +})) + +await test('real PTC loop keeps the current run_code snapshot fixed and publishes the requested SDK at request two', () => withTempDir('ptc-next-step', async root => { + fs.writeFileSync(path.join(root, 'note'), 'fixture\n') + const host = await bootLoop(root, { ptcMode: true }) + try { + const owner = await host.agent(), sibling = await host.agent() + const run = await host.run(owner, [request => { + assert.deepEqual(names(request), ['run_code']); assert.doesNotMatch(text(request), /ops_stat:/) + return [sdkCall("const selected = await tools.ops_sdk({tools:['stat']}); let unavailable = false; try { await tools.ops_stat({path:'note'}); } catch { unavailable = true; } return {selected,unavailable};")] + }, request => { + assert.deepEqual(names(request), ['run_code']); assert.match(text(request), /ops_stat:/) + assert.equal(host.ctx.get('tools').get('ops_stat', sibling), undefined) + return [sdkCall("return await tools.ops_stat({path:'note'});")] + }, []]) + const calls = run.results.filter(value => value.exec.name === 'run_code') + assert.equal(calls.length, 2) + assert.equal(calls[0].result.isError, false, JSON.stringify(calls[0].result)) + assert.equal(calls[0].result.value.result.selected.status, 'completed') + assert.equal(calls[0].result.value.result.unavailable, true) + assert.equal(calls[1].result.value.result.data.exists, true) + } finally { await host.close() } +})) +await test('real committed authority changes cancel pending activation while always-loaded readers are ready at startup', () => withTempDir('loop-mode-change', async root => { + fs.writeFileSync(path.join(root, 'note'), 'unchanged') + const host = await bootLoop(root, { discoveryConfig: { alwaysLoaded: ['inspect_local_file'] } }) + try { + const owner = await host.agent() + host.ctx.on('tools/execute', async (exec, next) => { + const result = await next() + if (exec.name === 'ops_discover') setSandboxMode(owner.session, 'read-only') + return result + }) + const run = await host.run(owner, [request => { + assert.ok(names(request).includes('ops_inspect_local_file')) + assert.equal(names(request).includes('ops_edit_many'), false) + return [{ name: 'ops_discover', args: { query: 'select:ops_edit_many' } }] + }, request => { + assert.equal(names(request).includes('ops_edit_many'), false, 'permission change must discard the pending choice before step/end') + assert.ok(names(request).includes('ops_inspect_local_file')) + return [] + }]) + assert.equal(run.requests.length, 2) + assert.equal(fs.readFileSync(path.join(root, 'note'), 'utf8'), 'unchanged') + } finally { await host.close() } +})) +report('progressive-loop') From 17884f2659c3fcc534cd35a4fd63ef8af64c41c1 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:12:28 +0800 Subject: [PATCH 043/118] feat(documents): read bounded typed CSV and TSV rows --- docs/CHANGELOG.md | 10 + docs/native-kernel.md | 45 +++++ ...37\347\224\237\345\206\205\346\240\270.md" | 36 ++++ native/Cargo.lock | 12 ++ native/Cargo.toml | 1 + native/crates/ops-documents/Cargo.toml | 14 ++ native/crates/ops-documents/src/decode.rs | 84 ++++++++ native/crates/ops-documents/src/lib.rs | 129 ++++++++++++ native/crates/ops-documents/src/parse.rs | 42 ++++ native/crates/ops-documents/src/tests.rs | 52 +++++ native/crates/ops-results/src/spill.rs | 2 +- native/src/lib.rs | 1 + native/src/main.rs | 2 +- test/package-source.test.mjs | 5 +- test/table-worker.test.mjs | 183 ++++++++++++++++++ 15 files changed, 615 insertions(+), 3 deletions(-) create mode 100644 native/crates/ops-documents/Cargo.toml create mode 100644 native/crates/ops-documents/src/decode.rs create mode 100644 native/crates/ops-documents/src/lib.rs create mode 100644 native/crates/ops-documents/src/parse.rs create mode 100644 native/crates/ops-documents/src/tests.rs create mode 100644 test/table-worker.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ddd952ec..416f4e92 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,16 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Add bounded CSV/TSV worker observations (#261, 15a/16) + +- Add an ops-documents workspace crate with strict UTF-8/UTF-16 CSV/TSV decoding, positional + string cells, physical line ranges, bounded row pages and safe preceding rows on parse failures. +- Separate complete physical scans from requested pages and reference capture; reuse no-follow, + credential, readonly and automatic-spill boundaries without new external dependencies. +- 新增有界 CSV/TSV worker 读取,严格解码、保留原字符串与物理行范围;错误保留此前有效行, + 并分别报告扫描、分页和引用捕获完整性,不新增外部依赖。发现/宿主注册由后续切片接入。 + + ### Deduplicate Native reads with current-context evidence (#261, 10/16) - Replace repeated line bodies only when the exact successful prior read is present in the latest completed ordinary model request and every file is freshly revalidated. Keep revisions, coverage, paging notices and canonical values. Compaction, redaction, authority changes and uncertain evidence restore full output. diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 91c9e419..4bb8b670 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -12,6 +12,7 @@ and writes one JSON result. Splitting the kernel does not introduce a persistent | `ops-files` | No-follow handles, reads, CAS publication, metadata, tree operations, directory sync | protocol | | `ops-search` | Glob, grep and replacement orchestration over guarded file primitives | protocol, files | | `ops-results` | Bounded generation store, result pages and immutable job-audit access | protocol, files | +| `ops-documents` | Strict bounded CSV/TSV Unicode decoding and typed positional rows | protocol, files | | `ops-batch` | Plans, write-ahead transitions and conservative recovery | protocol, files, results | The root crate routes requests and opens audit storage only for batch/result capabilities. @@ -127,3 +128,47 @@ cannot create a reference. Cache refusal retains an incomplete bounded head with completed mutations must not be repeated. Reference metadata is budget-checked before publication, so an unusably small head budget does not create an unreachable blob. Calls without trusted storage retain the legacy transport contract; the host supplies recovery guidance when storage is unavailable. + +## CSV/TSV worker observations (15a/16) + +The new ops-documents crate handles `table_read` in the same one-shot worker. This slice exposes +only the worker capability; discovery/host registration is separate. It adds no external packages: +serde/serde_json and sha2 reuse the existing locked workspace dependency graph. PDF/DOC parsing +remains separate work requiring its own dependency review. + +Arguments are `{ path, format?, encoding?, header?, offset?, limit? }`. The host resolves path +against current cwd before sending an absolute path. Format is csv or tsv, inferred as tsv only +for a .tsv extension (case-insensitive), otherwise csv. Encoding defaults to utf-8; utf-16le and +utf-16be are explicit alternatives. Valid matching BOMs are consumed, contradictory BOMs and +malformed Unicode are rejected without replacement decoding. Without a BOM the explicit encoding +is authoritative; no unreliable byte-pattern guessing is performed. + +Header defaults true. Duplicate/empty column names remain positional; header=false returns an +empty columns array and infers columnCount from the first record. Data rows are +`{ row, cells: string[], lineStart, lineEnd }`: row is 1-based excluding the header, while lineStart +and lineEnd count physical LF lines including the header and quoted multiline fields. Values are +never guessed as numbers, dates or booleans. Quoted fields support delimiters, escaped double quotes +and exact embedded LF/CRLF. Record separators are LF/CRLF; bare CR outside quotes, trailing text +after a closing quote, unquoted quotes and inconsistent widths are errors. Blank records contain +one empty cell; a final newline adds no phantom record. Empty files have zero rows. + +Offset is 1..100000 data rows, default 1; limit is 1..1000, default 200. Each call streams a bounded +physical scan, retains only its requested rows, and reports rowsObserved, bytesRead (including +bounded decoder read-ahead), scanComplete and pageComplete separately. rowCount/total and the +raw-byte SHA-256 revision appear only after a clean EOF with unchanged observed file metadata. +Complete also requires no remaining rows after the requested page. A malformed suffix preserves +preceding valid rows with status=partial/error and no invented total, even when the requested page +was already fully observed. Metadata changes invalidate the complete-scan claim. + +Hard limits are 32 MiB input, 100000 data records plus one bounded EOF lookahead, 1 MiB decoded +record, 1024 columns, 8192 serialized header bytes and 30 seconds between records (the host/worker +request deadline also applies). A stopped scan returns explicit scan-budget/time recovery; row +paging does not bypass these physical limits. Use a dedicated streaming table reader for larger +inputs. Error codes include TABLE_PARSE_ERROR, ERR_INVALID_ENCODING, OUTPUT_LIMIT, FILE_CHANGED +and DEADLINE_EXCEEDED; preflight authority failures keep the existing policy codes. + +Credential and no-follow checks reuse ops-files. Readonly observations create no audit/cache +objects. Oversized requested rows use the existing bounded spill sink before display clipping: +references contain exact typed row JSON, source retains table/coverage facts, and captureComplete +is independent of scanComplete. Page with the original referenceRoot. Cache effects never imply +business writes; table_read is classified as read-only even in a writable session. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index a0d8e41f..c0dd4b09 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -11,6 +11,7 @@ English: [native-kernel.md](native-kernel.md). | `ops-files` | 禁止跟随链接的句柄、读取、CAS 发布、元数据、树操作、目录同步 | protocol | | `ops-search` | 基于受保护文件原语的 glob、grep 和替换编排 | protocol、files | | `ops-results` | 有界的代际存储、结果分页和只读作业审计访问 | protocol、files | +| `ops-documents` | 严格有界 CSV/TSV Unicode 解码与保留位置的类型化行 | protocol、files | | `ops-batch` | 计划、预写状态转移和保守恢复 | protocol、files、results | 根 crate 负责分派请求,仅为 batch/result 能力打开审计存储。作业归属校验与协议记录放在一起, @@ -103,3 +104,38 @@ complete=true 后 JSON.parse;须验证连续索引、偏移及结束标记。S 不完整的有界开头,并提示范围/十六进制恢复;已完成的修改不能重跑。发布前先验证引用元数据 能否放进头部预算,避免因预算过小而留下无法取得的快照。没有可信存储时保留旧传输契约, 由宿主在存储不可用时补充恢复指导。 + +## CSV/TSV worker 观察(15a/16) + +新增 ops-documents crate,在同一个单次 worker 内处理 table_read。本切片只增加 worker 能力, +宿主注册及发现入口另行接入。没有新增外部包:serde/serde_json 与 sha2 复用既有锁定依赖。 +PDF/DOC 解析仍是需要独立依赖审查的后续工作。 + +参数为 `{ path, format?, encoding?, header?, offset?, limit? }`。宿主先相对于当前 cwd 解析 +path,再发送绝对路径。format 为 csv/tsv;仅 .tsv 扩展名(忽略大小写)推断为 tsv,否则为 csv。 +encoding 默认 utf-8,也可明确指定 utf-16le/utf-16be。匹配的 BOM 被消费,矛盾 BOM 和非法 +Unicode 被拒绝,绝不使用替代字符解码。无 BOM 时以明确指定的编码为准,不做不可靠的字节猜测。 + +header 默认 true;重复或空列名按原位置保留。header=false 返回空 columns 数组,并从首条记录 +确定 columnCount。数据行为 `{ row, cells: string[], lineStart, lineEnd }`:row 从 1 开始且 +不含标题;lineStart/lineEnd 按实际 LF 计数,包含标题及引号内的多行。单元格始终保留字符串, +不猜测数字、日期或布尔类型。引号字段支持分隔符、双写引号和原样嵌入的 LF/CRLF。记录分隔符 +为 LF/CRLF;引号外单独 CR、闭合引号后的多余文本、未加引号字段中的引号以及列数不一致均报错。 +空记录含一个空单元格;末尾换行不产生多余记录,空文件为零行。 + +offset 为不含标题的 1..100000 数据行,默认 1;limit 为 1..1000,默认 200。每次调用流式执行 +有界物理扫描,只保留所请求的行,并分别报告 rowsObserved、bytesRead(含有界预读)、 +scanComplete 和 pageComplete。只有正常 EOF 且文件元数据观察未改变时,才返回 rowCount/total +和原始字节 SHA-256 revision。complete 还要求所请求页之后没有剩余行。即使目标页已观察完整, +非法后缀仍保留此前有效行、返回 status=partial/error,且不编造总数。元数据变化使完整扫描声明失效。 + +硬上限:32 MiB 输入、100000 条数据记录(另加一次有界 EOF 探测)、1 MiB 解码后记录、1024 列、 +8192 字节序列化标题,以及记录间检查的 30 秒截止时间;宿主/worker 请求截止时间仍同时适用。 +提前停止时明确返回 scan-budget/time 和恢复指导;行分页不能绕过这些物理扫描限制,更大的输入 +应使用专门的流式表格读取器。错误包括 TABLE_PARSE_ERROR、ERR_INVALID_ENCODING、OUTPUT_LIMIT、 +FILE_CHANGED、DEADLINE_EXCEEDED;预检权限拒绝沿用原有策略错误码。 + +凭据和禁止跟随链接检查复用 ops-files。只读观察不创建审计或缓存对象。请求行过大时在显示 +裁切前使用既有有界捕获器:引用保留可精确重建的类型化行 JSON,source 保留表格/覆盖事实, +captureComplete 独立于 scanComplete。分页须使用原 referenceRoot。缓存副作用不代表业务写入; +即使会话可写,table_read 也按只读操作分类。 diff --git a/native/Cargo.lock b/native/Cargo.lock index bf053112..d5af1de9 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -128,6 +128,7 @@ dependencies = [ "cap-fs-ext", "cap-std", "ops-batch", + "ops-documents", "ops-files", "ops-protocol", "ops-results", @@ -305,6 +306,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "ops-documents" +version = "0.3.1" +dependencies = [ + "ops-files", + "ops-protocol", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "ops-files" version = "0.3.1" diff --git a/native/Cargo.toml b/native/Cargo.toml index 48a3bf18..0a36acb7 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -18,6 +18,7 @@ cap-fs-ext = "3.4" serde_json = "1.0" ops-protocol = { path = "crates/ops-protocol" } ops-files = { path = "crates/ops-files" } +ops-documents = { path = "crates/ops-documents" } ops-search = { path = "crates/ops-search" } ops-results = { path = "crates/ops-results" } ops-batch = { path = "crates/ops-batch" } diff --git a/native/crates/ops-documents/Cargo.toml b/native/crates/ops-documents/Cargo.toml new file mode 100644 index 00000000..49ad6617 --- /dev/null +++ b/native/crates/ops-documents/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ops-documents" +version = "0.3.1" +edition = "2024" +license = "MIT" +rust-version = "1.88" +publish = false + +[dependencies] +ops-protocol = { path = "../ops-protocol" } +ops-files = { path = "../ops-files" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10.9" diff --git a/native/crates/ops-documents/src/decode.rs b/native/crates/ops-documents/src/decode.rs new file mode 100644 index 00000000..2896d121 --- /dev/null +++ b/native/crates/ops-documents/src/decode.rs @@ -0,0 +1,84 @@ +//! Strict streaming Unicode decoding. Invalid bytes/surrogates are never replaced. +use ops_protocol::{Failure, Result}; +use sha2::{Digest, Sha256}; +use std::io::{BufReader, Read}; +pub const MAX_BYTES: usize = 32 * 1024 * 1024; +#[derive(Clone, Copy)] +pub enum Encoding { Utf8, Utf16Le, Utf16Be } +impl Encoding { + pub fn parse(name: &str) -> Result { + match name.to_ascii_lowercase().as_str() { + "utf-8" => Ok(Self::Utf8), "utf-16le" => Ok(Self::Utf16Le), "utf-16be" => Ok(Self::Utf16Be), + _ => Err(invalid("Supported table encodings: utf-8, utf-16le, utf-16be")), + } + } + pub fn name(self) -> &'static str { match self { Self::Utf8 => "utf-8", Self::Utf16Le => "utf-16le", Self::Utf16Be => "utf-16be" } } +} +fn invalid(message: &str) -> Failure { Failure::new("ERR_INVALID_ENCODING", message) } +struct Tracked { input: R, bytes: usize, hash: Sha256 } +impl Read for Tracked { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let room = buffer.len().min((MAX_BYTES + 1).saturating_sub(self.bytes)); + let count = self.input.read(&mut buffer[..room])?; + self.bytes += count; self.hash.update(&buffer[..count]); Ok(count) + } +} +pub struct Decoder { input: BufReader>, encoding: Encoding, first: bool, consumed: usize, prefix: [u8; 3], prefix_len: usize, prefix_at: usize, pub line: usize } +impl Decoder { + pub fn new(input: R, encoding: Encoding) -> Result { + let mut decoder = Self { input: BufReader::new(Tracked { input, bytes: 0, hash: Sha256::new() }), encoding, first: true, consumed: 0, prefix: [0; 3], prefix_len: 0, prefix_at: 0, line: 1 }; + while decoder.prefix_len < 3 { + let count = decoder.input.read(&mut decoder.prefix[decoder.prefix_len..])?; + if count == 0 { break; } + decoder.prefix_len += count; + } + let prefix = &decoder.prefix[..decoder.prefix_len]; + let mismatch = (prefix.starts_with(&[0xef, 0xbb, 0xbf]) && !matches!(encoding, Encoding::Utf8)) + || (prefix.starts_with(&[0xff, 0xfe]) && !matches!(encoding, Encoding::Utf16Le)) + || (prefix.starts_with(&[0xfe, 0xff]) && !matches!(encoding, Encoding::Utf16Be)); + if mismatch { return Err(invalid("Table BOM conflicts with the requested encoding")); } + Ok(decoder) + } + pub fn bytes_read(&self) -> usize { self.input.get_ref().bytes } + pub fn revision(&self) -> String { format!("{:x}", self.input.get_ref().hash.clone().finalize()) } + fn byte(&mut self) -> Result> { + let mut byte = [0]; + if self.prefix_at < self.prefix_len { byte[0] = self.prefix[self.prefix_at]; self.prefix_at += 1; } + else if self.input.read(&mut byte)? == 0 { return Ok(None); } + self.consumed += 1; + if self.consumed > MAX_BYTES { return Err(Failure::new("OUTPUT_LIMIT", "Table scan reached the 32 MiB byte limit")); } + Ok(Some(byte[0])) + } + fn required(&mut self) -> Result { self.byte()?.ok_or_else(|| invalid("Incomplete Unicode sequence at end of table")) } + fn unit(&mut self) -> Result> { + let Some(first) = self.byte()? else { return Ok(None); }; + let second = self.required()?; + Ok(Some(match self.encoding { Encoding::Utf16Le => u16::from_le_bytes([first, second]), _ => u16::from_be_bytes([first, second]) })) + } + pub fn next(&mut self) -> Result> { + let character = match self.encoding { + Encoding::Utf8 => { + let Some(first) = self.byte()? else { return Ok(None); }; + let width = match first { 0..=0x7f => 1, 0xc2..=0xdf => 2, 0xe0..=0xef => 3, 0xf0..=0xf4 => 4, _ => return Err(invalid("Invalid UTF-8 table bytes")) }; + let mut bytes = [0; 4]; bytes[0] = first; + for byte in bytes.iter_mut().take(width).skip(1) { *byte = self.required()?; } + std::str::from_utf8(&bytes[..width]).map_err(|_| invalid("Invalid UTF-8 table bytes"))?.chars().next().unwrap() + }, + Encoding::Utf16Le | Encoding::Utf16Be => { + let Some(first) = self.unit()? else { return Ok(None); }; + if (0xd800..=0xdbff).contains(&first) { + let second = self.unit()?.ok_or_else(|| invalid("Unpaired UTF-16 high surrogate"))?; + if !(0xdc00..=0xdfff).contains(&second) { return Err(invalid("Invalid UTF-16 surrogate pair")); } + char::from_u32(0x10000 + ((u32::from(first) - 0xd800) << 10) + u32::from(second) - 0xdc00).unwrap() + } else { char::from_u32(u32::from(first)).ok_or_else(|| invalid("Unpaired UTF-16 low surrogate"))? } + }, + }; + if self.first { + self.first = false; + if character == '\u{fffe}' { return Err(invalid("Table BOM conflicts with the requested byte order")); } + if character == '\u{feff}' { return self.next(); } + } + if character == '\n' { self.line += 1; } + Ok(Some(character)) + } +} diff --git a/native/crates/ops-documents/src/lib.rs b/native/crates/ops-documents/src/lib.rs new file mode 100644 index 00000000..713576a9 --- /dev/null +++ b/native/crates/ops-documents/src/lib.rs @@ -0,0 +1,129 @@ +//! Bounded table observations; cells stay strings, and incomplete traversals never invent totals. +#![forbid(unsafe_code)] +mod decode; +mod parse; +use decode::{Decoder, Encoding}; +use ops_protocol::{Failure, Request, Result, capture::json_size}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::{path::{Path, PathBuf}, fs::{File, Metadata}, time::{Duration, Instant}}; +const MAX_ROWS: usize = 100_000; +const INLINE_BYTES: usize = 96 * 1024; +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Args { + path: String, + format: Option, + #[serde(default = "utf8")] encoding: String, + #[serde(default = "yes")] header: bool, + #[serde(default = "one")] offset: usize, + #[serde(default = "two_hundred")] limit: usize, +} +fn utf8() -> String { "utf-8".into() } +fn yes() -> bool { true } +fn one() -> usize { 1 } +fn two_hundred() -> usize { 200 } +fn recovery(error: &mut Failure) { + if error.code == "OUTPUT_LIMIT" { + error.next_action = "Use a dedicated streaming table reader for this bounded scan/record limit; ordinary row paging does not bypass physical scan limits. Do not assume an unobserved suffix is empty.".into(); + } else if error.code == "TABLE_PARSE_ERROR" { + error.next_action = "Inspect the reported record and correct quoting or column counts; preceding complete rows remain observations.".into(); + } else if error.code == "FILE_CHANGED" { + error.next_action = "Acquire a new stable observation before relying on totals or revision; do not treat these rows as a complete snapshot.".into(); + } else if error.code == "DEADLINE_EXCEEDED" { + error.next_action = "Use a dedicated streaming table reader for this scan deadline; the unobserved suffix is unknown.".into(); + } +} +struct Source { path: PathBuf, file: File, before: Metadata, started: Instant } +pub fn dispatch(request: &Request) -> Result { + if request.tool != "table_read" { return Err(Failure::new("INVALID_ARGUMENT", "Unknown document capability")); } + let args: Args = serde_json::from_value(request.args.clone())?; + if args.offset == 0 || args.offset > MAX_ROWS || args.limit == 0 || args.limit > 1000 { + return Err(Failure::new("INVALID_ARGUMENT", "table_read offset is 1-100000 data rows and limit is 1-1000")); + } + let encoding = Encoding::parse(&args.encoding)?; + let format = args.format.as_deref().unwrap_or(if Path::new(&args.path).extension().and_then(|name| name.to_str()).is_some_and(|name| name.eq_ignore_ascii_case("tsv")) { "tsv" } else { "csv" }); + let delimiter = match format { "csv" => ',', "tsv" => '\t', _ => return Err(Failure::new("INVALID_ARGUMENT", "format must be csv or tsv")) }; + request.authorize_path(Path::new(&args.path))?; + let target = ops_files::file_path::checked(&args.path)?; + request.authorize_path(&target.display)?; + let file = ops_files::file_path::read_file(&target)?.into_std(); + let before = file.metadata()?; + observe(request, &args, format, encoding, delimiter, Source { path: target.display, file, before, started: Instant::now() }) +} +fn observe(request: &Request, args: &Args, format: &str, encoding: Encoding, delimiter: char, source: Source) -> Result { + let Source { path, file, before, started } = source; + let observer = file.try_clone()?; + let mut input = Decoder::new(file, encoding)?; + let sink = request.output_sink.as_deref(); + let (mut columns, mut items) = (Vec::::new(), Vec::::new()); + let (mut rows, mut width, mut inline_bytes, mut selected) = (0usize, None, 0usize, 0usize); + let (mut scan_complete, mut output_cut, mut first) = (false, false, true); + let mut failure = None; + loop { + if started.elapsed() >= Duration::from_secs(30) { + failure = Some(Failure::new("DEADLINE_EXCEEDED", "Table scan reached its 30-second deadline")); break; + } + // One bounded lookahead proves EOF for a file with exactly MAX_ROWS data rows. + let record = match parse::next(&mut input, delimiter) { + Ok(Some(record)) => record, + Ok(None) => { scan_complete = true; break; }, + Err(error) => { failure = Some(error); break; }, + }; + if first && args.header { + first = false; + if json_size(&json!(record.cells)) > 8192 { + failure = Some(Failure::new("OUTPUT_LIMIT", "Table header exceeds the 8192-byte metadata limit")); break; + } + width = Some(record.cells.len()); columns = record.cells; continue; + } + first = false; + if rows >= MAX_ROWS { failure = Some(Failure::new("OUTPUT_LIMIT", "Table scan reached 100000 data records")); break; } + let expected = *width.get_or_insert(record.cells.len()); + if record.cells.len() != expected { + failure = Some(Failure::new("TABLE_PARSE_ERROR", format!("Data row {} has {} cells; expected {expected} at physical line {}", rows + 1, record.cells.len(), record.first))); break; + } + rows += 1; + if rows < args.offset || rows >= args.offset.saturating_add(args.limit) { continue; } + let item = json!({"row":rows,"cells":record.cells,"lineStart":record.first,"lineEnd":record.last}); + selected += 1; + if let Some(sink) = sink { sink.push(&item); } + let cost = json_size(&item) + 1; + if output_cut || inline_bytes + cost > INLINE_BYTES - 16384 { + output_cut = true; if let Some(sink) = sink { sink.clipped(); } + } else { inline_bytes += cost; items.push(item); } + } + let after = observer.metadata()?; + if before.len() != after.len() || before.modified().ok() != after.modified().ok() { + scan_complete = false; failure = Some(Failure::new("FILE_CHANGED", "Table metadata changed while reading; rows are observations, not a stable complete file")); + } + if let Some(error) = &mut failure { recovery(error); } + let next = args.offset.saturating_add(selected); + let page_observed = scan_complete || rows >= args.offset.saturating_add(args.limit).saturating_sub(1); + let more = !scan_complete || next <= rows; + let cause = if let Some(error) = &failure { match error.code.as_str() { + "ERR_INVALID_ENCODING" => "encoding", "TABLE_PARSE_ERROR" => "parse-error", "DEADLINE_EXCEEDED" => "time", "FILE_CHANGED" => "changed", _ => "scan-budget", + }} else if more { "row-limit" } else { "none" }; + let mut output = json!({"status":if failure.is_some() {"partial"} else {"completed"},"phase":"verify","changed":0,"effectState":"none", + "path":path,"format":format,"encoding":encoding.name(),"header":args.header,"columns":columns,"columnCount":width.unwrap_or(0), + "bytes":before.len(),"bytesRead":input.bytes_read(),"rowsObserved":rows,"skipped":args.offset-1, + "scanComplete":scan_complete,"pageComplete":page_observed,"complete":scan_complete && !more,"truncated":more, + "truncatedBy":cause,"nextOffset":next,"items":[]}); + if scan_complete { output["rowCount"] = json!(rows); output["total"] = json!(rows); output["omitted"] = json!(rows.saturating_sub(next.saturating_sub(1))); output["revision"] = json!(input.revision()); } + if let Some(error) = failure { + output["nextAction"] = json!(error.next_action); output["error"] = json!(error); + } + if let Some(sink) = sink { sink.source(output.clone()); } + if output_cut { + output["pageComplete"] = json!(false); output["complete"] = json!(false); output["truncated"] = json!(true); + output["nextOffset"] = json!(args.offset + items.len()); + if scan_complete { output["truncatedBy"] = json!("output-budget"); output["omitted"] = json!(rows.saturating_sub(args.offset - 1 + items.len())); } + if output.get("nextAction").is_none() { output["nextAction"] = json!("Use the stored reference when available; otherwise reduce the row limit or use bounded text/hex inspection for a very large record."); } + } + output["items"] = json!(items); + Ok(output) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/native/crates/ops-documents/src/parse.rs b/native/crates/ops-documents/src/parse.rs new file mode 100644 index 00000000..845befe6 --- /dev/null +++ b/native/crates/ops-documents/src/parse.rs @@ -0,0 +1,42 @@ +//! CSV/TSV record state machine; quoted fields retain exact embedded LF/CRLF text. +use crate::decode::Decoder; +use ops_protocol::{Failure, Result}; +use std::io::Read; +pub const MAX_COLUMNS: usize = 1024; +const MAX_RECORD_BYTES: usize = 1024 * 1024; +pub struct Record { pub cells: Vec, pub first: usize, pub last: usize } +fn malformed(message: &str, line: usize) -> Failure { + let mut error = Failure::new("TABLE_PARSE_ERROR", format!("{message} at physical line {line}")); + error.next_action = "Inspect the reported record and correct CSV/TSV quoting or column counts; preceding complete rows remain observations.".into(); error +} +pub fn next(input: &mut Decoder, delimiter: char) -> Result> { + let first = input.line; let mut cells = Vec::new(); let mut field = String::new(); + let (mut quoted, mut closed, mut started, mut count) = (false, false, false, 0usize); + loop { + let line = input.line; + let character = input.next()?; + let Some(character) = character else { + if quoted { return Err(malformed("Unclosed quoted field", line)); } + if !started { return Ok(None); } + cells.push(field); return Ok(Some(Record { cells, first, last: line })); + }; + started = true; count += character.len_utf8(); + if count > MAX_RECORD_BYTES { return Err(Failure::new("OUTPUT_LIMIT", "One decoded table record exceeds 1 MiB")); } + if quoted { + if character == '"' { quoted = false; closed = true; } else { field.push(character); } + continue; + } + if character == '"' { + if closed { field.push('"'); quoted = true; closed = false; } + else if field.is_empty() { quoted = true; } + else { return Err(malformed("Quote inside unquoted field", line)); } + } else if character == delimiter { + cells.push(std::mem::take(&mut field)); closed = false; + if cells.len() >= MAX_COLUMNS { return Err(Failure::new("OUTPUT_LIMIT", "One table record exceeds 1024 columns")); } + } else if character == '\n' || character == '\r' { + if character == '\r' && input.next()? != Some('\n') { return Err(malformed("Bare CR outside quoted field; use LF or CRLF records", line)); } + cells.push(field); return Ok(Some(Record { cells, first, last: line })); + } else if closed { return Err(malformed("Unexpected character after closing quote", line)); } + else { field.push(character); } + } +} diff --git a/native/crates/ops-documents/src/tests.rs b/native/crates/ops-documents/src/tests.rs new file mode 100644 index 00000000..30d867e2 --- /dev/null +++ b/native/crates/ops-documents/src/tests.rs @@ -0,0 +1,52 @@ +use super::*; +fn fixture(test: impl FnOnce(PathBuf, Request, Args)) { + let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let root = std::fs::canonicalize(std::env::temp_dir()).unwrap().join(format!("dsh-table-{}-{unique}", std::process::id())); + std::fs::create_dir(&root).unwrap(); + let path = root.join("table.csv"); std::fs::write(&path, "v\nsafe\n").unwrap(); + let request = serde_json::from_value(json!({"root":root,"scope":"table-unit","tool":"table_read","args":{}})).unwrap(); + let args = serde_json::from_value(json!({"path":path})).unwrap(); + test(path, request, args); + std::fs::remove_dir_all(root).unwrap(); +} +#[test] +fn expired_scan_deadline_has_no_total_revision_or_complete_claim() { + fixture(|path, request, args| { + let file = File::open(&path).unwrap(); let before = file.metadata().unwrap(); + let source = Source { path, file, before, started: Instant::now() - Duration::from_secs(31) }; + let output = observe(&request, &args, "csv", Encoding::Utf8, ',', source).unwrap(); + assert_eq!(output["status"], "partial"); assert_eq!(output["error"]["code"], "DEADLINE_EXCEEDED"); + assert_eq!(output["scanComplete"], false); assert_eq!(output["rowsObserved"], 0); + assert!(output.get("rowCount").is_none()); assert!(output.get("revision").is_none()); + }); +} +#[test] +fn changed_file_metadata_invalidates_totals_without_discarding_safe_rows() { + fixture(|path, request, args| { + let file = File::open(&path).unwrap(); let before = file.metadata().unwrap(); + std::fs::write(&path, "v\nchanged-long\n").unwrap(); + let source = Source { path, file, before, started: Instant::now() }; + let output = observe(&request, &args, "csv", Encoding::Utf8, ',', source).unwrap(); + assert_eq!(output["error"]["code"], "FILE_CHANGED"); assert_eq!(output["scanComplete"], false); + assert_eq!(output["complete"], false); assert_eq!(output["items"][0]["cells"][0], "changed-long"); + assert!(output.get("total").is_none()); assert!(output.get("revision").is_none()); + }); +} + +#[test] +fn short_reads_do_not_bypass_bom_or_unicode_validation() { + struct OneByte(std::io::Cursor>); + impl std::io::Read for OneByte { + fn read(&mut self, bytes: &mut [u8]) -> std::io::Result { + let count = bytes.len().min(1); std::io::Read::read(&mut self.0, &mut bytes[..count]) + } + } + let reader = |bytes: Vec| OneByte(std::io::Cursor::new(bytes)); + assert!(Decoder::new(reader(vec![0xef, 0xbb, 0xbf, b'x']), Encoding::Utf16Be).is_err()); + assert!(Decoder::new(reader(vec![0xff, 0xfe, b'x', 0]), Encoding::Utf8).is_err()); + let mut valid = Decoder::new(reader("\u{feff}中😀".as_bytes().to_vec()), Encoding::Utf8).unwrap(); + assert_eq!(valid.next().unwrap(), Some('中')); assert_eq!(valid.next().unwrap(), Some('😀')); + assert_eq!(valid.next().unwrap(), None); + let mut invalid = Decoder::new(reader(vec![0xf0, 0x9f]), Encoding::Utf8).unwrap(); + assert_eq!(invalid.next().unwrap_err().code, "ERR_INVALID_ENCODING"); +} diff --git a/native/crates/ops-results/src/spill.rs b/native/crates/ops-results/src/spill.rs index cb779a5e..de56e43e 100644 --- a/native/crates/ops-results/src/spill.rs +++ b/native/crates/ops-results/src/spill.rs @@ -19,7 +19,7 @@ fn mark_head(request: &Request, value: &mut Value) { let skipped = if request.tool == "replace" { 0 } else { value["skipped"].as_u64().unwrap_or(0) }; if let Some(total) = value["total"].as_u64() { value["omitted"] = json!(total.saturating_sub(skipped)); } if value.get("nextOffset").is_some() { - value["nextOffset"] = json!(if request.tool == "inspect_local_file" { + value["nextOffset"] = json!(if request.tool == "table_read" { skipped + 1 } else if request.tool == "inspect_local_file" { if request.args["view"] == "hex" { skipped / 16 + 1 } else { skipped + 1 } } else { skipped }); } diff --git a/native/src/lib.rs b/native/src/lib.rs index f5939863..f6fe2c4a 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -33,6 +33,7 @@ pub fn dispatch(request: Request) -> Result { Ok(ops_results::spill::finish(&request, value)) } fn execute(dir: &Dir, request: &Request) -> Result { + if request.tool == "table_read" { return ops_documents::dispatch(request); } // File reads/searches do not create or consult the durable audit store. // This is important for read-only sessions and keeps observation separate // from mutation audit records. diff --git a/native/src/main.rs b/native/src/main.rs index f4c28f3b..5191b642 100644 --- a/native/src/main.rs +++ b/native/src/main.rs @@ -4,7 +4,7 @@ use serde_json::json; use std::io::{Read, Write}; fn may_mutate(request: &Request) -> bool { - !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "glob" | "grep" | "diff" | "stat") + !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "table_read" | "glob" | "grep" | "diff" | "stat") && !(request.tool == "result" && request.args.get("action").and_then(serde_json::Value::as_str) == Some("page")) } fn main() { diff --git a/test/package-source.test.mjs b/test/package-source.test.mjs index cada4b64..281ab153 100644 --- a/test/package-source.test.mjs +++ b/test/package-source.test.mjs @@ -14,7 +14,10 @@ await test('npm pack includes compiled host code and native sources, never host 'lib/components/panel/package.json', 'lib/components/panel/icon.png', 'lib/panel/panel.html', 'lib/components/tui/dsh-plugin.json', 'native/Cargo.toml', 'native/Cargo.lock', 'native/src/lib.rs', 'native/windows-metadata/Cargo.toml', 'native/windows-metadata/src/lib.rs', - 'native/darwin-metadata/Cargo.toml', 'native/darwin-metadata/src/lib.rs'] + 'native/darwin-metadata/Cargo.toml', 'native/darwin-metadata/src/lib.rs', + 'native/crates/ops-documents/Cargo.toml', 'native/crates/ops-documents/src/lib.rs', + 'native/crates/ops-documents/src/decode.rs', 'native/crates/ops-documents/src/parse.rs', + 'native/crates/ops-documents/src/tests.rs'] for (const name of required) assert.ok(files.includes(name), `missing source-build dependency: ${name}`) assert.equal(files.some(name => name.startsWith('src/')), false, 'host source must not ship beside compiled lib/') assert.equal(files.some(name => /^native\/(?:target[^/]*|(?:windows|darwin)-metadata\/target)\//u.test(name)), false) diff --git a/test/table-worker.test.mjs b/test/table-worker.test.mjs new file mode 100644 index 00000000..83c20938 --- /dev/null +++ b/test/table-worker.test.mjs @@ -0,0 +1,183 @@ +/** Bounded table parsing through the real one-shot worker, including reference recovery. */ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { callWorker, request, envelopeOf, requireWorker } from './lib/boundary.mjs' +requireWorker('table-worker') +const hash = value => createHash('sha256').update(value).digest('hex') +const snapshot = root => fs.readdirSync(root, { recursive: true }).sort() +const quoted = text => `"${text.replaceAll('"', '""')}"` +const payload = (root, args, options = {}) => ({ version: 1, requestId: 'table-test', component: 'file', + budget: { timeoutMs: 30000, maxBytes: options.maxBytes ?? 131072 }, + ...(options.cache ? { storage: { root: options.cache, authorityId: 'a'.repeat(64), allowedTools: ['table_read', 'result'] } } : {}), + request: { ...request(options.tool ?? 'table_read', args, { root, readOnly: options.readOnly, writeRoots: [] }), ...options.authority } }) +async function send(value) { + const raw = await callWorker(JSON.stringify(value)); const output = envelopeOf(raw, assert) + assert.ok(Buffer.byteLength(raw.stdout) <= value.budget.maxBytes); return output +} +const run = (root, args, options) => send(payload(root, args, options)) +const fileAt = (root, name, bytes) => { const file = path.join(root, name); fs.writeFileSync(file, bytes); return file } +function gap(body, code) { + assert.equal(body.status, 'partial'); assert.equal(body.error.code, code); assert.equal(body.error.effectState, 'none') + assert.equal(body.scanComplete, false); assert.equal(body.complete, false) + for (const field of ['total', 'rowCount', 'revision']) assert.equal(body[field], undefined, `${field} requires a complete scan`) +} +await test('CSV preserves lexical cells, duplicate/empty headers, quoted commas, escaped quotes and physical LF ranges', async () => { + await withTempDir('table-csv', async root => { + const text = '\ufeffname,name,,value\r\n"alpha,beta","q""q",,"two\r\nlines"\r\n0001,TRUE,,2026-10-19\r\n' + const file = fileAt(root, 'values.csv', text); const body = await run(root, { path: file }) + assert.equal(body.status, 'completed'); assert.equal(body.complete, true); assert.equal(body.scanComplete, true) + assert.deepEqual(body.columns, ['name', 'name', '', 'value']); assert.equal(body.columnCount, 4) + assert.deepEqual(body.items, [{ row: 1, cells: ['alpha,beta', 'q"q', '', 'two\r\nlines'], lineStart: 2, lineEnd: 3 }, + { row: 2, cells: ['0001', 'TRUE', '', '2026-10-19'], lineStart: 4, lineEnd: 4 }]) + assert.equal(body.rowCount, 2); assert.equal(body.revision, hash(text)); assert.equal(body.bytesRead, Buffer.byteLength(text)) + assert.equal(body.encoding, 'utf-8'); assert.equal(body.format, 'csv'); assert.equal(body.nextOffset, 3) + }) +}) +await test('TSV inference and headerless/empty records have no phantom trailing row', async () => { + await withTempDir('table-tsv', async root => { + const file = fileAt(root, 'values.TSV', 'a\t"b\tc"\t\nx\t""\tz\n') + const body = await run(root, { path: file, header: false }) + assert.equal(body.format, 'tsv'); assert.deepEqual(body.columns, []); assert.equal(body.columnCount, 3) + assert.deepEqual(body.items.map(item => item.cells), [['a', 'b\tc', ''], ['x', '', 'z']]); assert.equal(body.total, 2) + for (const [text, count] of [['', 0], ['\ufeff', 0], ['\n\n', 2], ['a,\n', 1]]) { + const observed = await run(root, { path: fileAt(root, 'empty.csv', text), header: false }) + assert.equal(observed.total, count); assert.equal(observed.complete, true) + } + const headerOnly = await run(root, { path: fileAt(root, 'header.csv', 'a,b\n') }) + assert.equal(headerOnly.total, 0); assert.deepEqual(headerOnly.columns, ['a', 'b']); assert.deepEqual(headerOnly.items, []) + }) +}) +await test('bounded row pages keep full observed totals separate from page completeness', async () => { + await withTempDir('table-pages', async root => { + const file = fileAt(root, 'rows.csv', 'id\n' + Array.from({ length: 500 }, (_, i) => i + 1).join('\n')) + const first = await run(root, { path: file }) + assert.equal(first.items.length, 200); assert.equal(first.nextOffset, 201); assert.equal(first.complete, false) + assert.equal(first.pageComplete, true); assert.equal(first.rowCount, 500); assert.equal(first.rowsObserved, 500) + assert.equal(first.omitted, 300); assert.equal(first.truncatedBy, 'row-limit') + const tail = await run(root, { path: file, offset: 499, limit: 2 }) + assert.deepEqual(tail.items.map(item => item.cells), [['499'], ['500']]); assert.equal(tail.complete, true); assert.equal(tail.nextOffset, 501) + const after = await run(root, { path: file, offset: 999 }) + assert.equal(after.complete, true); assert.deepEqual(after.items, []); assert.equal(after.nextOffset, 999) + }) +}) +await test('UTF-16 byte orders decode strictly and all conflicting BOMs are refused', async () => { + await withTempDir('table-encoding', async root => { + const text = '\ufeffname,value\n中,😀\n'; const le = Buffer.from(text, 'utf16le'); const be = Buffer.from(le).swap16() + for (const [encoding, bytes] of [['utf-16le', le], ['utf-16be', be]]) { + const file = fileAt(root, `${encoding}.csv`, bytes); const body = await run(root, { path: file, encoding }) + assert.equal(body.encoding, encoding); assert.deepEqual(body.items[0].cells, ['中', '😀']); assert.equal(body.revision, hash(bytes)) + } + const cases = [[le, 'utf-8'], [le, 'utf-16be'], [be, 'utf-8'], [be, 'utf-16le'], [Buffer.from(text), 'utf-16be'], [Buffer.from(text), 'utf-16le']] + for (const [bytes, encoding] of cases) { + const body = await run(root, { path: fileAt(root, 'mismatch.csv', bytes), encoding }) + assert.equal(body.error.code, 'ERR_INVALID_ENCODING'); assert.equal(body.error.effectState, 'none'); assert.equal(body.items, undefined) + } + }) +}) +await test('malformed encoding after safe rows never emits replacement characters or a full total', async () => { + await withTempDir('table-invalid', async root => { + const cases = [ + [Buffer.concat([Buffer.from('v\nsafe\n'), Buffer.from([0xff])]), 'utf-8'], + [Buffer.concat([Buffer.from('v\nsafe\n'), Buffer.from([0xf0, 0x9f])]), 'utf-8'], + [Buffer.concat([Buffer.from('v\nsafe\n', 'utf16le'), Buffer.from([0, 0xd8])]), 'utf-16le'], + [Buffer.concat([Buffer.from('v\nsafe\n', 'utf16le'), Buffer.from([0, 0xdc])]), 'utf-16le'], + [Buffer.concat([Buffer.from('v\nsafe\n', 'utf16le'), Buffer.from([1])]), 'utf-16le'], + ] + for (const [bytes, encoding] of cases) { + const body = await run(root, { path: fileAt(root, 'bad.csv', bytes), encoding, limit: 1 }) + gap(body, 'ERR_INVALID_ENCODING'); assert.equal(body.truncatedBy, 'encoding'); assert.equal(body.pageComplete, true) + assert.deepEqual(body.items.map(item => item.cells), [['safe']]); assert.doesNotMatch(JSON.stringify(body.items), /�/) + } + }) +}) +await test('malformed quote/column records preserve only preceding complete rows', async () => { + await withTempDir('table-malformed', async root => { + for (const suffix of ['"unfinished', 'un"quoted', '"closed" suffix', 'x,y', 'bare\rnext']) { + const body = await run(root, { path: fileAt(root, 'bad.csv', 'v\nsafe\n' + suffix) }) + gap(body, 'TABLE_PARSE_ERROR'); assert.equal(body.truncatedBy, 'parse-error') + assert.deepEqual(body.items.map(item => item.cells), [['safe']]); assert.equal(body.rowsObserved, 1) + } + }) +}) +await test('strict argument errors happen before reads and do not create artifacts', async () => { + await withTempDir('table-args', async root => { + const file = fileAt(root, 'data.csv', 'a\nb'); const before = snapshot(root) + for (const bad of [{ offset: 0 }, { offset: 100001 }, { limit: 0 }, { limit: 1001 }, { format: 'auto' }, { header: 'yes' }, { unknown: true }]) { + const body = await run(root, { path: file, ...bad }); assert.equal(body.error.code, 'INVALID_ARGUMENT'); assert.equal(body.error.effectState, 'none') + } + assert.equal((await run(root, { path: file, encoding: 'latin1' })).error.code, 'ERR_INVALID_ENCODING') + assert.deepEqual(snapshot(root), before) + }) +}) +await test('readonly observations retain credential/no-follow checks and create no audit/cache files', async () => { + await withTempDir('table-authority', async root => { + const good = fileAt(root, 'good.csv', 'v\nsafe'); const secret = path.join(root, 'vault'); fs.mkdirSync(secret) + const privateFile = fileAt(secret, 'secret.csv', 'v\nDO_NOT_DISCLOSE'); const credential = fileAt(root, '.env', 'v\nSECRET') + const linked = path.join(root, 'linked'); fs.symlinkSync(secret, linked, process.platform === 'win32' ? 'junction' : 'dir') + const before = snapshot(root) + const options = { readOnly: true, authority: { deny_roots: [secret], deny_credential_names: true } } + assert.equal((await run(root, { path: good }, options)).items[0].cells[0], 'safe') + for (const [file, code] of [[privateFile, 'CREDENTIAL_DENIED'], [credential, 'CREDENTIAL_DENIED'], [path.join(linked, 'secret.csv'), 'PATH_RESTRICTED'], [root, 'IS_DIRECTORY']]) { + const body = await run(root, { path: file }, options); assert.equal(body.error.code, code); assert.equal(body.error.effectState, 'none') + assert.doesNotMatch(JSON.stringify(body), /DO_NOT_DISCLOSE|SECRET/) + } + assert.deepEqual(snapshot(root), before) + }) +}) +await test('record, header, column and scan-record caps never label an unobserved suffix complete', async () => { + await withTempDir('table-caps', async root => { + for (const text of ['v\nsafe\n' + 'x'.repeat(1024 * 1024 + 1), 'v\nsafe\n' + Array(1025).fill('x').join(','), 'x'.repeat(9000) + '\nvalue']) { + const body = await run(root, { path: fileAt(root, 'capped.csv', text) }); gap(body, 'OUTPUT_LIMIT') + assert.equal(body.truncatedBy, 'scan-budget'); assert.match(body.nextAction, /does not bypass physical scan limits/) + } + const exact = await run(root, { path: fileAt(root, 'exact.csv', 'v\n' + 'x\n'.repeat(100000)), offset: 100000, limit: 1 }) + assert.equal(exact.scanComplete, true); assert.equal(exact.rowCount, 100000); assert.equal(exact.complete, true) + const cap = await run(root, { path: fileAt(root, 'over.csv', 'v\n' + 'x\n'.repeat(100001)), offset: 100000, limit: 1 }) + gap(cap, 'OUTPUT_LIMIT'); assert.equal(cap.rowsObserved, 100000); assert.equal(cap.items[0].row, 100000) + }) +}) +await test('32 MiB streamed scan cap retains safe rows without fabricating a revision', async () => { + await withTempDir('table-byte-cap', async root => { + const file = fileAt(root, 'large.csv', 'v\n' + ('x'.repeat(4096) + '\n').repeat(8193)) + const body = await run(root, { path: file, limit: 1 }); gap(body, 'OUTPUT_LIMIT') + assert.equal(body.items.length, 1); assert.ok(body.rowsObserved > 0 && body.rowsObserved < 10000) + assert.ok(body.bytesRead <= 32 * 1024 * 1024 + 1); assert.equal(body.pageComplete, true) + }) +}) +await test('large table cells spill before clipping and recover as exact typed records', async () => { + await withTempDir('table-spill', async root => { + const cache = path.join(root, 'cache'); fs.mkdirSync(cache, { mode: 0o700 }) + const text = '中😀\0"\\'.repeat(10000); const file = fileAt(root, 'large.csv', `value\n${quoted(text)}\nlast`) + const head = await run(root, { path: file }, { cache, maxBytes: 4096 }) + assert.ok(head.reference); assert.equal(head.nextOffset, 1); assert.equal(head.complete, false) + const records = []; let offset = 0, source + do { + const page = await run(head.referenceRoot, { action: 'page', id: head.reference.refId, offset, limit: 512, maxBytes: 65536 }, { cache, tool: 'result', readOnly: true }) + assert.equal(page.error, undefined); source = page.source; records.push(...page.items) + assert.ok(page.nextOffset > offset); offset = page.nextOffset; if (page.complete) break + } while (true) + assert.equal(source.rowCount, 2); assert.equal(source.captureComplete, true); assert.equal(source.itemEncoding, 'tagged-json-v1') + const rows = []; let chunks = [], byteOffset = 0 + for (const record of records) { + assert.equal(record.index, rows.length) + if (record.kind === 'item') { assert.equal(chunks.length, 0); rows.push(record.value); continue } + assert.equal(record.chunk, chunks.length); assert.equal(record.byteOffset, byteOffset) + chunks.push(record.text); byteOffset += Buffer.byteLength(record.text) + if (record.complete) { rows.push(JSON.parse(chunks.join(''))); chunks = []; byteOffset = 0 } + } + assert.equal(chunks.length, 0); assert.deepEqual(rows.map(row => row.cells), [[text], ['last']]) + const empty = path.join(root, 'empty-cache'); fs.mkdirSync(empty, { mode: 0o700 }) + const readonly = await run(root, { path: file }, { cache: empty, maxBytes: 4096, readOnly: true }) + assert.equal(readonly.reference, undefined); assert.equal(readonly.storageError.code, 'POLICY_DENIED'); assert.deepEqual(fs.readdirSync(empty), []) + }) +}) +await test('table_read transport overflow remains a read-only operation in writable sessions', async () => { + await withTempDir('table-transport', async root => { + const file = fileAt(root, 'large.csv', 'v\n' + 'x'.repeat(40000)); const before = snapshot(root) + const body = await run(root, { path: file }, { maxBytes: 1024 }) + assert.equal(body.error.code, 'OUTPUT_LIMIT'); assert.equal(body.error.effectState, 'none'); assert.deepEqual(snapshot(root), before) + }) +}) +report('table-worker') From 5b46af91c3f5670f0093f4edf8e05f2302b53d38 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:31:03 +0800 Subject: [PATCH 044/118] test(discovery): cover always-loaded activation on the part eight surface --- test/progressive-loop.test.mjs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/progressive-loop.test.mjs b/test/progressive-loop.test.mjs index 4b0bc9bd..596fa8ad 100644 --- a/test/progressive-loop.test.mjs +++ b/test/progressive-loop.test.mjs @@ -35,7 +35,7 @@ await test('real Native loop exposes discovery at startup and activates exactly }, []]) assert.equal(diagnostic, true); assert.equal(run.requests.length, 3) const stat = run.results.find(value => value.exec.name === 'ops_stat').result - assert.equal(stat.isError, false); assert.equal(stat.value.exists, true) + assert.equal(stat.isError, false); assert.equal(stat.value.exists, true, JSON.stringify(stat.value)) } finally { await host.close() } })) @@ -57,12 +57,12 @@ await test('real PTC loop keeps the current run_code snapshot fixed and publishe assert.equal(calls[0].result.isError, false, JSON.stringify(calls[0].result)) assert.equal(calls[0].result.value.result.selected.status, 'completed') assert.equal(calls[0].result.value.result.unavailable, true) - assert.equal(calls[1].result.value.result.data.exists, true) + assert.equal(calls[1].result.value.result.data.exists, true, JSON.stringify(calls[1].result.value)) } finally { await host.close() } })) -await test('real committed authority changes cancel pending activation while always-loaded readers are ready at startup', () => withTempDir('loop-mode-change', async root => { +await test('real committed authority changes cancel pending activation while always-loaded capabilities are ready at startup', () => withTempDir('loop-mode-change', async root => { fs.writeFileSync(path.join(root, 'note'), 'unchanged') - const host = await bootLoop(root, { discoveryConfig: { alwaysLoaded: ['inspect_local_file'] } }) + const host = await bootLoop(root, { discoveryConfig: { alwaysLoaded: ['stat'] } }) try { const owner = await host.agent() host.ctx.on('tools/execute', async (exec, next) => { @@ -71,12 +71,12 @@ await test('real committed authority changes cancel pending activation while alw return result }) const run = await host.run(owner, [request => { - assert.ok(names(request).includes('ops_inspect_local_file')) + assert.ok(names(request).includes('ops_stat')) assert.equal(names(request).includes('ops_edit_many'), false) return [{ name: 'ops_discover', args: { query: 'select:ops_edit_many' } }] }, request => { assert.equal(names(request).includes('ops_edit_many'), false, 'permission change must discard the pending choice before step/end') - assert.ok(names(request).includes('ops_inspect_local_file')) + assert.ok(names(request).includes('ops_stat')) return [] }]) assert.equal(run.requests.length, 2) From 2057b73c7c4dbe106b5b758ff5a55c16e7db32ae Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:41:36 +0800 Subject: [PATCH 045/118] fix(shell): return honest failures without replaying commands --- docs/CHANGELOG.md | 4 ++ docs/component-reference.md | 2 +- ...04\344\273\266\345\217\202\350\200\203.md" | 2 + src/shells/failure.ts | 15 ++++ src/shells/host-contract.ts | 17 +++-- src/shells/host-tool.ts | 22 ++++-- src/shells/jobs.ts | 28 ++++++-- src/shells/publish.js | 4 +- src/shells/tool.js | 5 +- src/shells/types.ts | 2 + test/shells-host.test.mjs | 72 +++++++++++++++++-- test/shells-session.test.mjs | 46 ++++++------ 12 files changed, 169 insertions(+), 50 deletions(-) create mode 100644 src/shells/failure.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fb04a692..210a1120 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,10 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Return host-compatible Bash argument failures as values with stable retry/effect metadata; interrupted or failed running commands require read-only inspection before replay. Bash 参数失败返回结构化值;运行后的未知副作用禁止自动重试。 + +- Cancel queued job starters when host admission fails after accepting the run hook; never retry that call in the foreground. 宿主接受任务启动回调后若准入失败,取消排队启动且不转为前台重试。 + ### Type the shell execution and host boundary (#261) - Migrate the new shell executor, host contract, approvals, jobs and session integration to strict diff --git a/docs/component-reference.md b/docs/component-reference.md index dd502c1f..8cec8a5c 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -157,7 +157,7 @@ Routing: **toolkit → bash → PowerShell 7**. On Windows, `\child` uses the session's drive; a drive-relative path such as `C:child` on a different drive must be written as an absolute path. - Host `pwsh`: Windows-native operations, using bundled PowerShell 7. -`ops_bash` requires a non-empty `description` and keeps the host Bash fields and result kinds: `foreground`, `background`, or `promoted`. Its default timeout is 120 seconds and the cap is 600 seconds. When the host jobs service is available, `run_in_background: true` returns a job ID immediately; a foreground timeout promotes the same running job. Use the host `job_output` and `job_kill` tools to collect or stop it. Without host jobs, the background parameter is absent and a foreground timeout stops the command. Unknown arguments are rejected before execution. +`ops_bash` requires a non-empty `description` and keeps the host Bash fields and result kinds: `foreground`, `background`, or `promoted`. Its default timeout is 120 seconds and the cap is 600 seconds. When the host jobs service is available, `run_in_background: true` returns a job ID immediately; a foreground timeout promotes the same running job. Use the host `job_output` and `job_kill` tools to collect or stop it. Without host jobs, the background parameter is absent and a foreground timeout stops the command. Unknown arguments return a failed foreground value before execution. The compatible string `error` remains; `failure` adds stable code, effect state and retry guidance. Invalid arguments have no command effects and require input changes. After execution begins, timeouts, cancellation and nonzero exits report unknown effects and prohibit automatic replay until read-only inspection. If job admission fails after accepting its run hook, the queued starter is cancelled and no foreground retry occurs. `sandbox_permissions` plus `justification` can request a strictly wider mode through the host approval service. An allowed-once grant applies to that command only; rejection never executes it or changes the session mode. Authority is checked again after approval and sandbox preparation. Windows confinement reports the host provider’s actual enforcement (including `partial`); incompatible Bash runtimes fail closed. diff --git "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" index 47c023eb..0399412f 100644 --- "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" +++ "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" @@ -159,6 +159,8 @@ `ops_bash` 必须提供非空 `description`,输入字段和结果类型与宿主 Bash 保持一致:`foreground`、`background` 或 `promoted`。默认超时 120 秒,上限 600 秒。宿主 jobs 服务可用时,`run_in_background: true` 立即返回 job ID;前台等待超时会把同一个运行中任务交给宿主管理。用宿主 `job_output` 收集输出、`job_kill` 停止任务。没有 jobs 服务时不声明后台参数,前台超时会停止命令。未知参数在执行前被拒绝。 +Bash 参数错误以失败的 foreground 值返回,不抛出异常。兼容的字符串 `error` 保留,`failure` 补充稳定错误码、副作用状态和重试指引。参数错误无命令副作用;执行后的超时、取消和非零退出效果未知,必须先只读检查,禁止自动重放。 宿主接受任务启动回调后准入失败时,取消排队启动,不会转为前台再次执行。 + `sandbox_permissions` 和 `justification` 可通过宿主审批服务申请更宽的权限。一次批准只作用于当前命令;拒绝后不执行,也不修改会话模式。审批与沙箱准备后都会重新检查会话权限。Windows 如实报告宿主的实际约束级别(包括 `partial`);不兼容的 Bash 运行时直接拒绝执行。 `script_path`(替代 `command`)、`graceMs` 和 `includeDiagnostics` 是保留的 OPS 兼容扩展,不改变宿主命令契约或超时上限。 diff --git a/src/shells/failure.ts b/src/shells/failure.ts new file mode 100644 index 00000000..da5e70d5 --- /dev/null +++ b/src/shells/failure.ts @@ -0,0 +1,15 @@ +/** Add retry/effect facts without changing the host Bash string error contract. */ +import type { OpsErrorPayload } from '../kernel/outcome.js' +import type { ShellResult } from './types.js' + +export function bashFailure(value: ShellResult, effectState: 'none' | 'unknown'): OpsErrorPayload { + const message = value.error ?? value.restriction?.message ?? value.permission?.message + ?? (value.timedOut ? 'Command timed out.' : value.cancelled ? 'Command cancelled.' : `Command exited with ${value.exitCode ?? value.signal ?? 'unknown status'}.`) + const argument = message.startsWith('INVALID_BASH_ARGUMENT:') + const code = argument ? 'INVALID_BASH_ARGUMENT' : value.timedOut ? 'TIMEOUT' : value.cancelled ? 'CANCELLED' + : value.restriction?.code ?? value.permission?.code ?? (/^([A-Z][A-Z_]+):/.exec(message)?.[1]) ?? 'COMMAND_FAILED' + return { code, message, effectState, retry: argument && effectState === 'none' ? 'after-input-change' : 'never', + nextAction: effectState === 'unknown' ? 'Do not repeat the command. Inspect its effects with a read-only operation first.' + : argument ? 'Correct the named argument before submitting another call.' + : 'No command effects were applied. Resolve the reported authority or runtime condition before another call; do not bypass a rejection.' } +} diff --git a/src/shells/host-contract.ts b/src/shells/host-contract.ts index 808b5068..c7573bc8 100644 --- a/src/shells/host-contract.ts +++ b/src/shells/host-contract.ts @@ -2,6 +2,7 @@ /** DSH 0.2.0-rc.2 shell DTO and rendering, with explicit OPS compatibility fields. */ import type { JsonSchemaNode, JsonSchemaType } from '@deepseek-ai/dsh-tools' import type { ShellResult, HostForeground, HostBashResult } from './types.js' +import { bashFailure } from './failure.js' import { BASH_OUTPUT_SCHEMA, renderBashResult, withBashStatus } from './tool.js' const nullable = (type: JsonSchemaType): JsonSchemaNode => ({ oneOf: [{ type }, { type: 'null' }] }) @@ -15,20 +16,25 @@ export const HOST_BASH_OUTPUT_SCHEMA: JsonSchemaNode = { } }, { ...BASH_OUTPUT_SCHEMA, required: [...(BASH_OUTPUT_SCHEMA.required ?? []), 'kind', 'exitCode', 'signal', 'aborted'], properties: { ...BASH_OUTPUT_SCHEMA.properties, + failure: { type: 'object', additionalProperties: false, required: ['code', 'message', 'effectState', 'retry'], properties: { + code: { type: 'string' }, message: { type: 'string' }, effectState: { type: 'string', enum: ['none', 'unknown'] }, + retry: { type: 'string', enum: ['never', 'after-input-change'] }, nextAction: { type: 'string' }, + } }, kind: { type: 'string', const: 'foreground' }, exitCode: nullable('integer'), signal: nullable('string'), aborted: { type: 'boolean' }, stopped: { type: 'string' }, } }, ], } -export function hostForeground(value: ShellResult): HostForeground { - return { ...withBashStatus(value), kind: 'foreground', exitCode: value.exitCode ?? null, +export function hostForeground(value: ShellResult, effects: 'none' | 'unknown' = 'unknown'): HostForeground { + const settled = withBashStatus(value) + return { ...settled, ...(settled.status === 'failed' ? { failure: bashFailure(value, effects) } : {}), kind: 'foreground', exitCode: value.exitCode ?? null, signal: value.signal ?? null, aborted: value.cancelled === true } } -export function hostFailure(message: string, timeoutMs = 120_000) { - return hostForeground({ error: message, timedOut: false, timeoutMs, - stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false } }) +export function hostFailure(message: string, timeoutMs = 120_000, effects: 'none' | 'unknown' = 'none', cancelled = false) { + return hostForeground({ error: message, timedOut: false, timeoutMs, ...(cancelled ? { cancelled: true } : {}), + stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false } }, effects) } export function renderHostBash(value: HostBashResult): string { @@ -37,6 +43,7 @@ export function renderHostBash(value: HostBashResult): string { const { exitCode, signal, ...rest } = value const body = renderBashResult(rest) const markers = [] + if (value.failure) markers.push(`[${value.failure.code}; effects: ${value.failure.effectState}; retry: ${value.failure.retry}] ${value.failure.nextAction ?? ''}`) if (value.sandbox?.denied) markers.push(`[sandbox: file access denied under ${value.sandbox.mode} mode]`) if (value.stopped) markers.push(`[stopped: ${value.stopped}]`) if (signal !== null) markers.push(`[killed by signal: ${signal}]`) diff --git a/src/shells/host-tool.ts b/src/shells/host-tool.ts index 11d9af1e..3034e109 100644 --- a/src/shells/host-tool.ts +++ b/src/shells/host-tool.ts @@ -2,12 +2,12 @@ /** Host-compatible consumer over an OPS-owned executor; host services stay scoped and borrowed. */ import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' -import type { ShellContext, ShellBase, ShellCall, ShellPolicy, CreateShellExecution, StartShell, HostBashResult, HostShellDefinition } from './types.js' +import type { ShellContext, ShellBase, ShellCall, ShellPolicy, CreateShellExecution, StartShell, ShellExecution, HostBashResult, HostShellDefinition } from './types.js' import { errorMessage } from './types.js' import { BashArgumentError, isBashArgumentError } from './argument-error.js' import { approveBashPolicy } from './approval.js' import { HOST_BASH_OUTPUT_SCHEMA, hostFailure, hostForeground, renderHostBash } from './host-contract.js' -import { startBashJob, waitOnBashJob } from './jobs.js' +import { BashJobAdmissionError, startBashJob, waitOnBashJob } from './jobs.js' export const HOST_MAX_BASH_TIMEOUT_MS = 600_000 @@ -31,6 +31,8 @@ export function hostBashDefinition(ctx: ShellContext, base: ShellBase, createExe async execute(input: unknown, exec: ShellCall = {}): Promise { let timeoutMs = 120_000 let policy: ShellPolicy | undefined + let attempted = false + let process: ShellExecution | undefined try { if (!input || typeof input !== 'object' || Array.isArray(input)) throw new BashArgumentError('invalid arguments: expected an object', 'arguments') const args = input as Record @@ -52,20 +54,26 @@ export function hostBashDefinition(ctx: ShellContext, base: ShellBase, createExe exec.signal?.throwIfAborted() const dshEnv = ctx.get('shellEnv')?.collect(exec) ?? {} const approvedPolicy = policy - const start: StartShell = (execution, options) => createExecution(local, execution, standing, approvedPolicy, dshEnv, options) + const start: StartShell = async (execution, options) => { + attempted = true + process = await createExecution(local, execution, standing, approvedPolicy, dshEnv, options) + return process + } if (jobs && ctx.get('jobs') === jobs) { let attached try { attached = startBashJob(jobs, args as { command?: string; script_path?: string }, exec, start) } catch (error) { - if (run_in_background) throw error + if (run_in_background || attempted || error instanceof BashJobAdmissionError && error.runInvoked) throw error ctx.logger?.warn?.(`Bash job admission refused; using the foreground deadline: ${errorMessage(error)}`) } if (attached) return run_in_background ? { kind: 'background', jobId: attached.id } : await waitOnBashJob(jobs, attached, exec, timeoutMs) } else if (run_in_background) return hostFailure('EXECUTOR_UNAVAILABLE: the host job registry is unavailable.', timeoutMs) - return hostForeground(await (await start(exec, {})).result()) + const execution = await start(exec, {}) + const result = await execution.result() + return hostForeground(result, execution.started ? 'unknown' : 'none') } catch (error) { - if (isBashArgumentError(error)) throw error - return { ...hostFailure(errorMessage(error), timeoutMs), ...(exec.signal?.aborted ? { aborted: true, cancelled: true } : {}), ...(policy ? { sandbox: { mode: policy.mode, denied: false } } : {}) } + const message = isBashArgumentError(error) ? `INVALID_BASH_ARGUMENT: ${errorMessage(error)}` : errorMessage(error) + return { ...hostFailure(message, timeoutMs, process ? (process.started ? 'unknown' : 'none') : attempted ? 'unknown' : 'none', exec.signal?.aborted === true), ...(policy ? { sandbox: { mode: policy.mode, denied: false } } : {}) } } }, } satisfies ToolDefinition diff --git a/src/shells/jobs.ts b/src/shells/jobs.ts index b0b5e5ce..3ed93fb8 100644 --- a/src/shells/jobs.ts +++ b/src/shells/jobs.ts @@ -4,21 +4,31 @@ import type { ShellJobs, ShellCall, StartShell, ShellExecution, ShellJobView, Ho import { errorMessage } from './types.js' import { hostForeground } from './host-contract.js' +/** A run hook may have been accepted even when admission itself throws. */ +export class BashJobAdmissionError extends Error { + constructor(cause: unknown, readonly runInvoked: boolean) { super(errorMessage(cause), { cause }); this.name = 'BashJobAdmissionError' } +} + const running = (view: ShellJobView): boolean => ['running', 'stopping'].includes(view.status) export function startBashJob(jobs: ShellJobs, args: { command?: string; script_path?: string }, exec: ShellCall, start: StartShell) { let process: ShellExecution | undefined let stopped: string | undefined + let runInvoked = false + let attempted = false + const cancellation = new AbortController() const output = (['stdout', 'stderr'] as const).map(stream => ({ channel: stream, read: (offset: number) => process?.observed[stream].readFrom(offset) ?? { text: '', nextOffset: offset, lossy: false }, })) - const id = jobs.start({ kind: 'bash', label: args.command ?? args.script_path ?? 'Bash', + let id: string + try { id = jobs.start({ kind: 'bash', label: args.command ?? args.script_path ?? 'Bash', ...(exec.agent ? { owner: exec.agent.id } : {}), output, run: () => { - const cancellation = new AbortController() + runInvoked = true const done = Promise.resolve().then(async () => { cancellation.signal.throwIfAborted() + attempted = true process = await start({ ...exec, signal: cancellation.signal }, { onExpiry: 'none' }) if (cancellation.signal.aborted) process.kill(stopped) const value = await process.result() @@ -30,8 +40,14 @@ export function startBashJob(jobs: ShellJobs, args: { command?: string; script_p }).catch((error: unknown): { status: 'killed' | 'failed'; detail: string } => ({ status: cancellation.signal.aborted ? 'killed' : 'failed', detail: errorMessage(error) })) return { done, cancel: reason => { stopped = reason; cancellation.abort(new Error(reason)); process?.kill(reason) } } }, - }) - return { id, process: () => process, stopped: () => stopped } + }) } catch (error) { + // Cancel the queued microtask before a refused admission can orphan a command. + cancellation.abort(error) + process?.kill('job admission failed') + throw new BashJobAdmissionError(error, runInvoked) + } + return { id, process: () => process, stopped: () => stopped, + effects: (): 'none' | 'unknown' => process ? (process.started ? 'unknown' : 'none') : attempted ? 'unknown' : 'none' } } export async function waitOnBashJob(jobs: ShellJobs, attached: ReturnType, exec: ShellCall, timeoutMs: number): Promise { @@ -48,7 +64,7 @@ export async function waitOnBashJob(jobs: ShellJobs, attached: ReturnType chunk.channel !== 'stderr').map(chunk => chunk.text).join('') @@ -61,5 +77,5 @@ export async function waitOnBashJob(jobs: ShellJobs, attached: ReturnType { const f = fixture() - await assert.rejects(() => f.definition.execute({ command: 'true' }, f.exec), /invalid description/) - await assert.rejects(() => f.definition.execute({ command: 'true', description: 'x', timeoutMs: NaN }, f.exec), /invalid timeoutMs/) + for (const args of [{ command: 'true' }, { command: 'true', description: 'x', timeoutMs: NaN }]) { + const invalid = await f.definition.execute(args, f.exec) + assert.equal(invalid.status, 'failed'); assert.equal(invalid.failure.code, 'INVALID_BASH_ARGUMENT') + assert.equal(invalid.failure.effectState, 'none'); assert.equal(invalid.failure.retry, 'after-input-change') + assert.deepEqual(validateJsonSchemaValue(f.definition.output.schema, invalid, 'value'), []) + assert.match(f.definition.output.render({}, invalid)[0].text, /invalid (description|timeoutMs)/) + } assert.equal(f.calls.length, 0) const value = await f.definition.execute({ command: 'true', description: 'Cap the command timeout', timeoutMs: 900000 }, f.exec) assert.equal(value.timeoutMs, 600000) @@ -88,6 +93,7 @@ await test('approval rejection and missing approval never execute or create jobs } } }) const value = await f.definition.execute({ command: 'true', description: 'Check explicit wider access', sandbox_permissions: 'danger-full-access', justification: 'Need the requested command access.' }, f.exec) assert.match(value.error, /APPROVAL_/); assert.equal(f.calls.length, 0) + assert.equal(value.failure.effectState, 'none'); assert.equal(value.failure.retry, 'never') } const f = fixture({ mode: 'read-only' }) const value = await f.definition.execute({ command: 'true', description: 'Check missing approval service', sandbox_permissions: 'danger-full-access', justification: 'Need wider access.' }, f.exec) @@ -116,7 +122,9 @@ await test('escalation argument failures precede approval and execution', async let asks = 0 const f = fixture({ approval: { async request() { asks++; return 'allowed-once' } } }) for (const args of [{ justification: 'orphan' }, { sandbox_permissions: 'workspace-write', justification: 'narrow' }, { sandbox_permissions: 'unknown' }]) { - await assert.rejects(() => f.definition.execute({ command: 'true', description: 'Validate escalation fields', ...args }, f.exec), /invalid/) + const value = await f.definition.execute({ command: 'true', description: 'Validate escalation fields', ...args }, f.exec) + assert.equal(value.failure.code, 'INVALID_BASH_ARGUMENT'); assert.match(value.error, /invalid/) + assert.equal(value.failure.effectState, 'none') } assert.equal(asks, 0); assert.equal(f.calls.length, 0) }) @@ -168,11 +176,13 @@ await test('same-mode requests do not prompt and cancellation while approving pr const value = await call assert.equal(value.status, 'failed'); assert.equal(value.aborted, true); assert.match(value.error, /approval was cancelled/) assert.equal(pending.calls.length, 0) + assert.equal(value.failure.code, 'CANCELLED'); assert.equal(value.failure.effectState, 'none') }) await test('without jobs the foreground deadline stops execution and background arguments fail', async () => { const f = fixture({ settle: false }) - await assert.rejects(() => f.definition.execute({ command: 'true', description: 'Reject unavailable background work', run_in_background: true }, f.exec), /unknown key/) + const invalid = await f.definition.execute({ command: 'true', description: 'Reject unavailable background work', run_in_background: true }, f.exec) + assert.equal(invalid.failure.code, 'INVALID_BASH_ARGUMENT'); assert.match(invalid.error, /unknown key/) assert.equal(f.calls.length, 0) // Keep this test alive while the executor's deliberately unref'ed deadline runs. const hold = setTimeout(() => {}, 2000) @@ -180,6 +190,8 @@ await test('without jobs the foreground deadline stops execution and background const value = await f.definition.execute({ command: 'true', description: 'Stop the command at expiry', timeoutMs: 5 }, f.exec) assert.equal(value.kind, 'foreground'); assert.equal(value.timedOut, true) assert.equal(value.status, 'failed'); assert.equal(f.calls[0].signal.aborted, true) + assert.equal(value.failure.effectState, 'unknown'); assert.equal(value.failure.retry, 'never') + assert.match(f.definition.output.render({}, value)[0].text, /Inspect its effects with a read-only operation first/) } finally { clearTimeout(hold) } }) @@ -188,6 +200,8 @@ await test('nonzero exit completes the host job but preserves the failed command const value = await f.definition.execute({ command: 'exit 7', description: 'Report a nonzero command exit' }, f.exec) assert.equal(jobs.outcome.status, 'completed'); assert.equal(jobs.outcome.detail, 'exit code: 7') assert.equal(value.exitCode, 7); assert.equal(value.status, 'failed') + assert.equal(value.failure.effectState, 'unknown'); assert.equal(value.failure.retry, 'never') + assert.deepEqual(validateJsonSchemaValue(f.definition.output.schema, value, 'value'), []) }) await test('job cancellation before asynchronous preparation prevents the starter from running', async () => { @@ -210,6 +224,7 @@ await test('an expiring pending preparation is stopped and never promoted', asyn const value = await waitOnBashJob(jobs, attached, f.exec, 1) assert.equal(value.kind, 'foreground'); assert.equal(value.timedOut, true) assert.equal(killed, true); assert.equal(jobs.removed, true) + assert.equal(value.failure.effectState, 'none'); assert.equal(value.failure.code, 'TIMEOUT') }) await test('an unresolved executable is refused before the host can search PATH', async () => { @@ -225,4 +240,53 @@ await test('a session executor without an authoritative agent refuses before con { command: 'true' }, {}, policy, policy, {}, {}), /EXECUTOR_UNAVAILABLE: an authoritative session is required/) }) +await test('post-run admission failure cancels queued work and never retries foreground', async () => { + let hooks + const jobs = { start(spec) { hooks = spec.run(); throw new Error('admission failed after run hook') } } + const f = fixture({ jobs }) + const value = await f.definition.execute({ command: 'true', description: 'Refuse partially accepted job admission' }, f.exec) + await hooks.done + assert.equal(value.status, 'failed'); assert.match(value.error, /admission failed after run hook/) + assert.equal(value.failure.effectState, 'none'); assert.equal(value.failure.retry, 'never') + assert.equal(f.calls.length, 0, 'neither queued job nor foreground retry can start') +}) + +await test('starter/result errors distinguish pre-execution evidence from uncertain command effects', async () => { + const f = fixture(), base = bashToolDefinition({ file: process.execPath, source: 'config', subprocess: f.services.subprocess }) + const args = { command: 'true', description: 'Classify execution uncertainty without repeating' } + for (const started of [false, true, undefined]) { + let starts = 0 + const custom = hostBashDefinition({ get: name => f.services[name] }, base, () => { + starts++ + if (started === undefined) throw new Error('starter failed without a handle') + return { started, result() { throw new Error('result read failed') } } + }, sessionWorkdir) + const value = await custom.execute(args, f.exec) + assert.equal(starts, 1); assert.equal(value.status, 'failed') + assert.equal(value.failure.effectState, started === false ? 'none' : 'unknown') + assert.equal(value.failure.retry, 'never') + assert.deepEqual(validateJsonSchemaValue(custom.output.schema, value, 'value'), []) + } + f.services.shellEnv.collect = () => { throw new Error('environment collection failed') } + const value = await f.definition.execute(args, f.exec) + assert.equal(value.failure.effectState, 'none'); assert.equal(f.calls.length, 0) +}) + +await test('timeout cannot claim no effects while an invoked asynchronous starter has no handle', async () => { + const jobs = jobRegistry({ promote: true }), f = fixture() + const pending = Promise.withResolvers(); let stopped = false + const wait = jobs.wait.bind(jobs); let waits = 0 + // The real registry's second bounded wait may still report cancellation in progress. + jobs.wait = (...args) => ++waits === 2 ? Promise.resolve({ status: 'stopping' }) : wait(...args) + const attached = startBashJob(jobs, { command: 'true' }, f.exec, () => pending.promise) + const value = await waitOnBashJob(jobs, attached, f.exec, 1) + assert.equal(value.timedOut, true); assert.equal(value.failure.effectState, 'unknown') + assert.equal(value.failure.retry, 'never'); assert.equal(jobs.removed, false) + pending.resolve({ started: false, kill() { stopped = true }, + result: async () => ({ cancelled: true, timedOut: false, timeoutMs: 1, + stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false } }) }) + await jobs.hooks.done + assert.equal(stopped, true); assert.equal(jobs.outcome.status, 'killed') +}) + report('shells-host') diff --git a/test/shells-session.test.mjs b/test/shells-session.test.mjs index 94db89f0..60d7ff27 100644 --- a/test/shells-session.test.mjs +++ b/test/shells-session.test.mjs @@ -45,7 +45,9 @@ async function publishOpsBash(ctx, id, workspace) { await ctx.plugin({ inject: ['tools'], apply(inner) { mountSessionBash(inner, { publishBashTool: true }, { bash }) } }) await ctx.serial('agent/created', { agent, source: 'startup' }) await waitFor(() => ctx.get('tools').get('ops_bash', agent) !== undefined, { what: 'ops_bash publication to the session' }) - return { agent, scope, tool: ctx.get('tools').get('ops_bash', agent) } + const tool = ctx.get('tools').get('ops_bash', agent) + assert.deepEqual(tool.parameters.required, ['description'], 'the real session publishes the host projection, not the legacy helper') + return { agent, scope, tool } } /** The real-shell check for #237; registered on POSIX only, for the reason at its registration. */ @@ -113,21 +115,20 @@ await test('a wrong argument reaches the caller as itself, not as invalid output [{ command: 'echo hi', script_path: 'x.sh' }, /exactly one of command or script_path/, 'script_path'], [{ command: 'echo hi', workdir: path.join(workspace, 'missing') }, /invalid workdir: must be an existing directory/, 'workdir'], ]) { - // The session layer rethrows a caller's refusal instead of folding it into a value. - await assert.rejects(() => tool.execute(args, { agent, signal: signal() }), error => { - assert.equal(error.code, BASH_ARGUMENT_ERROR_CODE, error?.message) - assert.equal(error.argument, argument, error?.message) - assert.match(error.message, pattern) - return true - }) + const input = { ...args, description: 'Report the precise invalid argument' } + const value = await tool.execute(input, { agent, signal: signal() }) + assert.equal(value.status, 'failed'); assert.equal(value.failure.code, BASH_ARGUMENT_ERROR_CODE) + assert.equal(value.failure.effectState, 'none'); assert.equal(value.failure.retry, 'after-input-change') + assert.match(value.error, pattern); assert.ok(value.error.includes(argument)) // Through the real registry the caller reads the refusal itself; the pre-fix path ended // in "returned invalid output" and the argument was never named. const result = await ctx.get('tools').execute({ - name: 'ops_bash', callId: 'shells-session-args-' + call++, agent, signal: signal(), arguments: args, + name: 'ops_bash', callId: 'shells-session-args-' + call++, agent, signal: signal(), arguments: input, }) - assert.equal(result.isError, true, JSON.stringify(result)) - assert.match(result.error.message, pattern) - assert.doesNotMatch(result.error.message, /invalid output/) + assert.equal(result.isError, false, JSON.stringify(result)) + assert.equal(result.value.status, 'failed') + assert.match(result.value.error, pattern) + assert.doesNotMatch(result.value.error, /invalid output/) } } finally { await scope.dispose() } }) @@ -141,18 +142,19 @@ await test('a vanished session workspace is named as the missing directory (#180 const { agent, scope, tool } = await publishOpsBash(ctx, 'shells-session-gone', workspace) try { fs.rmSync(workspace, { recursive: true, force: true }) - await assert.rejects(() => tool.execute({ command: 'echo hi' }, { agent, signal: signal() }), error => { - assert.equal(error.code, BASH_ARGUMENT_ERROR_CODE, error?.message) - assert.match(error.message, /invalid workdir: must be an existing directory/) - assert.ok(error.message.includes(workspace), error.message) - assert.doesNotMatch(error.message, /bash executable|provision-shells/) - return true - }) + const input = { command: 'echo hi', description: 'Report the missing session workspace' } + const value = await tool.execute(input, { agent, signal: signal() }) + assert.equal(value.failure.code, BASH_ARGUMENT_ERROR_CODE) + assert.equal(value.failure.effectState, 'none') + assert.match(value.error, /invalid workdir: must be an existing directory/) + assert.ok(value.error.includes(workspace), value.error) + assert.doesNotMatch(value.error, /bash executable|provision-shells/) const result = await ctx.get('tools').execute({ - name: 'ops_bash', callId: 'shells-session-gone', agent, signal: signal(), arguments: { command: 'echo hi' }, + name: 'ops_bash', callId: 'shells-session-gone', agent, signal: signal(), arguments: input, }) - assert.equal(result.isError, true, JSON.stringify(result)) - assert.ok(result.error.message.includes(workspace), result.error.message) + assert.equal(result.isError, false, JSON.stringify(result)) + assert.equal(result.value.status, 'failed') + assert.ok(result.value.error.includes(workspace), result.value.error) } finally { await scope.dispose() } }) } finally { await shutdown(ctx) } From 6985e5303a1cf07accf7f686df87d736e4b1088d Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:41:57 +0800 Subject: [PATCH 046/118] feat(documents): expose scoped typed table readers --- docs/CHANGELOG.md | 4 +- docs/native-kernel.md | 15 ++- ...37\347\224\237\345\206\205\346\240\270.md" | 11 +- dsh-plugin.json | 3 +- src/capabilities/discovery.ts | 1 + src/capabilities/documents.ts | 40 ++++++ src/capabilities/file.ts | 5 + src/capabilities/registry.ts | 2 +- src/kernel/errors.ts | 2 + src/modes/native/documents.ts | 19 +++ src/modes/native/render.ts | 7 +- src/modes/ptc/canonical-schema.ts | 4 +- src/modes/ptc/continuation.ts | 9 +- test/capabilities.test.mjs | 6 +- test/lib/document-host.mjs | 49 +++++++ test/table-host.test.mjs | 120 ++++++++++++++++++ test/types/ptc-sdk.ts | 15 +++ 17 files changed, 297 insertions(+), 15 deletions(-) create mode 100644 src/capabilities/documents.ts create mode 100644 src/modes/native/documents.ts create mode 100644 test/lib/document-host.mjs create mode 100644 test/table-host.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 416f4e92..97e0f097 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,8 +13,10 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm string cells, physical line ranges, bounded row pages and safe preceding rows on parse failures. - Separate complete physical scans from requested pages and reference capture; reuse no-follow, credential, readonly and automatic-spill boundaries without new external dependencies. +- Expose discoverable-only Native rows and typed PTC cells with full-scan-only continuations; retain + safe prefix data beside non-resumable parse/scan gaps and preserve authority and reference metadata. - 新增有界 CSV/TSV worker 读取,严格解码、保留原字符串与物理行范围;错误保留此前有效行, - 并分别报告扫描、分页和引用捕获完整性,不新增外部依赖。发现/宿主注册由后续切片接入。 + 并分别报告扫描、分页和引用捕获完整性,不新增外部依赖。通过发现/SDK 按代理在下一步骤启用;解析缺口保留前缀且不伪造续取。 ### Deduplicate Native reads with current-context evidence (#261, 10/16) diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 4bb8b670..0e555b2a 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -131,10 +131,11 @@ retain the legacy transport contract; the host supplies recovery guidance when s ## CSV/TSV worker observations (15a/16) -The new ops-documents crate handles `table_read` in the same one-shot worker. This slice exposes -only the worker capability; discovery/host registration is separate. It adds no external packages: +The new ops-documents crate handles `table_read` in the same one-shot worker. The capability stays outside +the default schema: Native uses `ops_discover` and PTC uses `ops_sdk` to activate `table_read` +for the next agent step. Siblings and descendants require their own selection. It adds no external packages: serde/serde_json and sha2 reuse the existing locked workspace dependency graph. PDF/DOC parsing -remains separate work requiring its own dependency review. +requires separate dependency review. Arguments are `{ path, format?, encoding?, header?, offset?, limit? }`. The host resolves path against current cwd before sending an absolute path. Format is csv or tsv, inferred as tsv only @@ -172,3 +173,11 @@ objects. Oversized requested rows use the existing bounded spill sink before dis references contain exact typed row JSON, source retains table/coverage facts, and captureComplete is independent of scanComplete. Page with the original referenceRoot. Cache effects never imply business writes; table_read is classified as read-only even in a writable session. + +Native renders positional JSON cells with physical LF ranges and explicit non-exhaustive gap notices. +PTC exposes lexical `string[]` cells through generated declarations. Stable complete scans with +remaining rows supply read-only forward continuations; callers must compare revisions across calls. +Malformed/changed input and physical scan limits retain prefix data and recovery advice, with no +continuation that would repeat the same gap. Native reference pages and PTC result pages preserve +original table coverage separately from stored-row pagination. Shared admission, current authority, +credential checks, cancellation and storage bindings apply unchanged. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index c0dd4b09..c17331e4 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -107,8 +107,9 @@ complete=true 后 JSON.parse;须验证连续索引、偏移及结束标记。S ## CSV/TSV worker 观察(15a/16) -新增 ops-documents crate,在同一个单次 worker 内处理 table_read。本切片只增加 worker 能力, -宿主注册及发现入口另行接入。没有新增外部包:serde/serde_json 与 sha2 复用既有锁定依赖。 +新增 ops-documents crate,在同一个单次 worker 内处理 table_read。该能力不进入默认工具 schema: +Native 经 `ops_discover`、PTC 经 `ops_sdk` 选择 table_read 后,于代理下一步骤生效;兄弟及 +子代理必须独立选择。没有新增外部包:serde/serde_json 与 sha2 复用既有锁定依赖。 PDF/DOC 解析仍是需要独立依赖审查的后续工作。 参数为 `{ path, format?, encoding?, header?, offset?, limit? }`。宿主先相对于当前 cwd 解析 @@ -139,3 +140,9 @@ FILE_CHANGED、DEADLINE_EXCEEDED;预检权限拒绝沿用原有策略错误码 裁切前使用既有有界捕获器:引用保留可精确重建的类型化行 JSON,source 保留表格/覆盖事实, captureComplete 独立于 scanComplete。分页须使用原 referenceRoot。缓存副作用不代表业务写入; 即使会话可写,table_read 也按只读操作分类。 + +Native 用带物理 LF 行范围的原位置 JSON 单元格展示,并明确提示扫描缺口不完整。PTC 的生成 +声明保留 `string[]` 单元格。只有稳定完整扫描且仍有后续行时提供只读前进续取;跨调用组合页面 +须比较 revision。非法输入、文件变化和物理扫描上限保留前缀数据与恢复提示,不提供会重复 +原缺口的续取。Native/PTC 引用页均区分原表格覆盖率与已存记录分页;既有排队、实时权限、 +凭据检查、取消与存储绑定继续适用。 diff --git a/dsh-plugin.json b/dsh-plugin.json index 11c5eeac..ee45bd6e 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -85,6 +85,7 @@ "remove", "read_many", "edit_many", + "table_read", "result" ], "shellPermission": "Per-call DSH sandbox policy and native subprocess" @@ -110,7 +111,7 @@ "targets": ["win32-x64", "linux-x64", "darwin-arm64"], "presentation": "PTC-only; reader/result/SDK loader resident, other APIs become visible on the next agent step", "runtime": "independent native Rust file worker", - "tools": ["ops_inspect_local_file","ops_grep","ops_glob","ops_replace","ops_write_file","ops_edit_file","ops_diff","ops_stat","ops_copy","ops_move","ops_remove","ops_read_many","ops_edit_many","ops_batch","ops_result","ops_sdk"], + "tools": ["ops_inspect_local_file","ops_grep","ops_glob","ops_replace","ops_write_file","ops_edit_file","ops_diff","ops_stat","ops_copy","ops_move","ops_remove","ops_read_many","ops_edit_many","ops_table_read","ops_batch","ops_result","ops_sdk"], "permissions": "Per-call original DSH sandboxPolicy and sandbox.confine (host full/partial enforcement reported); full access bypasses confinement and permits explicit operation roots; subprocess owns cancellation and credential scrub" }, { diff --git a/src/capabilities/discovery.ts b/src/capabilities/discovery.ts index 7e37aab8..87d5e0c7 100644 --- a/src/capabilities/discovery.ts +++ b/src/capabilities/discovery.ts @@ -3,6 +3,7 @@ import type { ParameterSchema } from './types.js' /** Reviewed, currently implemented advanced operations. Future formats are absent. */ export const DISCOVERY_TERMS = Object.freeze({ + table_read: 'table csv tsv delimiter quoted records columns rows encoding utf16', inspect_local_file: 'read inspect single file text hex binary bounded line windows', edit_many: 'edit patch multiple files atomic per file revision cas', result: 'result reference page release stored output continuation', diff --git a/src/capabilities/documents.ts b/src/capabilities/documents.ts new file mode 100644 index 00000000..a296d568 --- /dev/null +++ b/src/capabilities/documents.ts @@ -0,0 +1,40 @@ +/** Document schemas describe their bounded observation scope; registration remains catalogue-owned. */ +import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import type { ParameterSchema } from './types.js' +import { STORAGE_REFERENCE } from './result.js' +const string: JsonSchemaNode = { type: 'string' } +const integer: JsonSchemaNode = { type: 'integer' } +const boolean: JsonSchemaNode = { type: 'boolean' } +const strings: JsonSchemaNode = { type: 'array', items: string } +const object = (properties: Record, required: string[] = []): ParameterSchema => ({ type: 'object', properties, required, additionalProperties: false }) +export const DOCUMENT_PARAMETERS = Object.freeze({ + table_read: object({ + path: { ...string, description: 'Regular file path relative to the current session cwd.' }, + format: { type: 'string', enum: ['csv', 'tsv'], description: 'Default tsv for .tsv paths, otherwise csv.' }, + encoding: { type: 'string', enum: ['utf-8', 'utf-16le', 'utf-16be'], default: 'utf-8', description: 'Strict decoding; conflicting BOMs fail. No replacement characters.' }, + header: { type: 'boolean', default: true, description: 'First record is columns when true; empty/duplicate column names stay positional.' }, + offset: { type: 'integer', default: 1, description: '1-100000 data rows, 1-based; header is excluded.' }, + limit: { type: 'integer', default: 200, description: '1-1000 data rows.' }, + }, ['path']), +}) +export const DOCUMENT_DESCRIPTIONS = Object.freeze({ + table_read: 'Read bounded CSV/TSV lexical string cells relative to session cwd. Preserves quoted multiline records and physical LF lineStart/lineEnd. Check scanComplete versus pageComplete; total/revision exist only after a stable full scan. Up to 32 MiB, 100000 data records, 1 MiB/record, 1024 columns and 30s; row paging cannot bypass scan gaps. Errors retain safe preceding rows with nextAction. Compare revisions before combining rescanned pages. No spreadsheet formulas or type coercion.', +}) +export const DOCUMENT_FACTS: Record = { + format: string, encoding: string, header: boolean, columns: strings, columnCount: integer, + bytes: integer, bytesRead: integer, rowsObserved: integer, skipped: integer, + scanComplete: boolean, pageComplete: boolean, complete: boolean, truncated: boolean, + truncatedBy: string, nextOffset: integer, total: integer, rowCount: integer, omitted: integer, revision: string, +} +export const DOCUMENT_SOURCE: JsonSchemaNode = { type: 'object', properties: { ...DOCUMENT_FACTS, root: string, + itemEncoding: string, capturedItems: integer, captureComplete: boolean } } +export const DOCUMENT_OUTPUT: JsonSchemaNode = { + type: 'object', properties: { + ...DOCUMENT_FACTS, status: string, phase: string, changed: integer, effectState: string, path: string, + items: { type: 'array', items: object({ row: integer, cells: strings, lineStart: integer, lineEnd: integer }, ['row', 'cells', 'lineStart', 'lineEnd']) }, + error: { type: 'object' }, nextAction: string, reference: STORAGE_REFERENCE, referenceRoot: string, + source: DOCUMENT_SOURCE, storageError: { type: 'object' }, + }, +} +export function isDocumentTool(tool: string): tool is keyof typeof DOCUMENT_PARAMETERS { return Object.hasOwn(DOCUMENT_PARAMETERS, tool) } +export function documentOutput(tool: string): JsonSchemaNode | undefined { return isDocumentTool(tool) ? DOCUMENT_OUTPUT : undefined } diff --git a/src/capabilities/file.ts b/src/capabilities/file.ts index c21bbd9c..66520864 100644 --- a/src/capabilities/file.ts +++ b/src/capabilities/file.ts @@ -1,5 +1,6 @@ /** File capability data; no host services or registration side effects. */ import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import { DOCUMENT_PARAMETERS, DOCUMENT_DESCRIPTIONS, documentOutput } from './documents.js' import { STORAGE_REFERENCE } from './result.js' import type { ParameterSchema } from './types.js' @@ -16,6 +17,7 @@ const existingRevision: JsonSchemaNode = { type: 'string', description: '64 lowe const edits = bounded({ path: string, expectedRevision: existingRevision, edits: { type: 'array', description: '1-100 nonoverlapping edits against the same original bytes.', items: bounded({ oldText: { ...string, description: 'Nonempty text that occurs exactly once in the original.' }, newText: string }, ['oldText', 'newText']) }, dryRun: { type: 'boolean' } }, ['path', 'expectedRevision', 'edits']) export const PARAMETERS = Object.freeze({ + ...DOCUMENT_PARAMETERS, read_many: bounded({ files: { type: 'array', description: '1-32 file ranges.', items: bounded({ path: string, offset: { type: 'integer', description: 'Line >=1; default 1.' }, limit: range(1, 10000, 'Default 200.'), }, ['path']) } }, ['files']), @@ -105,6 +107,8 @@ export const SEARCH_OUTPUT: JsonSchemaNode = { /** Every projection declares the same canonical execution shape. */ export function fileOutput(rawName: keyof typeof PARAMETERS): JsonSchemaNode { + const document = documentOutput(rawName) + if (document) return document if (rawName === 'read_many') return READ_MANY_OUTPUT if (rawName === 'glob' || rawName === 'grep') return SEARCH_OUTPUT return OUTPUT @@ -114,6 +118,7 @@ export function fileOutput(rawName: keyof typeof PARAMETERS): JsonSchemaNode { const RESULT = ' Failures resolve as {status:"error"|"rejected", error.code}; they are not thrown.' const EDIT_DESCRIPTION = 'Precisely patch one UTF-8 file using 1-100 oldText/newText edits against the SAME original bytes. Every nonempty oldText must occur exactly once; overlapping edits reject all. Required expectedRevision from inspect/stat. Atomic CAS; dryRun previews. Darwin nonempty extended ACLs fail closed with METADATA_UNSUPPORTED. Preserve CRLF/BOM explicitly in text. Windows preserves owner/group/ordered DACL and supported basic attributes/timestamps; readonly overwrite is refused and unsupported streams/EA/special storage fail with METADATA_UNSUPPORTED. SACL/audit labels are not preserved.' + RESULT export const DESCRIPTIONS = Object.freeze({ + ...DOCUMENT_DESCRIPTIONS, read_many: 'Read UTF-8 ranges relative to session cwd. Check each file complete/error; nextOffset continues lines, nextIndex resumes pending files. Binary needs a binary reader. Failures return status:error|rejected and error.code; never throw.', inspect_local_file: 'Read bounded UTF-8 text lines or a hex view from path relative to the DSH session cwd. Directories are rejected; use ops_glob or ops_grep.' + RESULT, glob: 'Find regular files below path relative to the current session cwd. pattern is an ARRAY of globs matched against paths relative to path: ["**/*.md","!vendor/**"]; "*.md" matches the top level only, "!" excludes, at least one positive entry. Symlinks, reparse points, hidden files and dependency/build directories are excluded. limit defaults to 100; paging uses 0-based offset/nextOffset. Check scanComplete and pageComplete; matchedTotal exists only after a complete scan. Prefer continuation with unchanged options; SEARCH_STALE means restart. Incomplete scans require a narrower query.' + RESULT, diff --git a/src/capabilities/registry.ts b/src/capabilities/registry.ts index 885abc9b..9d1f9647 100644 --- a/src/capabilities/registry.ts +++ b/src/capabilities/registry.ts @@ -10,7 +10,7 @@ import type { CapabilityMetadata, Component, SchemaCapability } from './types.js export type FileToolName = keyof typeof fileParameters export type PtcToolName = keyof typeof ptcParameters // Preserve the published registration order independently of schema object key order. -const fileOrder = ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many'] as const satisfies readonly FileToolName[] +const fileOrder = ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read'] as const satisfies readonly FileToolName[] const fileMutations = new Set(['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) const allPresentations = Object.freeze(['native', 'ptc', 'both'] as const) diff --git a/src/kernel/errors.ts b/src/kernel/errors.ts index aa9ea6ba..bd489c6f 100644 --- a/src/kernel/errors.ts +++ b/src/kernel/errors.ts @@ -22,6 +22,8 @@ export const ERROR_POLICIES: Readonly> = Object.free OUTPUT_LIMIT: invalid, CROSS_DEVICE: invalid, ERR_INVALID_ENCODING: invalid, + TABLE_PARSE_ERROR: invalid, + FILE_CHANGED: changed, REFERENCE_EXPIRED: changed, AUTHORITY_CHANGED: changed, ACCESS_DENIED: changed, diff --git a/src/modes/native/documents.ts b/src/modes/native/documents.ts new file mode 100644 index 00000000..49551e53 --- /dev/null +++ b/src/modes/native/documents.ts @@ -0,0 +1,19 @@ +/** Native document rendering keeps safe parsed prefixes beside non-exhaustive coverage facts. */ +type Row = Record +const object = (value: unknown): value is Row => !!value && typeof value === 'object' && !Array.isArray(value) +const show = (value: unknown): string => (JSON.stringify(value) ?? 'null').replace(/[\u0085\u2028\u2029]/gu, char => '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0')) +const facts = (value: Row, keys: string[]): string => keys.filter(key => value[key] !== undefined).map(key => key + '=' + show(value[key])).join('; ') +export function renderDocument(_tool: string, args: Row, value: Row): string[] { + const items = Array.isArray(value.items) ? value.items.filter(object) : [] + const lines = ['=== DOCUMENT: ' + show(value.path ?? args.path ?? '(path unavailable)') + ' ===', + '[Observation: ' + facts(value, ['status', 'format', 'encoding', 'header', 'bytes', 'bytesRead', 'revision', 'columnCount']) + '.]', + '[Coverage: ' + facts(value, ['scanComplete', 'pageComplete', 'complete', 'rowsObserved', 'skipped', 'total', 'omitted', 'nextOffset', 'truncatedBy']) + '.]'] + if (Array.isArray(value.columns)) lines.push('Columns: ' + show(value.columns)) + if (object(value.error)) lines.push('Error: ' + facts(value.error, ['code', 'message', 'effectState', 'nextAction'])) + if (value.scanComplete === false || object(value.error)) lines.push('[Notice: This observation is not exhaustive. Only preceding complete records are shown; page offsets do not recover a physical scan gap.]') + for (const item of items) lines.push('Row ' + show(item.row) + ' (LF lines ' + show(item.lineStart) + '-' + show(item.lineEnd) + ') | ' + show(item.cells)) + if (!items.length) lines.push('(no records in this displayed range)') + lines.push('[Notice: displayedRecords=' + items.length + '. Scan, requested range, and displayed content have separate completeness.]') + if (typeof value.nextAction === 'string') lines.push('Next: ' + show(value.nextAction)) + return lines +} diff --git a/src/modes/native/render.ts b/src/modes/native/render.ts index 7f97434b..20ec692e 100644 --- a/src/modes/native/render.ts +++ b/src/modes/native/render.ts @@ -1,4 +1,6 @@ import { Buffer } from 'node:buffer' +import { renderDocument } from './documents.js' +import { isDocumentTool } from '../../capabilities/documents.js' import { renderResult } from './result.js' import { errorPolicy } from '../../kernel/errors.js' import type { EffectState } from '../../kernel/outcome.js' @@ -161,7 +163,8 @@ export function renderNative(tool: string, args: unknown, result: unknown): stri if (!record(result)) return capNativeText('[Notice: No structured result was returned.]') const input = record(args) ? args : {} let lines: string[] - if (record(result.error) || ['error', 'rejected', 'conflict', 'unknown', 'failed'].includes(String(result.status))) lines = failure(result) + if (isDocumentTool(tool)) lines = renderDocument(tool, input, result) + else if (record(result.error) || ['error', 'rejected', 'conflict', 'unknown', 'failed'].includes(String(result.status))) lines = failure(result) else if (tool === 'result') return capNativeText(renderResult(input, result), NATIVE_OUTPUT_BYTES, 'Repeat the same offset with smaller limit/maxBytes; do not advance past undisplayed rows.') else if (tool === 'read_many') lines = readMany(result) else if (tool === 'inspect_local_file') lines = inspect(input, result) @@ -175,7 +178,7 @@ export function renderNative(tool: string, args: unknown, result: unknown): stri if (record(result.storageError)) lines.unshift('[Storage: ' + facts(result.storageError, ['code', 'message', 'effectState']) + '. Cache effects are separate from business effects.]') return capNativeText(lines.join('\n'), NATIVE_OUTPUT_BYTES, mutations.has(tool) ? 'Inspect current state before another mutation; do not repeat the write to recover undisplayed receipts.' - : undefined) + : isDocumentTool(tool) ? 'Use the stored reference when available; otherwise repeat this range with a smaller limit or use a dedicated reader for a large record. Do not advance past undisplayed content.' : undefined) } function readMany(value: Row): string[] { diff --git a/src/modes/ptc/canonical-schema.ts b/src/modes/ptc/canonical-schema.ts index 00689dd4..8d90548c 100644 --- a/src/modes/ptc/canonical-schema.ts +++ b/src/modes/ptc/canonical-schema.ts @@ -1,5 +1,6 @@ /** Typed fields describe canonical data, independently of the Native display projection. */ import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import { DOCUMENT_FACTS, isDocumentTool } from '../../capabilities/documents.js' import { capability } from '../../capabilities/registry.js' import type { FileToolName, PtcToolName } from '../../capabilities/registry.js' import { STORAGE_REFERENCE } from '../../capabilities/result.js' @@ -27,6 +28,7 @@ export function canonicalOutput(tool: FileToolName | PtcToolName): JsonSchemaNod const entry = capability(tool) if (!entry || !('output' in entry)) throw new Error(`Missing canonical schema: ${tool}`) const properties: Record = { ...entry.output.properties, ...cache } + if (isDocumentTool(tool)) return { ...entry.output, properties } if (tool === 'inspect_local_file') Object.assign(properties, facts, { path: string, items: array(physicalLine) }) else if (tool === 'read_many') properties.items = array(object({ ...range, items: array(physicalLine) })) else if (tool === 'glob') properties.items = array({ oneOf: [string, object({ path: string, bytes: integer, modified: nullableInteger })] }) @@ -34,7 +36,7 @@ export function canonicalOutput(tool: FileToolName | PtcToolName): JsonSchemaNod else if (tool === 'stat') Object.assign(properties, facts, { exists: boolean, path: string, readonly: boolean, mode: string, modifiedMs: nullableInteger }) else if (tool === 'diff') Object.assign(properties, { before: object(facts), after: object(facts), hunk: object({ startLine: integer, removed: integer, added: integer }), items: array(object({ kind: string, line: integer, text: string })) }) - else if (tool === 'result') properties.source = object({ ...range, ...coverage, root: string, + else if (tool === 'result') properties.source = object({ ...range, ...coverage, ...DOCUMENT_FACTS, root: string, itemEncoding: string, capturedItems: integer, captureComplete: boolean }) else if (tool !== 'batch') properties.items = array(receipt) return { ...entry.output, properties } diff --git a/src/modes/ptc/continuation.ts b/src/modes/ptc/continuation.ts index 459aeffc..608b4e5d 100644 --- a/src/modes/ptc/continuation.ts +++ b/src/modes/ptc/continuation.ts @@ -2,7 +2,7 @@ import type { CanonicalData, JsonValue } from './outcome.js' const object = (value: unknown): CanonicalData | undefined => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as CanonicalData : undefined const integer = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0 -const readers = ['inspect_local_file', 'read_many', 'glob', 'grep', 'diff', 'result'] as const +const readers = ['inspect_local_file', 'read_many', 'glob', 'grep', 'diff', 'result', 'table_read'] as const export type ContinuationTool = typeof readers[number] export interface RetrievalContinuation { version: 1, tool: ContinuationTool, args: CanonicalData } const PREFIX = 'ops-next-v1:' @@ -53,6 +53,13 @@ export function continuationFor(tool: string, rawArgs: unknown, data: CanonicalD if ((tool === 'grep' || tool === 'glob') && data.scanComplete === true && typeof data.continuation === 'string') { return encode(tool, { ...args, continuation: data.continuation, ...(integer(data.nextOffset) ? { offset: data.nextOffset } : {}) }) } + if (tool === 'table_read') { + const offset = args.offset ?? 1 + return data.scanComplete === true && typeof data.revision === 'string' && /^[a-f0-9]{64}$/u.test(data.revision) + && integer(offset) && offset > 0 && integer(data.nextOffset) && data.nextOffset > offset + && integer(data.total) && data.nextOffset <= data.total + ? encode(tool, { ...args, offset: data.nextOffset }) : undefined + } if (tool === 'read_many') { const next = readMany(args, data) return next ? encode('read_many', next) : undefined diff --git a/test/capabilities.test.mjs b/test/capabilities.test.mjs index 4ac03c75..4ea6ed71 100644 --- a/test/capabilities.test.mjs +++ b/test/capabilities.test.mjs @@ -10,9 +10,9 @@ import { BASH_TOOL_NAME } from '../lib/shells/constants.js' import { assert, test, report } from './lib/harness.mjs' await test('the catalogue covers every owned file, PTC and shell tool exactly once', () => { - assert.equal(CAPABILITIES.length, 18) + assert.equal(CAPABILITIES.length, 19) assert.equal(new Set(CAPABILITIES.map(item => item.rawName)).size, CAPABILITIES.length) - assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many']) + assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read']) assert.deepEqual(FILE_MUTATIONS, ['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) assert.deepEqual(PTC_TOOLS, ['batch', 'result']) assert.deepEqual(IN_PROCESS_TOOLS, ['bash']) @@ -55,7 +55,7 @@ const snapshot = (names, definition) => names.map(name => { await test('business file and batch definitions retain their original serialized contracts', () => { // Captured before this migration, covering every argument/output contract and published description. const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') - assert.equal(hash(snapshot(FILE_TOOLS.filter(name => !['glob', 'grep', 'read_many', 'edit_many'].includes(name)), fileDefinition)), 'f32e33306b4052fdb3d19a11e0fe9d65c255a87d2d485de08a9d7b1275e958fc') + assert.equal(hash(snapshot(FILE_TOOLS.filter(name => !['glob', 'grep', 'read_many', 'edit_many', 'table_read'].includes(name)), fileDefinition)), 'f32e33306b4052fdb3d19a11e0fe9d65c255a87d2d485de08a9d7b1275e958fc') const batch = ptcDefinition('batch', async () => null) assert.equal(batch.parameters, ptcParameters.batch); assert.ok(batch.description.startsWith(ptcDescriptions.batch)); assert.match(batch.description, /PTC returns Outcome/) assert.ok(batch.output.schema.oneOf.every(branch => branch.properties.status)) diff --git a/test/lib/document-host.mjs b/test/lib/document-host.mjs new file mode 100644 index 00000000..8160fad5 --- /dev/null +++ b/test/lib/document-host.mjs @@ -0,0 +1,49 @@ +/** Real host composition shared by document capability adapters. */ +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import Sandbox from '@deepseek-ai/dsh-sandbox-local' +import FileSystem from '@deepseek-ai/dsh-fs-local' +import NodePtc from '@deepseek-ai/dsh-ptc-runtime-node' +import { createScope } from '@deepseek-ai/dsh-scope' +import { bootHost } from './host.mjs' +import { WORKER } from './boundary.mjs' +import { assert } from './harness.mjs' +import { publicToolName } from '../../lib/policy.js' +import * as discovery from '../../lib/discovery/index.js' +import * as ptc from '../../lib/ptc/index.js' +let serial = 0 +export async function documentHost(root, names, { real = false } = {}) { + const { ctx } = await bootHost() + const agents = []; ctx.provide('agents', { list: () => agents }) + await ctx.plugin(Subprocess); await ctx.plugin(Sandbox); await ctx.plugin(FileSystem) + await ctx.plugin(inner => { + inner.provide('sandboxPolicy', { resolve: ({ session } = {}) => ({ mode: session?.mode ?? 'workspace-write', workspaceRoot: root, sessionId: session?.id }) }) + if (!real) inner.provide('ptcRuntime', { language: 'typescript' }) + }) + if (real) await ctx.plugin(NodePtc, {}) + await ctx.plugin(discovery, { binaryPath: WORKER, required: true, allowedTools: names }) + await ctx.plugin(ptc, { binaryPath: WORKER, required: true, allowedTools: names.filter(name => !['result', 'inspect_local_file'].includes(name)) }) + async function agent(mode = 'native', { policy = 'workspace-write', cwd = root, parent } = {}) { + const id = 'document-host-' + ++serial + const value = { id, session: { id, mode: policy, header: { cwd }, append() {}, read() { return [] } } } + value.ctx = createScope(parent?.ctx ?? ctx, value, parent ? { parent } : undefined).ctx + await value.ctx.plugin({ inject: ['tools'], apply(inner) { inner.tools.presentAs(mode) } }) + agents.push(value); await ctx.serial('agent/created', { agent: value }); return value + } + return { ctx, agent } +} +export const documentStep = (ctx, agent) => ctx.waterfall('agent/pre-step', { agent, messages: [], turn: 1, step: ++serial, signal: new AbortController().signal }, async () => ({ kind: 'continue' })) +export const documentNames = (ctx, agent) => ctx.get('tools').schemas(agent).map(tool => tool.name) +export const documentCall = (ctx, agent, name, args) => ctx.get('tools').get(publicToolName(name), agent).execute(args, { agent, signal: new AbortController().signal }) +export async function activateDocument(ctx, agent, name, mode = 'native') { + const result = await documentCall(ctx, agent, mode === 'native' ? 'discover' : 'sdk', mode === 'native' ? { query: 'select:' + publicToolName(name) } : { tools: [name] }) + if (mode === 'native') assert.deepEqual(result.activated, [publicToolName(name)]) + else assert.equal(result.status, 'completed', JSON.stringify(result)) + assert.equal(documentNames(ctx, agent).includes(publicToolName(name)), false) + await documentStep(ctx, agent) + assert.ok(documentNames(ctx, agent).includes(publicToolName(name))) +} +export async function documentCode(ctx, agent, code) { + const result = await ctx.get('tools').execute({ name: 'run_code', callId: 'documents-' + ++serial, agent, signal: new AbortController().signal, + arguments: { description: 'Read document data through the scoped SDK', code } }) + assert.equal(result.isError, false, JSON.stringify(result)); return result.value.result +} diff --git a/test/table-host.test.mjs b/test/table-host.test.mjs new file mode 100644 index 00000000..7bfe90f5 --- /dev/null +++ b/test/table-host.test.mjs @@ -0,0 +1,120 @@ +/** Document tools share live authority while preserving separate Native/PTC projections. */ +import fs from 'node:fs' +import path from 'node:path' +import { assertSupportedJsonSchema, jsonSchemaToTs } from '@deepseek-ai/dsh-tools' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { shutdown } from './lib/host.mjs' +import { requireWorker } from './lib/boundary.mjs' +import { documentHost, documentNames, documentCall, documentCode, activateDocument } from './lib/document-host.mjs' +import { capability } from '../lib/capabilities/registry.js' +import { publicToolName } from '../lib/policy.js' +import { canonicalOutput } from '../lib/modes/ptc/canonical-schema.js' +import { decodeContinuation, continuationFor } from '../lib/modes/ptc/continuation.js' +import { definition } from '../lib/file/contract.js' +requireWorker('table-host') +const TOOL = publicToolName('table_read') +const rendered = (args, value) => definition('table_read', async () => value).output.render(args, value)[0].text +await test('table catalogue is discoverable, read-only, strict and generates lexical row types', () => { + const entry = capability('table_read') + assert.equal(entry.availability, 'discoverable'); assert.equal(entry.mutates, false); assert.equal(entry.concurrencySafe, true) + assert.equal(entry.parameters.additionalProperties, false); assert.deepEqual(entry.parameters.required, ['path']) + for (const schema of [entry.parameters, canonicalOutput('table_read'), canonicalOutput('result')]) assertSupportedJsonSchema(schema) + const schema = canonicalOutput('table_read') + assert.equal(schema.properties.items.items.properties.cells.items.type, 'string') + assert.equal(schema.properties.source.properties.rowsObserved.type, 'integer') + assert.equal(canonicalOutput('result').properties.source.properties.rowsObserved.type, 'integer') + assert.match(jsonSchemaToTs(schema), /cells: string\[\]/) + const common = { scanComplete: true, revision: 'a'.repeat(64), nextOffset: 2, total: 3 } + assert.equal(decodeContinuation(continuationFor('table_read', { path: 'x' }, common)).args.offset, 2) + for (const change of [{ scanComplete: false }, { revision: undefined }, { nextOffset: 1 }, { nextOffset: 4 }]) { + assert.equal(continuationFor('table_read', { path: 'x' }, { ...common, ...change }), undefined) + } +}) +await test('Native table activation is next-step and owner-only; relative cwd and positional records survive rendering', () => withTempDir('table-native', async root => { + const cwd = path.join(root, 'nested'); fs.mkdirSync(cwd) + fs.writeFileSync(path.join(cwd, 'data.csv'), 'name,name,\n"中,文","two\nlines",0001\n') + const { ctx, agent } = await documentHost(root, ['table_read']) + try { + const owner = await agent('native', { cwd }), sibling = await agent(), child = await agent('native', { parent: owner }) + assert.equal(documentNames(ctx, owner).includes(TOOL), false) + const assembly = await ctx.get('systemPrompt').assemble({ scope: owner }) + assert.equal(assembly.tools.some(tool => tool.name === TOOL), false) + await activateDocument(ctx, owner, 'table_read') + for (const other of [sibling, child]) assert.equal(documentNames(ctx, other).includes(TOOL), false) + const data = await documentCall(ctx, owner, 'table_read', { path: 'data.csv' }) + assert.equal(data.status, 'completed', JSON.stringify(data)); assert.equal(data.complete, true) + assert.deepEqual(data.columns, ['name', 'name', '']); assert.deepEqual(data.items[0].cells, ['中,文', 'two\nlines', '0001']) + assert.equal(data.path, path.join(cwd, 'data.csv')) + const text = rendered({ path: 'data.csv' }, data) + assert.match(text, /Row 1 \(LF lines 2-3\)/); assert.match(text, /"two\\nlines"/); assert.match(text, /scanComplete=true/) + assert.doesNotMatch(text, /Mutation|receipt/) + } finally { await shutdown(ctx) } +})) +await test('real PTC SDK returns typed lexical rows and only full stable scans supply forward page cursors', () => withTempDir('table-ptc', async root => { + fs.writeFileSync(path.join(root, 'rows.tsv'), 'id\tvalue\n0001\tTRUE\n0002\tFALSE\n0003\tnull\n') + const { ctx, agent } = await documentHost(root, ['table_read'], { real: true }) + try { + const owner = await agent('ptc'), sibling = await agent('ptc') + await activateDocument(ctx, owner, 'table_read', 'ptc') + assert.equal(documentNames(ctx, sibling).includes(TOOL), false) + const first = await documentCode(ctx, owner, `return await tools.${TOOL}({path:'rows.tsv',limit:1});`) + assert.equal(first.status, 'partial', JSON.stringify(first)); assert.equal(first.data.scanComplete, true) + assert.deepEqual(first.data.items[0].cells, ['0001', 'TRUE']); assert.equal(first.data.total, 3) + const next = decodeContinuation(first.continuation) + assert.deepEqual(next, { version: 1, tool: 'table_read', args: { path: 'rows.tsv', limit: 1, offset: 2 } }) + const second = await documentCall(ctx, owner, next.tool, next.args) + assert.equal(second.status, 'partial'); assert.deepEqual(second.data.items[0].cells, ['0002', 'FALSE']) + assert.equal(second.data.revision, first.data.revision) + const tail = await documentCall(ctx, owner, 'table_read', { path: 'rows.tsv', offset: 3, limit: 1 }) + assert.equal(tail.status, 'completed'); assert.equal(tail.data.complete, true) + } finally { await shutdown(ctx) } +})) +await test('malformed late records retain safe prefix data in both projections without resumable scan-gap advice', () => withTempDir('table-gap-host', async root => { + fs.writeFileSync(path.join(root, 'bad.csv'), 'name\nsafe\n"unterminated') + const { ctx, agent } = await documentHost(root, ['table_read']) + try { + const native = await agent(), code = await agent('ptc') + await activateDocument(ctx, native, 'table_read'); await activateDocument(ctx, code, 'table_read', 'ptc') + const raw = await documentCall(ctx, native, 'table_read', { path: 'bad.csv' }) + const text = rendered({ path: 'bad.csv' }, raw) + assert.match(text, /TABLE_PARSE_ERROR/); assert.match(text, /Row 1.*"safe"/); assert.match(text, /not exhaustive/) + assert.equal(raw.scanComplete, false); assert.equal(raw.total, undefined) + const typed = await documentCall(ctx, code, 'table_read', { path: 'bad.csv' }) + assert.equal(typed.status, 'error'); assert.equal(typed.error.code, 'TABLE_PARSE_ERROR'); assert.equal(typed.error.effectState, 'none') + assert.equal(typed.error.retry, 'after-input-change'); assert.equal(typed.continuation, undefined) + assert.deepEqual(typed.data.items[0].cells, ['safe']); assert.equal(typed.data.revision, undefined) + } finally { await shutdown(ctx) } +})) +await test('read-only table calls create no files and current credentials remain denied', () => withTempDir('table-authority-host', async root => { + fs.writeFileSync(path.join(root, 'ok.csv'), 'key\nvalue\n'); fs.mkdirSync(path.join(root, '.ssh')); fs.writeFileSync(path.join(root, '.ssh', 'id_ed25519.csv'), 'SECRET') + const before = fs.readdirSync(root, { recursive: true }).sort() + const { ctx, agent } = await documentHost(root, ['table_read']) + try { + const owner = await agent('native', { policy: 'read-only' }); await activateDocument(ctx, owner, 'table_read') + const ok = await documentCall(ctx, owner, 'table_read', { path: 'ok.csv' }) + assert.equal(ok.complete, true); assert.deepEqual(ok.items[0].cells, ['value']) + const denied = await documentCall(ctx, owner, 'table_read', { path: '.ssh/id_ed25519.csv' }) + assert.equal(denied.error.code, 'CREDENTIAL_DENIED'); assert.equal(denied.error.effectState, 'none'); assert.doesNotMatch(JSON.stringify(denied), /SECRET/) + const invalid = await documentCall(ctx, owner, 'table_read', { path: 'ok.csv', limit: 0 }) + assert.equal(invalid.error.code, 'INVALID_ARGUMENT') + assert.deepEqual(fs.readdirSync(root, { recursive: true }).sort(), before) + } finally { await shutdown(ctx) } +})) +await test('large Native table rows spill with original coverage and recoverable item metadata', () => withTempDir('table-spill-host', async root => { + fs.writeFileSync(path.join(root, 'big.csv'), 'value\n' + '😀'.repeat(40000) + '\n') + const { ctx, agent } = await documentHost(root, ['table_read', 'result']) + try { + const owner = await agent(); await activateDocument(ctx, owner, 'table_read'); await activateDocument(ctx, owner, 'result') + const data = await documentCall(ctx, owner, 'table_read', { path: 'big.csv' }) + assert.equal(data.reference.producerTool, 'table_read'); assert.equal(data.scanComplete, true) + assert.equal(data.complete, false); assert.equal(data.rowsObserved, 1) + assert.equal(fs.realpathSync.native(data.referenceRoot), fs.realpathSync.native(root)) + const content = [{ type: 'text', text: rendered({ path: 'big.csv' }, data) }] + assert.ok(Buffer.byteLength(JSON.stringify(content)) <= 65536); assert.match(content[0].text, /ref:\/\//) + const page = await documentCall(ctx, owner, 'result', { action: 'page', id: data.reference.refId, root: data.referenceRoot, limit: 1 }) + assert.equal(page.source.rowsObserved, 1); assert.equal(page.source.scanComplete, true) + assert.equal(page.source.captureComplete, true); assert.equal(page.source.capturedItems, 1) + assert.equal(page.items[0].kind, 'item-json-chunk'); assert.equal(page.items[0].index, 0); assert.equal(page.items[0].byteOffset, 0) + } finally { await shutdown(ctx) } +})) +report('table-host') diff --git a/test/types/ptc-sdk.ts b/test/types/ptc-sdk.ts index dbb4bc39..61f76c46 100644 --- a/test/types/ptc-sdk.ts +++ b/test/types/ptc-sdk.ts @@ -39,3 +39,18 @@ void tools.ops_edit_many({ path: 'a.ts', edits: [{ oldText: 'a', newText: 'b' }] void tools.ops_unknown_tool({}) const completed: Outcome = { status: 'completed', data: undefined } void [read, metadata, completed] + +async function tables() { + const result = await tools.ops_table_read({ path: 'rows.csv', header: false, encoding: 'utf-16le' }) + if (result.status === 'completed' || result.status === 'partial') { + const cells: string[] | undefined = result.data.items?.[0]?.cells + const rows: number | undefined = result.data.source?.rowsObserved + // @ts-expect-error Cells preserve lexical text instead of guessing numbers. + const numeric: number[] | undefined = result.data.items?.[0]?.cells + return { cells, rows } + } + return result.data?.items?.[0]?.cells +} +// @ts-expect-error Encoding is a finite strict-decoding contract. +void tools.ops_table_read({ path: 'rows.csv', encoding: 'latin1' }) +void tables From 8913d3e38f9fae14be3b7db5cd78c6bfd098aa89 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:51:39 +0800 Subject: [PATCH 047/118] feat(documents): read bounded DOCX main-body paragraphs --- NOTICE | 6 + docs/CHANGELOG.md | 8 + docs/document-dependencies.md | 51 +++ docs/native-kernel.md | 40 ++ ...37\347\224\237\345\206\205\346\240\270.md" | 30 ++ ...43\346\236\220\344\276\235\350\265\226.md" | 40 ++ licenses/documents/THIRD-PARTY-NOTICES.txt | 313 ++++++++++++++ native/Cargo.lock | 95 +++++ native/crates/ops-documents/Cargo.toml | 2 + native/crates/ops-documents/src/archive.rs | 163 ++++++++ native/crates/ops-documents/src/docx.rs | 386 ++++++++++++++++++ native/crates/ops-documents/src/lib.rs | 4 + native/crates/ops-documents/src/xml.rs | 173 ++++++++ native/crates/ops-results/src/spill.rs | 2 +- native/src/lib.rs | 2 +- native/src/main.rs | 2 +- test/docx-worker.test.mjs | 152 +++++++ test/lib/document-fixture.mjs | 29 ++ test/package-source.test.mjs | 4 +- 19 files changed, 1498 insertions(+), 4 deletions(-) create mode 100644 docs/document-dependencies.md create mode 100644 "docs/\346\226\207\346\241\243\350\247\243\346\236\220\344\276\235\350\265\226.md" create mode 100644 licenses/documents/THIRD-PARTY-NOTICES.txt create mode 100644 native/crates/ops-documents/src/archive.rs create mode 100644 native/crates/ops-documents/src/docx.rs create mode 100644 native/crates/ops-documents/src/xml.rs create mode 100644 test/docx-worker.test.mjs create mode 100644 test/lib/document-fixture.mjs diff --git a/NOTICE b/NOTICE index 15ed7492..a1033468 100644 --- a/NOTICE +++ b/NOTICE @@ -94,3 +94,9 @@ tarballs of earlier versions. FastCtx is developed and maintained at https://github.com/yc-duan/fastctx + +The native worker's DOCX reader uses pinned third-party Rust ZIP/XML libraries. +Their exact versions and dependency rationale are recorded in docs/document-dependencies.md; +original license notices (MIT choices for dual-licensed packages, plus zlib-rs's Zlib license) +are reproduced in licenses/documents/THIRD-PARTY-NOTICES.txt. Existing upstream notices above +remain unchanged. No third-party code is relicensed by the plugin's own MIT grant. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 97e0f097..435e6f92 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Add bounded DOCX main-body worker observations (#261, 15b/16) + +- Read strict DOCX main-body paragraphs and table coordinates with explicit excluded-content + scope, bounded ZIP/XML processing, honest partial results and lossless reference spill. +- Pin ZIP/XML dependencies with defaults disabled; preserve their original license notices and + exact release-date review. Legacy DOC, unsupported content and external resolution are refused. +- 新增有界 DOCX 正文段落/表格坐标读取,明确排除内容及部分扫描;固定经日期核验的 ZIP/XML + 依赖并保留许可,拒绝旧版 DOC 与不支持内容,复用权限、nofollow 和无损引用恢复。 ### Add bounded CSV/TSV worker observations (#261, 15a/16) diff --git a/docs/document-dependencies.md b/docs/document-dependencies.md new file mode 100644 index 00000000..114ddff8 --- /dev/null +++ b/docs/document-dependencies.md @@ -0,0 +1,51 @@ +# Document parser dependency review + +The 15b/16 DOCX worker uses format libraries only after byte/count preflight. It adds no +host dependencies, process execution, external fetches, archive extraction, OCR, or Office +integration. `zip = "=9.0.1"` disables defaults and enables only +`deflate-flate2-zlib-rs`; `quick-xml = "=0.42.0"` disables defaults. These libraries provide +CRC-checked ZIP decompression and namespace-aware XML parsing without implementing those +formats again. Every resolved addition is locked; verification date: 2026-10-19 UTC+08. + +| Locked package | Published UTC | Purpose | License | +| --- | --- | --- | --- | +| zip 9.0.1 | 2026-10-10 06:10:55 | Bounded ZIP entry access | MIT | +| quick-xml 0.42.0 | 2026-08-22 19:22:01 | XML events and namespaces | MIT | +| crc32fast 1.5.2 | 2026-09-12 18:41:46 | ZIP CRC validation | MIT OR Apache-2.0 | +| flate2 1.1.10 | 2026-08-28 04:35:22 | Deflate decoding | MIT OR Apache-2.0 | +| zlib-rs 0.6.8 | 2026-09-15 13:10:00 | Rust deflate implementation | Zlib | +| indexmap 2.14.2 | 2026-09-05 01:53:48 | ZIP metadata index | Apache-2.0 OR MIT | +| hashbrown 0.17.1 | 2026-05-09 04:35:04 | Index storage | MIT OR Apache-2.0 | +| equivalent 1.0.2 | 2025-02-14 22:14:18 | Index key comparison | Apache-2.0 OR MIT | +| typed-path 0.12.3 | 2026-02-11 02:30:37 | ZIP path representation | MIT OR Apache-2.0 | +| nix 0.31.3 | 2026-05-11 07:01:18 | ZIP macOS/BSD target dependency | MIT | +| cfg_aliases 0.2.2 | 2026-07-16 14:57:16 | nix build configuration | MIT | + +Publication dates and non-yanked status were checked against the publisher's +[crates.io version API](https://crates.io/api/v1/crates/zip/9.0.1) for each exact version; +replace the final name/version in that URL to reproduce another row. All are at least seven +days old. Cargo first selected equivalent 1.0.3 (2026-10-13); the lockfile deliberately uses +1.0.2 because 1.0.3 had not reached seven days. Do not update that lock entry casually. +Original license texts are in `licenses/documents/THIRD-PARTY-NOTICES.txt`; MIT is selected +where an alternative license is offered. Existing dependencies are unchanged. + +The checked upstream security fixes include quick-xml namespace allocation/attribute parsing +fixes present since 0.41 and ZIP extraction fixes present since 9.0.0. See the primary +[RustSec XML allocation advisory](https://rustsec.org/advisories/RUSTSEC-2026-0195.html), +[XML attribute advisory](https://rustsec.org/advisories/RUSTSEC-2026-0194.html), and +[ZIP advisory](https://rustsec.org/advisories/RUSTSEC-2026-0367.html). This is a targeted +source/version review, not a claim that a full dependency audit has found no vulnerabilities. +This reader never calls archive extraction APIs. + +The worker rejects ZIP64/multi-disk/encrypted archives; validates exact central-directory +bounds/counts, unique names, local-header agreement and nonoverlapping payloads before +the ZIP constructor; alternate EOCD/ZIP64 footer signatures are refused anywhere in the archive +(including coincidental payload bytes) because constructor recovery could reinterpret them. +The archive offset is fixed at zero. The reader then reads only fixed OPC metadata and word/document.xml into capped +buffers. Deflate output is stopped at the actual decoded limit plus one byte, independent +of advertised sizes; complete reads verify CRC. XML disables DTD/custom entities, checks +UTF-8 or BOM-marked UTF-16 without replacement, and bounds depth, attributes and namespace +bindings. Unsupported content yields explicit errors or partial observations. + +PDF parsing has a separate follow-up review. This slice neither adds a PDF dependency nor +claims PDF support; the table/DOCX readers are not substitutes for the PDF benchmark. diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 0e555b2a..258e1639 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -181,3 +181,43 @@ Malformed/changed input and physical scan limits retain prefix data and recovery continuation that would repeat the same gap. Native reference pages and PTC result pages preserve original table coverage separately from stored-row pagination. Shared admission, current authority, credential checks, cancellation and storage bindings apply unchanged. + +## Bounded DOCX main-body observations (15b/16) + +`doc_read` is a worker-only capability in this slice; later host/discovery integration remains +separate. Arguments are `{path, offset?, limit?}`: path must be absolute after host resolution +against current session cwd; offset is a 1-based paragraph index (1-100000, default 1), limit is +1-1000 (default 200). Each item is `{block,type:"paragraph",text}`; table paragraphs additionally +carry 1-based table/row/cell coordinates. Empty paragraphs are retained. Tabs, line breaks, +Unicode, entity references and lexical spaces are preserved. Formatting and rendered layout, +headers/footers, footnotes/endnotes, comments and package metadata are explicitly excluded in +`scope:"main-body-paragraphs"` and `excludedContent`. A complete result describes that scope, +not the full rendered document. There is no Office automation or external relationship fetch. + +The subset requires a standard internal word/document.xml main part, stored/deflated ZIP, +strict UTF-8 or BOM-marked UTF-16 and recognized transitional/strict Word namespaces. Binary DOC, +macros, encrypted/multi-disk/ZIP64 archives, nested tables, body drawings, fields, revisions, +foreign/unknown body content and malformed structures are explicitly refused. Ambiguous ZIP footer +signatures anywhere in the raw archive are conservatively refused before constructor recovery. A late content +failure returns preceding complete paragraphs with `status:partial`, `scanComplete:false`, no +full total/revision, and recovery guidance. XML never resolves DTD/custom/external entities. + +Bounds apply before library allocation/decompression: archive 32 MiB; central directory 1 MiB +and 4096 entries; each OPC metadata part 1 MiB; main XML 8 MiB actual decoded bytes; one paragraph +1 MiB UTF-8; 100000 observed paragraphs; XML depth 128, attributes 64 per element, namespace +bindings 512 in scope; 30 seconds between parse events plus the transport watchdog. Each fixed +XML part is CRC-checked after bounded decompression. ZIP local/central headers, uniqueness and +nonoverlap are preflighted; nothing is extracted to disk. Selected pages alone are retained. + +`blocksObserved`, `scanComplete`, `pageComplete` and `complete` separate scan coverage, requested +page coverage and remaining paragraphs. `total`, raw-file SHA-256 `revision` and `omitted` are +available only after a stable complete scan; `nextOffset` is always 1-based. Paging rescans the +same file, so callers must compare revisions before combining changing observations. It does +not bypass physical scan limits. The existing spill sink captures requested full paragraphs +before inline clipping, including long Unicode text. Reference pages retain scope/coverage and +captureComplete; their own complete flag describes stored-record pagination. Use referenceRoot +for paging. Reads repeat authorization/no-follow/credential checks and create no artifacts in +read-only sessions; transport or cache failures never imply a business mutation. + +Exact dependency versions, release dates, rationale and original notices are recorded in +[document-dependencies.md](document-dependencies.md). diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index c17331e4..eb1cb269 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -146,3 +146,33 @@ Native 用带物理 LF 行范围的原位置 JSON 单元格展示,并明确提 须比较 revision。非法输入、文件变化和物理扫描上限保留前缀数据与恢复提示,不提供会重复 原缺口的续取。Native/PTC 引用页均区分原表格覆盖率与已存记录分页;既有排队、实时权限、 凭据检查、取消与存储绑定继续适用。 + +## 有界 DOCX 正文观察(15b/16) + +本切片仅在 worker 暴露 `doc_read`,宿主与发现注册另行接入。参数 `{path, offset?, limit?}`: +path 由宿主按当前会话 cwd 解析为绝对路径;offset 为从 1 开始的段落序号(1-100000,默认 1), +limit 为 1-1000(默认 200)。每项为 `{block,type:"paragraph",text}`;表格内段落另有从 1 开始的 + table/row/cell 坐标。保留空段落、制表符、换行、Unicode、实体字符及原文本空白。明确排除格式与 +渲染布局、页眉页脚、脚注尾注、批注和包元数据;`scope:"main-body-paragraphs"` 与 +excludedContent 显示该范围。complete 仅表示该范围完整,不表示整个渲染文档;无 Office 自动化或 +外部关系抓取。 + +支持标准内部 word/document.xml 正文、存储/Deflate ZIP、严格 UTF-8 或 BOM 标记 UTF-16,以及 +过渡/严格 Word 命名空间。二进制 DOC、宏、加密/多磁盘/ZIP64、嵌套表格、正文绘图、域、修订、 +未知/外部命名空间内容和结构错误均明确拒绝。整个原包中的歧义 ZIP 尾记录签名字节也会在构造器恢复 +逻辑之前保守拒绝。后段失败保留此前完整段落,返回 partial、 +scanComplete:false、恢复指引,不给出全量 total/revision。XML 不解析 DTD 或自定义/外部实体。 + +限制在库分配/解压之前施加:原包 32 MiB;中央目录 1 MiB/4096 条目;每个 OPC 元数据部分 1 MiB; +正文 XML 实际解码字节 8 MiB;单段落 UTF-8 1 MiB;100000 段;XML 深度 128、每元素 64 属性、 +作用域内 512 命名空间绑定;解析事件之间 30 秒期限并有传输 watchdog。固定 XML 部分有界解压后 +完整校验 CRC;预检 ZIP 本地/中央头一致性、唯一性及不重叠;不解压到磁盘,仅保留所请求的段落页。 + +blocksObserved、scanComplete、pageComplete 和 complete 分别描述已观察段落、扫描、所请求页和 +剩余段落。仅稳定完整扫描后才有 total、原包 SHA-256 revision 及 omitted;nextOffset 从 1 开始。 +分页会重新扫描同一文件,合并观察前需比较 revision;分页不能绕过物理扫描上限。既有 spill 在内联 +裁剪前捕获请求的完整段落,包括长 Unicode 文本。引用页保留范围、覆盖率与 captureComplete;页本身 +complete 仅表示存储记录分页结束。按 referenceRoot 读取。每次读取重复权限、nofollow 与凭据检查; +只读会话不创建产物,传输或缓存失败不暗示业务写入。 + +精确依赖、发布日期、理由与原始许可记录见[文档解析依赖.md](文档解析依赖.md)。 diff --git "a/docs/\346\226\207\346\241\243\350\247\243\346\236\220\344\276\235\350\265\226.md" "b/docs/\346\226\207\346\241\243\350\247\243\346\236\220\344\276\235\350\265\226.md" new file mode 100644 index 00000000..e961b593 --- /dev/null +++ "b/docs/\346\226\207\346\241\243\350\247\243\346\236\220\344\276\235\350\265\226.md" @@ -0,0 +1,40 @@ +# 文档解析依赖审查 + +15b/16 DOCX worker 仅在字节数、条目数预检后调用格式库。不增加宿主依赖、进程执行、外部请求、 +解压到磁盘、OCR 或 Office 集成。zip 精确固定为 9.0.1、关闭默认功能,只开启 +`deflate-flate2-zlib-rs`;quick-xml 精确固定为 0.42.0、关闭默认功能。分别负责 CRC 校验的 +ZIP 解压及 XML 命名空间事件解析,避免重新实现通用格式。核验日期:2026-10-19(UTC+08)。 + +| 锁定包 | 发布时间(UTC) | 用途 | 许可证 | +| --- | --- | --- | --- | +| zip 9.0.1 | 2026-10-10 06:10:55 | 有界 ZIP 条目访问 | MIT | +| quick-xml 0.42.0 | 2026-08-22 19:22:01 | XML 事件及命名空间 | MIT | +| crc32fast 1.5.2 | 2026-09-12 18:41:46 | ZIP CRC 校验 | MIT OR Apache-2.0 | +| flate2 1.1.10 | 2026-08-28 04:35:22 | Deflate 解码 | MIT OR Apache-2.0 | +| zlib-rs 0.6.8 | 2026-09-15 13:10:00 | Rust Deflate 实现 | Zlib | +| indexmap 2.14.2 | 2026-09-05 01:53:48 | ZIP 元数据索引 | Apache-2.0 OR MIT | +| hashbrown 0.17.1 | 2026-05-09 04:35:04 | 索引存储 | MIT OR Apache-2.0 | +| equivalent 1.0.2 | 2025-02-14 22:14:18 | 索引键比较 | Apache-2.0 OR MIT | +| typed-path 0.12.3 | 2026-02-11 02:30:37 | ZIP 路径表示 | MIT OR Apache-2.0 | +| nix 0.31.3 | 2026-05-11 07:01:18 | ZIP 的 macOS/BSD 平台依赖 | MIT | +| cfg_aliases 0.2.2 | 2026-07-16 14:57:16 | nix 构建配置 | MIT | + +精确版本的日期及未撤回状态均通过发布者的 +[crates.io 版本 API](https://crates.io/api/v1/crates/zip/9.0.1) 核验;将 URL 末尾的包名和版本替换 +即可复查其他行。所有新增版本均已发布至少七天。Cargo 最初解析到 equivalent 1.0.3(10 月 13 日), +因不足七天而将锁文件固定为 1.0.2;不要随意更新。原始许可文本保存在 +`licenses/documents/THIRD-PARTY-NOTICES.txt`,双许可包选择 MIT;既有依赖不变。 + +审查包含 quick-xml 0.41 起修复的命名空间分配与属性解析问题,以及 ZIP 9.0.0 起的解压 API 修复。 +原始公告:[XML 分配](https://rustsec.org/advisories/RUSTSEC-2026-0195.html)、 +[XML 属性](https://rustsec.org/advisories/RUSTSEC-2026-0194.html)、 +[ZIP](https://rustsec.org/advisories/RUSTSEC-2026-0367.html)。这是针对性源码和版本审查, +并不声明完整依赖审计无漏洞。worker 从不调用解压到文件系统的 API。 + +在调用 ZIP 构造器之前,worker 拒绝 ZIP64、多磁盘和加密包,验证中央目录的精确边界、 +数量、唯一名称、本地头一致性与非重叠载荷;仅以限定大小缓冲读取固定 OPC 元数据和 +word/document.xml。解压以实际输出字节数限额加一为界,不信任声明长度;完整读取校验 CRC。 +XML 禁用 DTD 与自定义实体、严格 UTF-8 或 BOM 标记 UTF-16 解码、限制深度、属性数和命名空间绑定数。 +不支持的内容返回明确错误或部分观察结果。 + +PDF 解析另有后续审查。本切片不增加 PDF 依赖、不声明 PDF 支持;CSV/DOCX 不能替代 PDF 基准。 diff --git a/licenses/documents/THIRD-PARTY-NOTICES.txt b/licenses/documents/THIRD-PARTY-NOTICES.txt new file mode 100644 index 00000000..c639454d --- /dev/null +++ b/licenses/documents/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,313 @@ + +================================================================ +cfg_aliases-0.2.2 — LICENSE +https://crates.io/crates/cfg_aliases/0.2.2 +================================================================ + +MIT License + +Copyright (c) 2020 Katharos Technology + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +================================================================ +crc32fast-1.5.2 — LICENSE-MIT +https://crates.io/crates/crc32fast/1.5.2 +================================================================ + +MIT License + +Copyright (c) 2018 Sam Rijs, Alex Crichton and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +================================================================ +equivalent-1.0.2 — LICENSE-MIT +https://crates.io/crates/equivalent/1.0.2 +================================================================ + +Copyright (c) 2016--2023 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +================================================================ +flate2-1.1.10 — LICENSE-MIT +https://crates.io/crates/flate2/1.1.10 +================================================================ + +Copyright (c) 2014-2026 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +================================================================ +hashbrown-0.17.1 — LICENSE-MIT +https://crates.io/crates/hashbrown/0.17.1 +================================================================ + +Copyright (c) 2016 Amanieu d'Antras + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +================================================================ +indexmap-2.14.2 — LICENSE-MIT +https://crates.io/crates/indexmap/2.14.2 +================================================================ + +Copyright (c) 2016--2017 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +================================================================ +nix-0.31.3 — LICENSE +https://crates.io/crates/nix/0.31.3 +================================================================ + +The MIT License (MIT) + +Copyright (c) 2015 Carl Lerche + nix-rust Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +================================================================ +quick-xml-0.42.0 — LICENSE-MIT.md +https://crates.io/crates/quick-xml/0.42.0 +================================================================ + +The MIT License (MIT) + +Copyright (c) 2016 Johann Tuffe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +================================================================ +typed-path-0.12.3 — LICENSE-MIT +https://crates.io/crates/typed-path/0.12.3 +================================================================ + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +================================================================ +zip-9.0.1 — LICENSE +https://crates.io/crates/zip/9.0.1 +================================================================ + +The MIT License (MIT) + +Copyright (c) 2014 Mathijs van de Nes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +================================================================ +zlib-rs-0.6.8 — LICENSE +https://crates.io/crates/zlib-rs/0.6.8 +================================================================ + +(C) 2024 Trifecta Tech Foundation + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution. diff --git a/native/Cargo.lock b/native/Cargo.lock index d5af1de9..77ca47e6 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -92,6 +92,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -101,6 +107,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -149,6 +164,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -159,6 +180,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "zlib-rs", +] + [[package]] name = "fs-set-times" version = "0.20.3" @@ -225,6 +255,22 @@ dependencies = [ "r-efi", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "io-extras" version = "0.18.4" @@ -288,6 +334,18 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -312,9 +370,11 @@ version = "0.3.1" dependencies = [ "ops-files", "ops-protocol", + "quick-xml", "serde", "serde_json", "sha2", + "zip", ] [[package]] @@ -388,6 +448,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -532,6 +601,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.1" @@ -736,6 +811,26 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "zip" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "nix", + "typed-path", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/native/crates/ops-documents/Cargo.toml b/native/crates/ops-documents/Cargo.toml index 49ad6617..f684e78a 100644 --- a/native/crates/ops-documents/Cargo.toml +++ b/native/crates/ops-documents/Cargo.toml @@ -12,3 +12,5 @@ ops-files = { path = "../ops-files" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10.9" +zip = { version = "=9.0.1", default-features = false, features = ["deflate-flate2-zlib-rs"] } +quick-xml = { version = "=0.42.0", default-features = false } diff --git a/native/crates/ops-documents/src/archive.rs b/native/crates/ops-documents/src/archive.rs new file mode 100644 index 00000000..097f08ab --- /dev/null +++ b/native/crates/ops-documents/src/archive.rs @@ -0,0 +1,163 @@ +//! ZIP metadata is bounded before ZipArchive can allocate from attacker-controlled counts. +use ops_protocol::{Failure, Result}; +use std::{ + collections::HashSet, + io::{Cursor, Read}, +}; +use zip::ZipArchive; +pub const MAX_ARCHIVE: usize = 32 * 1024 * 1024; +fn error(message: impl Into) -> Failure { + Failure::new("DOCUMENT_PARSE_ERROR", message.into()) +} +fn unsupported(message: &str) -> Failure { + Failure::new("UNSUPPORTED_DOCUMENT", message) +} +fn u16le(data: &[u8], at: usize) -> usize { + u16::from_le_bytes([data[at], data[at + 1]]) as usize +} +fn u32le(data: &[u8], at: usize) -> usize { + u32::from_le_bytes(data[at..at + 4].try_into().unwrap()) as usize +} +fn extra(data: &[u8]) -> Result<()> { + let mut at = 0; + while at < data.len() { + if data.len() - at < 4 { + return Err(error("Truncated ZIP extra field")); + } + let kind = u16le(data, at); + let size = u16le(data, at + 2); + at += 4; + if kind == 1 { + return Err(unsupported("ZIP64 documents are outside the bounded DOCX subset")); + } + if size > data.len() - at { + return Err(error("ZIP extra field exceeds its declared header")); + } + at += size; + } + Ok(()) +} +fn preflight(data: &[u8]) -> Result { + if data.starts_with(&[0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) { + return Err(unsupported("Legacy binary DOC is unsupported; supply DOCX or plain text")); + } + if data.len() < 22 || data.len() > MAX_ARCHIVE { + return Err(error("DOCX must be a ZIP archive of at most 32 MiB")); + } + let end = (data.len().saturating_sub(65557)..=data.len() - 22) + .rev() + .find(|&at| &data[at..at + 4] == b"PK\x05\x06" && at + 22 + u16le(data, at + 20) == data.len()) + .ok_or_else(|| error("ZIP end record is missing or has trailing data"))?; + // zip retries earlier EOCDs after metadata errors. Forbid alternate footer interpretations + // before its constructor can follow an unvalidated count or a ZIP64 allocation path. + if data + .windows(4) + .enumerate() + .any(|(at, magic)| (magic == b"PK\x05\x06" && at != end) || magic == b"PK\x06\x06" || magic == b"PK\x06\x07") + { + return Err(unsupported("Ambiguous ZIP footer signatures and ZIP64 structures are unsupported")); + } + let count = u16le(data, end + 10); + let size = u32le(data, end + 12); + let start = u32le(data, end + 16); + if u16le(data, end + 4) != 0 + || u16le(data, end + 6) != 0 + || u16le(data, end + 8) != count + || count == 65535 + || size == u32::MAX as usize + || start == u32::MAX as usize + { + return Err(unsupported("Multi-disk and ZIP64 documents are unsupported")); + } + if count > 4096 || size > 1024 * 1024 { + return Err(Failure::new("OUTPUT_LIMIT", "DOCX ZIP exceeds 4096 entries or 1 MiB central metadata")); + } + if start.checked_add(size) != Some(end) { + return Err(error("ZIP central-directory bounds are inconsistent")); + } + let mut at = start; + let mut names = HashSet::new(); + let mut ranges = Vec::new(); + for _ in 0..count { + if end - at < 46 || &data[at..at + 4] != b"PK\x01\x02" { + return Err(error("Truncated ZIP central-directory entry")); + } + let (name, extras, comment) = (u16le(data, at + 28), u16le(data, at + 30), u16le(data, at + 32)); + let next = at + 46 + name + extras + comment; + if next > end || u16le(data, at + 34) != 0 { + return Err(error("Invalid ZIP central-directory entry bounds")); + } + let filename = &data[at + 46..at + 46 + name]; + if !names.insert(filename.to_vec()) { + return Err(error("Duplicate ZIP entry names are ambiguous")); + } + extra(&data[at + 46 + name..at + 46 + name + extras])?; + let flags = u16le(data, at + 8); + let method = u16le(data, at + 10); + let compressed = u32le(data, at + 20); + if flags & (1 | 64 | 8192) != 0 { + return Err(unsupported("Encrypted ZIP entries are unsupported")); + } + if compressed == u32::MAX as usize || u32le(data, at + 24) == u32::MAX as usize { + return Err(unsupported("ZIP64 entries are unsupported")); + } + let local = u32le(data, at + 42); + if local > start || start - local < 30 || &data[local..local + 4] != b"PK\x03\x04" { + return Err(error("ZIP local-header bounds are invalid")); + } + let (ln, le) = (u16le(data, local + 26), u16le(data, local + 28)); + let body = local + 30 + ln + le; + if body > start + || compressed > start - body + || &data[local + 30..local + 30 + ln] != filename + || u16le(data, local + 6) != flags + || u16le(data, local + 8) != method + { + return Err(error("ZIP local and central entries disagree")); + } + extra(&data[local + 30 + ln..body])?; + ranges.push((local, body + compressed)); + at = next; + } + if at != end { + return Err(error("ZIP entry count does not match central-directory bytes")); + } + ranges.sort_unstable(); + if ranges.windows(2).any(|r| r[0].1 > r[1].0) { + return Err(error("Overlapping ZIP entries are unsupported")); + } + Ok(count) +} +pub struct Archive<'a>(ZipArchive>); +impl<'a> Archive<'a> { + pub fn new(data: &'a [u8]) -> Result { + let count = preflight(data)?; + let archive = ZipArchive::with_config( + zip::read::Config { archive_offset: zip::read::ArchiveOffset::Known(0) }, + Cursor::new(data), + ) + .map_err(|e| error(format!("Invalid DOCX ZIP: {e}")))?; + if archive.len() != count { + return Err(error("ZIP entry identities differ from preflight")); + } + Ok(Self(archive)) + } + pub fn xml(&mut self, name: &str, limit: usize) -> Result { + let mut file = self.0.by_name(name).map_err(|_| error(format!("Required DOCX part is missing: {name}")))?; + if !file.is_file() || file.unix_mode().is_some_and(|mode| mode & 0o170000 != 0 && mode & 0o170000 != 0o100000) { + return Err(unsupported("DOCX XML parts must be regular ZIP entries")); + } + if !matches!(file.compression(), zip::CompressionMethod::Stored | zip::CompressionMethod::Deflated) { + return Err(unsupported("Only stored or deflated DOCX XML parts are supported")); + } + let mut bytes = Vec::new(); + file.by_ref() + .take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|e| error(format!("DOCX part failed decompression or CRC validation: {e}")))?; + if bytes.len() > limit { + return Err(Failure::new("OUTPUT_LIMIT", format!("DOCX part {name} exceeds its decoded byte limit"))); + } + super::xml::decode(&bytes) + } +} diff --git a/native/crates/ops-documents/src/docx.rs b/native/crates/ops-documents/src/docx.rs new file mode 100644 index 00000000..10568f4b --- /dev/null +++ b/native/crates/ops-documents/src/docx.rs @@ -0,0 +1,386 @@ +//! DOCX main-body text observations. Paragraphs retain table coordinates, never rendered-layout claims. +use crate::{ + archive::{Archive, MAX_ARCHIVE}, + xml::{self, Token}, +}; +use ops_protocol::{Failure, Request, Result, capture::json_size}; +use serde::Deserialize; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{io::Read, path::Path, time::Instant}; +const WORD: [&str; 2] = [ + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "http://purl.oclc.org/ooxml/wordprocessingml/main", +]; +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Args { + path: String, + #[serde(default = "super::one")] + offset: usize, + #[serde(default = "super::two_hundred")] + limit: usize, +} +fn unsupported(name: &str) -> Failure { + Failure::new( + "UNSUPPORTED_DOCUMENT", + format!("Unsupported DOCX main-body content: {name}; preceding complete paragraphs remain observations"), + ) +} +fn metadata( + text: &str, + started: Instant, + namespace: &str, + root: &str, + mut visit: impl FnMut(xml::Tag) -> Result<()>, +) -> Result<()> { + let mut depth = 0usize; + xml::walk(text, started, |token| { + match token { + Token::Start(tag) => { + if tag.ns != namespace || (depth == 0 && tag.name != root) || depth > 1 { + return Err(xml::bad("Invalid DOCX package XML structure")); + } + if depth == 1 { + visit(tag)?; + } + depth += 1; + } + Token::End(_) => { + depth = depth.checked_sub(1).ok_or_else(|| xml::bad("Invalid package XML nesting"))?; + } + Token::Text(text) => { + if !text.trim().is_empty() { + return Err(xml::bad("Unexpected text in DOCX package XML")); + } + } + } + Ok(()) + }) +} +fn package(archive: &mut Archive<'_>, started: Instant) -> Result<()> { + let types = archive.xml("[Content_Types].xml", 1024 * 1024)?; + let mut main = 0; + metadata(&types, started, "http://schemas.openxmlformats.org/package/2006/content-types", "Types", |tag| { + { + if !matches!(tag.name.as_str(), "Override" | "Default") { + return Err(xml::bad("Unexpected content-type element")); + } + if tag.ns != "http://schemas.openxmlformats.org/package/2006/content-types" { + return Err(xml::bad("Invalid DOCX content-type namespace")); + } + if tag.attrs.get("ContentType").is_some_and(|t| { + t.to_ascii_lowercase().contains("macroenabled") || t.to_ascii_lowercase().contains("vbaproject") + }) { + return Err(unsupported("macro-enabled package")); + } + if tag.name == "Override" && tag.attrs.get("PartName").is_some_and(|p| p == "/word/document.xml") { + if tag.attrs.get("ContentType").map(String::as_str) + != Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml") + { + return Err(unsupported("non-DOCX main content type")); + } + main += 1; + } + } + Ok(()) + })?; + if main != 1 { + return Err(xml::bad("DOCX must declare exactly one word/document.xml main part")); + } + let rels = archive.xml("_rels/.rels", 1024 * 1024)?; + let mut main = 0; + metadata(&rels, started, "http://schemas.openxmlformats.org/package/2006/relationships", "Relationships", |tag| { + { + if tag.name != "Relationship" { + return Err(xml::bad("Unexpected relationship element")); + } + if tag.ns != "http://schemas.openxmlformats.org/package/2006/relationships" { + return Err(xml::bad("Invalid package relationship namespace")); + } + if tag.name == "Relationship" + && tag.attrs.get("Type").is_some_and(|t| { + t == "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" + || t == "http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument" + }) + { + if tag.attrs.get("Target").map(String::as_str) != Some("word/document.xml") + || tag.attrs.get("TargetMode").is_some_and(|m| m != "Internal") + { + return Err(unsupported("external or nonstandard main-document relationship")); + } + main += 1; + } + } + Ok(()) + })?; + if main != 1 { + return Err(xml::bad("DOCX must have exactly one internal main-document relationship")); + } + Ok(()) +} +struct Page<'a> { + request: &'a Request, + args: &'a Args, + items: Vec, + observed: usize, + selected: usize, + bytes: usize, + cut: bool, +} +impl Page<'_> { + fn push(&mut self, mut item: Value) -> Result<()> { + if self.observed >= 100_000 { + return Err(Failure::new("OUTPUT_LIMIT", "DOCX exceeds 100000 observed paragraphs")); + } + self.observed += 1; + item["block"] = json!(self.observed); + if self.observed < self.args.offset || self.observed >= self.args.offset + self.args.limit { + return Ok(()); + } + self.selected += 1; + if let Some(sink) = self.request.output_sink.as_deref() { + sink.push(&item); + } + let bytes = json_size(&item) + 1; + if self.cut || self.bytes + bytes > 80 * 1024 { + self.cut = true; + if let Some(sink) = self.request.output_sink.as_deref() { + sink.clipped(); + } + } else { + self.bytes += bytes; + self.items.push(item); + } + Ok(()) + } +} +fn paragraphs(text: &str, started: Instant, page: &mut Page<'_>) -> Result<()> { + let mut stack = Vec::::new(); + let (mut body, mut document) = (0, 0); + let (mut paragraph, mut content, mut table, mut row, mut cell) = (None::, String::new(), 0, 0, 0); + let mut in_table = false; + xml::walk(text, started, |token| { + match token { + Token::Start(tag) => { + if !WORD.contains(&tag.ns.as_str()) { + return Err(unsupported(&format!("foreign namespace {}", tag.name))); + } + let parent = stack.last().map(String::as_str); + let property = stack.iter().any(|n| { + matches!(n.as_str(), "pPr" | "rPr" | "tblPr" | "trPr" | "tcPr" | "sectPr" | "tblGrid" | "sdtPr") + }); + if property { + if matches!(tag.name.as_str(), "document" | "body" | "p" | "r" | "t" | "tbl" | "tr" | "tc") { + return Err(xml::bad("Content nested inside formatting properties")); + } + stack.push(tag.name); + return Ok(()); + } + match tag.name.as_str() { + "document" if stack.is_empty() => document += 1, + "body" if parent == Some("document") => body += 1, + "tbl" => { + if in_table || paragraph.is_some() || !stack.iter().any(|n| n == "body") { + return Err(unsupported("nested table")); + } + in_table = true; + table += 1; + row = 0; + cell = 0; + } + "tr" => { + if !in_table || parent != Some("tbl") { + return Err(xml::bad("Table row outside table")); + } + row += 1; + cell = 0; + } + "tc" => { + if parent != Some("tr") { + return Err(xml::bad("Table cell outside row")); + } + cell += 1; + } + "p" => { + if paragraph.is_some() + || !stack.iter().any(|n| n == "body") + || !matches!(parent, Some("body" | "tc" | "sdtContent" | "customXml")) + { + return Err(unsupported("paragraph placement")); + } + let mut item = json!({"type":"paragraph","text":""}); + if in_table { + item["table"] = json!(table); + item["row"] = json!(row); + item["cell"] = json!(cell); + } + paragraph = Some(item); + content.clear(); + } + "t" => { + if paragraph.is_none() || parent != Some("r") { + return Err(xml::bad("Text outside a paragraph run")); + } + } + "tab" | "br" | "cr" => { + if paragraph.is_none() || parent != Some("r") { + return Err(xml::bad("Text control outside a paragraph run")); + } + content.push(if tag.name == "tab" { '\t' } else { '\n' }); + } + "drawing" | "pict" | "object" | "fldSimple" | "fldChar" | "instrText" | "altChunk" | "del" + | "ins" | "moveFrom" | "moveTo" | "sym" | "footnoteReference" | "endnoteReference" => { + return Err(unsupported(&tag.name)); + } + "document" | "body" => return Err(xml::bad("Invalid document/body nesting")), + "r" + | "hyperlink" + | "bookmarkStart" + | "bookmarkEnd" + | "proofErr" + | "commentRangeStart" + | "commentRangeEnd" + | "commentReference" + | "pPr" + | "rPr" + | "tblPr" + | "trPr" + | "tcPr" + | "sectPr" + | "tblGrid" + | "sdt" + | "sdtPr" + | "sdtContent" + | "customXml" + | "lastRenderedPageBreak" => {} + "noBreakHyphen" | "softHyphen" => { + if paragraph.is_none() || parent != Some("r") { + return Err(xml::bad("Text control outside a paragraph run")); + } + content.push(if tag.name == "noBreakHyphen" { '\u{2011}' } else { '\u{ad}' }); + } + _ => return Err(unsupported(&tag.name)), + } + if content.len() > 1024 * 1024 { + return Err(Failure::new("OUTPUT_LIMIT", "One DOCX paragraph exceeds 1 MiB")); + } + stack.push(tag.name); + } + Token::End(name) => { + if stack.pop().as_deref() != Some(&name) { + return Err(xml::bad("DOCX element nesting differs")); + } + if stack.iter().any(|n| { + matches!(n.as_str(), "pPr" | "rPr" | "tblPr" | "trPr" | "tcPr" | "sectPr" | "tblGrid" | "sdtPr") + }) { + return Ok(()); + } + if name == "p" { + let mut item = paragraph.take().ok_or_else(|| xml::bad("Unmatched paragraph"))?; + item["text"] = json!(std::mem::take(&mut content)); + page.push(item)?; + } + if name == "tbl" { + in_table = false; + } + } + Token::Text(text) => { + if stack.last().is_some_and(|n| n == "t") { + if content.len().saturating_add(text.len()) > 1024 * 1024 { + return Err(Failure::new("OUTPUT_LIMIT", "One DOCX paragraph exceeds 1 MiB")); + } + content.push_str(&text); + } else if !text.trim().is_empty() { + return Err(unsupported("text outside w:t")); + } + } + } + Ok(()) + })?; + if document != 1 || body != 1 { + return Err(xml::bad("DOCX main part requires one document and one body")); + } + Ok(()) +} +pub fn read(request: &Request) -> Result { + let args: Args = serde_json::from_value(request.args.clone())?; + if args.offset == 0 || args.offset > 100_000 || args.limit == 0 || args.limit > 1000 { + return Err(Failure::new("INVALID_ARGUMENT", "doc_read offset is 1-100000 paragraphs and limit is 1-1000")); + } + request.authorize_path(Path::new(&args.path))?; + let target = ops_files::file_path::checked(&args.path)?; + request.authorize_path(&target.display)?; + let mut file = ops_files::file_path::read_file(&target)?.into_std(); + let before = file.metadata()?; + let started = Instant::now(); + if before.len() > MAX_ARCHIVE as u64 { + return Err(Failure::new("OUTPUT_LIMIT", "DOCX archive exceeds 32 MiB")); + } + let mut bytes = Vec::new(); + file.by_ref().take(MAX_ARCHIVE as u64 + 1).read_to_end(&mut bytes)?; + if bytes.len() > MAX_ARCHIVE { + return Err(Failure::new("OUTPUT_LIMIT", "DOCX archive grew beyond 32 MiB")); + } + let mut archive = Archive::new(&bytes)?; + package(&mut archive, started)?; + let text = archive.xml("word/document.xml", 8 * 1024 * 1024)?; + let mut page = Page { request, args: &args, items: Vec::new(), observed: 0, selected: 0, bytes: 0, cut: false }; + let mut failure = paragraphs(&text, started, &mut page).err(); + let after = file.metadata()?; + if before.len() != after.len() || before.modified().ok() != after.modified().ok() { + failure = Some(Failure::new( + "FILE_CHANGED", + "DOCX metadata changed while reading; observed paragraphs are not a stable snapshot", + )); + } + let scan = failure.is_none(); + let next = args.offset + page.selected; + let more = !scan || next <= page.observed; + let cause = if let Some(error) = &failure { + match error.code.as_str() { + "DEADLINE_EXCEEDED" => "time", + "FILE_CHANGED" => "changed", + "OUTPUT_LIMIT" => "scan-budget", + _ => "parse-error", + } + } else if more { + "row-limit" + } else { + "none" + }; + let mut output = json!({"status":if scan {"completed"} else {"partial"},"phase":"verify","changed":0,"effectState":"none","path":target.display,"format":"docx","scope":"main-body-paragraphs", + "excludedContent":["formatting and rendered layout","headers and footers","footnotes and endnotes","comments","package metadata"], + "bytes":before.len(),"blocksObserved":page.observed,"skipped":args.offset-1,"scanComplete":scan,"pageComplete":scan || page.observed>=args.offset+args.limit-1, + "complete":scan&&!more,"truncated":more,"truncatedBy":cause,"nextOffset":next,"items":[]}); + if scan { + output["total"] = json!(page.observed); + output["omitted"] = json!(page.observed.saturating_sub(next - 1)); + output["revision"] = json!(format!("{:x}", Sha256::digest(&bytes))); + } + if let Some(mut error) = failure { + error.next_action="Use a dedicated DOCX reader for unsupported content or parse limits; main-body paragraph paging cannot recover an unobserved suffix. Do not treat this scope as the full rendered document.".into(); + output["nextAction"] = json!(error.next_action); + output["error"] = json!(error); + } + if let Some(sink) = request.output_sink.as_deref() { + sink.source(output.clone()); + } + if page.cut { + output["complete"] = json!(false); + output["pageComplete"] = json!(false); + output["truncated"] = json!(true); + output["nextOffset"] = json!(args.offset + page.items.len()); + if scan { + output["truncatedBy"] = json!("output-budget"); + output["omitted"] = json!(page.observed.saturating_sub(args.offset - 1 + page.items.len())); + } + if output.get("nextAction").is_none() { + output["nextAction"] = json!( + "Read the stored reference when available; otherwise reduce limit or use a dedicated DOCX reader for a large paragraph." + ); + } + } + output["items"] = json!(page.items); + Ok(output) +} diff --git a/native/crates/ops-documents/src/lib.rs b/native/crates/ops-documents/src/lib.rs index 713576a9..0d3fd695 100644 --- a/native/crates/ops-documents/src/lib.rs +++ b/native/crates/ops-documents/src/lib.rs @@ -1,5 +1,8 @@ //! Bounded table observations; cells stay strings, and incomplete traversals never invent totals. #![forbid(unsafe_code)] +mod archive; +mod xml; +mod docx; mod decode; mod parse; use decode::{Decoder, Encoding}; @@ -36,6 +39,7 @@ fn recovery(error: &mut Failure) { } struct Source { path: PathBuf, file: File, before: Metadata, started: Instant } pub fn dispatch(request: &Request) -> Result { + if request.tool == "doc_read" { return docx::read(request); } if request.tool != "table_read" { return Err(Failure::new("INVALID_ARGUMENT", "Unknown document capability")); } let args: Args = serde_json::from_value(request.args.clone())?; if args.offset == 0 || args.offset > MAX_ROWS || args.limit == 0 || args.limit > 1000 { diff --git a/native/crates/ops-documents/src/xml.rs b/native/crates/ops-documents/src/xml.rs new file mode 100644 index 00000000..0aad9e59 --- /dev/null +++ b/native/crates/ops-documents/src/xml.rs @@ -0,0 +1,173 @@ +//! Strict bounded XML events; no DTD, custom entity expansion, or external resolution. +use ops_protocol::{Failure, Result}; +use quick_xml::{events::Event, name::ResolveResult, reader::NsReader}; +use std::{ + collections::BTreeMap, + time::{Duration, Instant}, +}; +pub fn bad(message: impl Into) -> Failure { + Failure::new("DOCUMENT_PARSE_ERROR", message.into()) +} +fn valid(text: &str) -> bool { + text.chars() + .all(|c| matches!(c, '\t'|'\n'|'\r'|'\u{20}'..='\u{d7ff}'|'\u{e000}'..='\u{fffd}'|'\u{10000}'..='\u{10ffff}')) +} +pub fn decode(bytes: &[u8]) -> Result { + let (text, encoding) = if bytes.starts_with(&[0xff, 0xfe]) || bytes.starts_with(&[0xfe, 0xff]) { + if !bytes.len().is_multiple_of(2) { + return Err(Failure::new("ERR_INVALID_ENCODING", "Odd DOCX UTF-16 byte count")); + } + let le = bytes[0] == 0xff; + let units = bytes[2..] + .as_chunks::<2>() + .0 + .iter() + .map(|b| if le { u16::from_le_bytes([b[0], b[1]]) } else { u16::from_be_bytes([b[0], b[1]]) }); + let text = char::decode_utf16(units) + .collect::>() + .map_err(|_| Failure::new("ERR_INVALID_ENCODING", "Invalid DOCX UTF-16 surrogate"))?; + (text, if le { "utf-16le" } else { "utf-16be" }) + } else { + ( + std::str::from_utf8(bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(bytes)) + .map_err(|_| { + Failure::new("ERR_INVALID_ENCODING", "DOCX XML must be strict UTF-8 or BOM-marked UTF-16") + })? + .to_owned(), + "utf-8", + ) + }; + if !valid(&text) { + return Err(bad("Invalid XML 1.0 character")); + } + let mut reader = NsReader::from_str(&text); + if let Event::Decl(decl) = reader.read_event().map_err(|e| bad(e.to_string()))? { + if decl.version().map_err(|e| bad(e.to_string()))? != "1.0" { + return Err(bad("Only XML 1.0 is supported")); + } + if let Some(declared) = decl.encoding() { + let declared = declared.map_err(|e| bad(e.to_string()))?.to_ascii_lowercase(); + if declared != encoding && !(declared == "utf-16" && encoding.starts_with("utf-16")) { + return Err(Failure::new( + "ERR_INVALID_ENCODING", + "DOCX XML encoding declaration disagrees with actual bytes", + )); + } + } + } + Ok(text) +} +pub struct Tag { + pub ns: String, + pub name: String, + pub attrs: BTreeMap, +} +pub enum Token { + Start(Tag), + End(String), + Text(String), +} +pub fn walk(text: &str, started: Instant, mut visit: impl FnMut(Token) -> Result<()>) -> Result<()> { + let mut reader = NsReader::from_str(text); + reader.config_mut().check_comments = true; + reader.resolver_mut().set_max_namespace_bindings(512); + let (mut depth, mut roots, mut declaration, mut events) = (0usize, 0usize, false, 0usize); + loop { + if started.elapsed() > Duration::from_secs(30) { + return Err(Failure::new("DEADLINE_EXCEEDED", "Document parse reached its 30-second deadline")); + } + let event = reader.read_event().map_err(|e| bad(format!("Invalid DOCX XML: {e}")))?; + match event { + Event::Start(ref tag) | Event::Empty(ref tag) => { + if depth == 0 { + roots += 1; + if roots > 1 { + return Err(bad("XML has multiple roots")); + } + } + if depth >= 128 { + return Err(Failure::new("OUTPUT_LIMIT", "DOCX XML exceeds 128 nesting levels")); + } + let (ns, name) = reader.resolver().resolve_element(tag.name()); + let ns = match ns { + ResolveResult::Bound(ns) => ns.as_ref().to_owned(), + _ => return Err(bad("DOCX XML element has no recognized namespace binding")), + }; + let name = name.as_ref().to_owned(); + let mut attrs = BTreeMap::new(); + for (index, attr) in tag.attributes().enumerate() { + if index >= 64 { + return Err(Failure::new("OUTPUT_LIMIT", "DOCX element exceeds 64 attributes")); + } + let attr = attr.map_err(|e| bad(e.to_string()))?; + let value = attr + .normalized_value(quick_xml::XmlVersion::Implicit1_0) + .map_err(|e| bad(e.to_string()))? + .into_owned(); + if !valid(&value) { + return Err(bad("Invalid XML attribute character reference")); + } + if attr.key.as_ref() != "xmlns" + && !attr.key.as_ref().starts_with("xmlns:") + && matches!(reader.resolver().resolve_attribute(attr.key).0, ResolveResult::Unknown(_)) + { + return Err(bad("Unbound XML attribute namespace")); + } + attrs.insert(attr.key.as_ref().to_owned(), value); + } + visit(Token::Start(Tag { ns: ns.clone(), name: name.clone(), attrs }))?; + if matches!(event, Event::Empty(_)) { + visit(Token::End(name))?; + } else { + depth += 1; + } + } + Event::End(tag) => { + let (ns, name) = reader.resolver().resolve_element(tag.name()); + if !matches!(ns, ResolveResult::Bound(_)) { + return Err(bad("Unbound DOCX XML closing element")); + } + depth = depth.checked_sub(1).ok_or_else(|| bad("Unexpected XML closing element"))?; + visit(Token::End(name.as_ref().to_owned()))?; + } + Event::Text(value) => { + if depth == 0 && !value.trim().is_empty() { + return Err(bad("Text outside XML root")); + } + visit(Token::Text(value.xml10_content().into_owned()))?; + } + Event::CData(value) => { + if depth == 0 { + return Err(bad("CDATA outside XML root")); + } + visit(Token::Text(value.xml10_content().into_owned()))?; + } + Event::GeneralRef(value) => { + if depth == 0 { + return Err(bad("Entity outside XML root")); + } + let escaped = format!("&{};", value.as_ref()); + let expanded = quick_xml::escape::unescape(&escaped).map_err(|e| bad(e.to_string()))?; + if !valid(&expanded) { + return Err(bad("Invalid XML character reference")); + } + visit(Token::Text(expanded.into_owned()))?; + } + Event::DocType(_) => return Err(bad("DTD and custom entities are unsupported")), + Event::Decl(_) => { + if declaration || roots != 0 || events != 0 { + return Err(bad("Misplaced XML declaration")); + } + declaration = true; + } + Event::Eof => { + if roots != 1 || depth != 0 { + return Err(bad("Incomplete XML document")); + } + return Ok(()); + } + _ => {} + } + events += 1; + } +} diff --git a/native/crates/ops-results/src/spill.rs b/native/crates/ops-results/src/spill.rs index de56e43e..a3b25fe0 100644 --- a/native/crates/ops-results/src/spill.rs +++ b/native/crates/ops-results/src/spill.rs @@ -19,7 +19,7 @@ fn mark_head(request: &Request, value: &mut Value) { let skipped = if request.tool == "replace" { 0 } else { value["skipped"].as_u64().unwrap_or(0) }; if let Some(total) = value["total"].as_u64() { value["omitted"] = json!(total.saturating_sub(skipped)); } if value.get("nextOffset").is_some() { - value["nextOffset"] = json!(if request.tool == "table_read" { skipped + 1 } else if request.tool == "inspect_local_file" { + value["nextOffset"] = json!(if matches!(request.tool.as_str(), "table_read" | "doc_read") { skipped + 1 } else if request.tool == "inspect_local_file" { if request.args["view"] == "hex" { skipped / 16 + 1 } else { skipped + 1 } } else { skipped }); } diff --git a/native/src/lib.rs b/native/src/lib.rs index f6fe2c4a..e4a49714 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -33,7 +33,7 @@ pub fn dispatch(request: Request) -> Result { Ok(ops_results::spill::finish(&request, value)) } fn execute(dir: &Dir, request: &Request) -> Result { - if request.tool == "table_read" { return ops_documents::dispatch(request); } + if matches!(request.tool.as_str(), "table_read" | "doc_read") { return ops_documents::dispatch(request); } // File reads/searches do not create or consult the durable audit store. // This is important for read-only sessions and keeps observation separate // from mutation audit records. diff --git a/native/src/main.rs b/native/src/main.rs index 5191b642..6465cefd 100644 --- a/native/src/main.rs +++ b/native/src/main.rs @@ -4,7 +4,7 @@ use serde_json::json; use std::io::{Read, Write}; fn may_mutate(request: &Request) -> bool { - !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "table_read" | "glob" | "grep" | "diff" | "stat") + !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "table_read" | "doc_read" | "glob" | "grep" | "diff" | "stat") && !(request.tool == "result" && request.args.get("action").and_then(serde_json::Value::as_str) == Some("page")) } fn main() { diff --git a/test/docx-worker.test.mjs b/test/docx-worker.test.mjs new file mode 100644 index 00000000..e0e331a9 --- /dev/null +++ b/test/docx-worker.test.mjs @@ -0,0 +1,152 @@ +/** Real one-shot DOCX reads: bounded ZIP/XML, honest scope and durable spill recovery. */ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { callWorker, request, envelopeOf, requireWorker } from './lib/boundary.mjs' +import { docx, zip, parts, document, paragraph, word, types, rels } from './lib/document-fixture.mjs' +requireWorker('docx-worker') +const fileAt = (root, bytes, name = 'document.docx') => { const file = path.join(root, name); fs.writeFileSync(file, bytes); return file } +async function run(root, args, options = {}) { + const payload = { version: 1, requestId: 'docx-test', component: 'file', budget: { timeoutMs: 30000, maxBytes: options.maxBytes ?? 131072 }, + ...(options.cache ? { storage: { root: options.cache, authorityId: 'a'.repeat(64), allowedTools: ['doc_read', 'result'] } } : {}), + request: { ...request(options.tool ?? 'doc_read', args, { root, readOnly: options.readOnly, writeRoots: [] }), ...options.authority } } + const raw = await callWorker(JSON.stringify(payload)); assert.ok(Buffer.byteLength(raw.stdout) <= payload.budget.maxBytes) + return envelopeOf(raw, assert) +} +function failure(body, code) { assert.equal(body.error.code, code, JSON.stringify(body)); assert.equal(body.error.effectState, 'none'); assert.notEqual(body.complete, true); assert.equal(body.revision, undefined); assert.equal(body.total, undefined) } +await test('stored and deflated DOCX preserve lexical Unicode paragraphs, controls and table coordinates', async () => { + await withTempDir('docx-text', async root => { + const content = paragraph(' 中😀 & < A ') + 'ABC' + '' + paragraph('one') + paragraph('two') + '' + for (const deflate of [false, true]) { + const bytes = docx(content, { deflate }), body = await run(root, { path: fileAt(root, bytes) }) + assert.equal(body.status, 'completed'); assert.equal(body.complete, true); assert.equal(body.scanComplete, true) + assert.equal(body.scope, 'main-body-paragraphs'); assert.ok(body.excludedContent.includes('headers and footers')) + assert.deepEqual(body.items, [{ block: 1, type: 'paragraph', text: ' 中😀 & < A ' }, { block: 2, type: 'paragraph', text: 'A\tB\nC\u2011\u00ad' }, + { block: 3, type: 'paragraph', text: 'one', table: 1, row: 1, cell: 1 }, { block: 4, type: 'paragraph', text: 'two', table: 1, row: 1, cell: 1 }, { block: 5, type: 'paragraph', text: '', table: 1, row: 1, cell: 2 }]) + assert.equal(body.total, 5); assert.equal(body.revision, createHash('sha256').update(bytes).digest('hex')); assert.equal(body.nextOffset, 6) + } + }) +}) +await test('namespace prefixes, strict Word namespace and BOM-marked UTF-16 remain exact', async () => { + await withTempDir('docx-encoding', async root => { + for (const be of [false, true]) { + const xml = '\ufeff' + document(paragraph('中😀')).replaceAll('UTF-8', 'UTF-16').replaceAll('w:', 'x:').replace('xmlns:w', 'xmlns:x').replaceAll(word, 'http://purl.oclc.org/ooxml/wordprocessingml/main') + const bytes = Buffer.from(xml, 'utf16le'); if (be) bytes.swap16() + const body = await run(root, { path: fileAt(root, zip(parts(bytes))) }); assert.equal(body.complete, true); assert.equal(body.items[0].text, '中😀') + } + for (const xml of [Buffer.concat([Buffer.from(document(paragraph('ok'))), Buffer.from([0xff])]), Buffer.from(document(paragraph('ok')).replace('UTF-8', 'UTF-16')), Buffer.from([0xff, 0xfe, 0, 0xd8])]) { + failure(await run(root, { path: fileAt(root, zip(parts(xml))) }), 'ERR_INVALID_ENCODING') + } + }) +}) +await test('paragraph paging reports full totals separately and empty documents have no phantom item', async () => { + await withTempDir('docx-pages', async root => { + const file = fileAt(root, docx(Array.from({ length: 205 }, (_, i) => paragraph(String(i + 1))).join(''))) + const first = await run(root, { path: file }); assert.equal(first.items.length, 200); assert.equal(first.nextOffset, 201); assert.equal(first.total, 205); assert.equal(first.scanComplete, true); assert.equal(first.pageComplete, true); assert.equal(first.complete, false) + const last = await run(root, { path: file, offset: 201, limit: 5 }); assert.deepEqual(last.items.map(i => i.text), ['201', '202', '203', '204', '205']); assert.equal(last.complete, true) + const empty = await run(root, { path: fileAt(root, docx('')) }); assert.equal(empty.total, 0); assert.deepEqual(empty.items, []); assert.equal(empty.complete, true) + for (const args of [{ offset: 0 }, { offset: 100001 }, { limit: 0 }, { limit: 1001 }, { unknown: true }]) failure(await run(root, { path: file, ...args }), 'INVALID_ARGUMENT') + }) +}) +await test('unsupported body content and malformed suffix preserve only safe preceding paragraphs without totals', async () => { + await withTempDir('docx-partial', async root => { + for (const suffix of ['', '' + paragraph('revision') + '', '', '', '']) { + const body = await run(root, { path: fileAt(root, docx(paragraph('safe') + suffix)), limit: 1 }); failure(body, 'UNSUPPORTED_DOCUMENT'); assert.equal(body.status, 'partial'); assert.equal(body.scanComplete, false); assert.equal(body.pageComplete, true); assert.equal(body.items[0].text, 'safe') + } + const body = await run(root, { path: fileAt(root, zip(parts(document(paragraph('safe')).replace('', '')))) }) + failure(body, 'DOCUMENT_PARSE_ERROR'); assert.deepEqual(body.items.map(i => i.text), ['safe']) + }) +}) +await test('DTD, custom entities, invalid numeric references and XML roots/depth/attributes cannot claim completeness', async () => { + await withTempDir('docx-xml', async root => { + for (const xml of [document(paragraph('&unknown;')), ']>' + document(paragraph('&secret;')).replace(/^<\?xml.*?\?>/, ''), document(paragraph('�')), document('') + document(''), document(paragraph('x')).replace(''.repeat(129) + ''.repeat(129))) }), 'OUTPUT_LIMIT') + failure(await run(root, { path: fileAt(root, docx(' `a${i}="x"`).join(' ') + '/>')) }), 'OUTPUT_LIMIT') + }) +}) +await test('ZIP preflight rejects counts, overlapping/duplicate identities, ZIP64, encryption and bad offsets', async () => { + await withTempDir('docx-zip', async root => { + const source = docx(paragraph('safe')); const mutations = [bytes => { bytes.writeUInt16LE(4097, bytes.length - 14); bytes.writeUInt16LE(4097, bytes.length - 12) }, bytes => bytes.writeUInt32LE(0xffffffff, bytes.length - 6), bytes => bytes.writeUInt32LE(0, bytes.length - 6), bytes => bytes.writeUInt16LE(1, 6)] + for (const mutate of mutations) { const bytes = Buffer.from(source); mutate(bytes); const body = await run(root, { path: fileAt(root, bytes) }); assert.ok(body.error); assert.equal(body.error.effectState, 'none'); assert.equal(body.items, undefined) } + failure(await run(root, { path: fileAt(root, zip([...parts(document('')), ['word/document.xml', document('')]])) }), 'DOCUMENT_PARSE_ERROR') + const corrupt = Buffer.from(source); corrupt[corrupt.indexOf(Buffer.from('safe'))] = 120 + failure(await run(root, { path: fileAt(root, corrupt) }), 'DOCUMENT_PARSE_ERROR') + }) +}) +await test('legacy DOC, macros, external or nonstandard main relationships are explicitly refused', async () => { + await withTempDir('docx-types', async root => { + failure(await run(root, { path: fileAt(root, Buffer.from('d0cf11e0a1b11ae1', 'hex')) }), 'UNSUPPORTED_DOCUMENT') + for (const list of [[['[Content_Types].xml', types.replace('wordprocessingml.document', 'wordprocessingml.macroEnabled')], ...parts(document('')).slice(1)], [parts(document(''))[0], ['_rels/.rels', rels.replace('Target="word/document.xml"', 'Target="https://example.invalid/file" TargetMode="External"')], parts(document(''))[2]]]) { + failure(await run(root, { path: fileAt(root, zip(list)) }), 'UNSUPPORTED_DOCUMENT') + } + }) +}) +await test('archive, decompressed XML and paragraph limits are bounded with honest gaps', async () => { + await withTempDir('docx-bounds', async root => { + failure(await run(root, { path: fileAt(root, Buffer.alloc(32 * 1024 * 1024 + 1)) }), 'OUTPUT_LIMIT') + failure(await run(root, { path: fileAt(root, docx(paragraph('x'.repeat(8 * 1024 * 1024)), { deflate: true })) }), 'OUTPUT_LIMIT') + const body = await run(root, { path: fileAt(root, docx(paragraph('safe') + paragraph('x'.repeat(1024 * 1024 + 1)), { deflate: true })) }) + failure(body, 'OUTPUT_LIMIT'); assert.equal(body.scanComplete, false); assert.deepEqual(body.items.map(i => i.text), ['safe']) + }) +}) +await test('read-only DOCX reads create nothing and repeat credential and no-follow checks', async () => { + await withTempDir('docx-authority', async root => { + const file = fileAt(root, docx(paragraph('safe'))); const before = fs.readdirSync(root) + assert.equal((await run(root, { path: file }, { readOnly: true })).complete, true); assert.deepEqual(fs.readdirSync(root), before) + failure(await run(root, { path: file }, { authority: { deny_roots: [root] } }), 'CREDENTIAL_DENIED') + const target = path.join(root, 'target'); fs.mkdirSync(target); const hidden = fileAt(target, docx(paragraph('secret'))) + const alias = path.join(root, 'alias'); fs.symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + const denied = await run(root, { path: path.join(alias, path.basename(hidden)) }); assert.ok(denied.error); assert.equal(denied.error.effectState, 'none'); assert.equal(denied.items, undefined) + }) +}) +await test('large paragraph spill reconstructs every Unicode byte and retains main-body scope', async () => { + await withTempDir('docx-spill', async root => { + const cache = path.join(root, 'cache'); fs.mkdirSync(cache, { mode: 0o700 }); const text = '中😀'.repeat(20000) + const file = fileAt(root, docx(paragraph(text) + paragraph('last'), { deflate: true })); const head = await run(root, { path: file }, { cache, maxBytes: 4096 }) + assert.ok(head.reference); assert.equal(head.nextOffset, 1); assert.equal(head.referenceRoot, process.platform === 'win32' ? path.toNamespacedPath(fs.realpathSync.native(root)) : fs.realpathSync(root)); assert.equal(head.complete, false) + let offset = 0, source; const records = [] + for (;;) { const page = await run(head.referenceRoot, { action: 'page', id: head.reference.refId, offset, limit: 512, maxBytes: 65536 }, { cache, tool: 'result', readOnly: true }); assert.equal(page.error, undefined); records.push(...page.items); source = page.source; assert.ok(page.nextOffset > offset); offset = page.nextOffset; if (page.complete) break } + assert.equal(source.scope, 'main-body-paragraphs'); assert.equal(source.captureComplete, true); assert.equal(source.scanComplete, true); assert.equal(source.total, 2) + const rows = []; let chunks = [], byteOffset = 0 + for (const item of records) { assert.equal(item.index, rows.length); if (item.kind === 'item') { rows.push(item.value); continue } assert.equal(item.chunk, chunks.length); assert.equal(item.byteOffset, byteOffset); chunks.push(item.text); byteOffset += Buffer.byteLength(item.text); if (item.complete) { rows.push(JSON.parse(chunks.join(''))); chunks = []; byteOffset = 0 } } + assert.equal(chunks.length, 0); assert.deepEqual(rows.map(row => row.text), [text, 'last']) + const noCache = await run(root, { path: file }, { maxBytes: 4096, cache, readOnly: true }); assert.equal(noCache.reference, undefined); assert.equal(noCache.storageError.code, 'POLICY_DENIED'); assert.equal(noCache.complete, false) + }) +}) +await test('DOCX transport overflow remains read-only even in writable sessions', async () => { + await withTempDir('docx-transport', async root => { const body = await run(root, { path: fileAt(root, docx(paragraph('x'.repeat(40000)))) }, { maxBytes: 1024 }); failure(body, 'OUTPUT_LIMIT') }) +}) + +await test('DOCX count limits and malformed package/content placement never invent complete observations', async () => { + await withTempDir('docx-structure', async root => { + const capped = await run(root, { path: fileAt(root, docx(''.repeat(100001), { deflate: true })), limit: 1 }) + failure(capped, 'OUTPUT_LIMIT'); assert.equal(capped.blocksObserved, 100000); assert.equal(capped.items.length, 1) + for (const xml of [document('hidden'), document(''), document('').replace('', '' + paragraph('outside') + '')]) { + const bad = await run(root, { path: fileAt(root, zip(parts(xml))) }); assert.ok(bad.error); assert.notEqual(bad.complete, true); assert.equal(bad.total, undefined) + } + const malformed = [['[Content_Types].xml', types.replace('', '')], ...parts(document('')).slice(1)] + failure(await run(root, { path: fileAt(root, zip(malformed)) }), 'DOCUMENT_PARSE_ERROR') + }) +}) + +await test('alternate ZIP footers and macro Default content types are refused before archive construction', async () => { + await withTempDir('docx-footer', async root => { + for (const magic of [0x06064b50, 0x07064b50, 0x06054b50]) { + const source = docx(paragraph('safe')); const end = source.length - 22 + const comment = Buffer.alloc(30, 120); comment.writeUInt32LE(magic, 2) + source.writeUInt16LE(comment.length, end + 20) + failure(await run(root, { path: fileAt(root, Buffer.concat([source, comment])) }), 'UNSUPPORTED_DOCUMENT') + } + const source = docx(paragraph('safe')), end = source.length - 22, footer = Buffer.alloc(76) + footer.writeUInt32LE(0x06064b50); footer.writeBigUInt64LE(44n, 4); footer.writeUInt32LE(0x07064b50, 56) + // Classic fields remain ordinary values: this still cannot open a ZIP64 interpretation. + failure(await run(root, { path: fileAt(root, Buffer.concat([source.subarray(0, end), footer, source.subarray(end)])) }), 'UNSUPPORTED_DOCUMENT') + const macro = types.replace('', '') + failure(await run(root, { path: fileAt(root, zip([['[Content_Types].xml', macro], ...parts(document('')).slice(1)])) }), 'UNSUPPORTED_DOCUMENT') + }) +}) + +report('docx-worker') diff --git a/test/lib/document-fixture.mjs b/test/lib/document-fixture.mjs new file mode 100644 index 00000000..e25ad16f --- /dev/null +++ b/test/lib/document-fixture.mjs @@ -0,0 +1,29 @@ +/** Small deterministic OPC fixtures, with real ZIP headers/CRC and optional raw deflate. */ +import { deflateRawSync } from 'node:zlib' +export const word = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' +export const paragraph = text => `${text}` +export const document = body => `${body}` +export function crc32(bytes) { + let crc = 0xffffffff + for (const byte of bytes) { crc ^= byte; for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)) } + return (crc ^ 0xffffffff) >>> 0 +} +export function zip(entries, { deflate = false } = {}) { + const locals = [], central = []; let offset = 0 + for (const [name, value] of entries) { + const bytes = Buffer.from(value), filename = Buffer.from(name), packed = deflate ? deflateRawSync(bytes) : bytes + const header = Buffer.alloc(30); header.writeUInt32LE(0x04034b50); header.writeUInt16LE(20, 4) + header.writeUInt16LE(deflate ? 8 : 0, 8); header.writeUInt32LE(crc32(bytes), 14) + header.writeUInt32LE(packed.length, 18); header.writeUInt32LE(bytes.length, 22); header.writeUInt16LE(filename.length, 26) + const entry = Buffer.alloc(46); entry.writeUInt32LE(0x02014b50); entry.writeUInt16LE(20, 4); entry.writeUInt16LE(20, 6) + header.copy(entry, 8, 6, 28); entry.writeUInt32LE(offset, 42) + locals.push(header, filename, packed); central.push(entry, filename); offset += header.length + filename.length + packed.length + } + const directory = Buffer.concat(central), end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50) + end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(directory.length, 12); end.writeUInt32LE(offset, 16) + return Buffer.concat([...locals, directory, end]) +} +export const types = '' +export const rels = '' +export const parts = main => [['[Content_Types].xml', types], ['_rels/.rels', rels], ['word/document.xml', main]] +export const docx = (body, options) => zip(parts(document(body)), options) diff --git a/test/package-source.test.mjs b/test/package-source.test.mjs index 281ab153..84f5f71e 100644 --- a/test/package-source.test.mjs +++ b/test/package-source.test.mjs @@ -17,7 +17,9 @@ await test('npm pack includes compiled host code and native sources, never host 'native/darwin-metadata/Cargo.toml', 'native/darwin-metadata/src/lib.rs', 'native/crates/ops-documents/Cargo.toml', 'native/crates/ops-documents/src/lib.rs', 'native/crates/ops-documents/src/decode.rs', 'native/crates/ops-documents/src/parse.rs', - 'native/crates/ops-documents/src/tests.rs'] + 'native/crates/ops-documents/src/tests.rs', 'native/crates/ops-documents/src/archive.rs', + 'native/crates/ops-documents/src/xml.rs', 'native/crates/ops-documents/src/docx.rs', + 'licenses/documents/THIRD-PARTY-NOTICES.txt'] for (const name of required) assert.ok(files.includes(name), `missing source-build dependency: ${name}`) assert.equal(files.some(name => name.startsWith('src/')), false, 'host source must not ship beside compiled lib/') assert.equal(files.some(name => /^native\/(?:target[^/]*|(?:windows|darwin)-metadata\/target)\//u.test(name)), false) From dfb83c9fbeb742ea32fd533fc26d0ee34aa82ef6 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:23:49 +0800 Subject: [PATCH 048/118] test(native): accept the compact failure value contract --- test/file-contract.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/file-contract.test.mjs b/test/file-contract.test.mjs index 014838a5..7e37498c 100644 --- a/test/file-contract.test.mjs +++ b/test/file-contract.test.mjs @@ -32,7 +32,7 @@ await test('descriptions name the array syntax, the missing include and literal await test('every file tool states that failures are values, not thrown errors', () => { for (const tool of Object.keys(PARAMETERS)) { - assert.match(DESCRIPTIONS[tool], /Failures resolve as \{status:"error"\|"rejected", error\.code\}; they are not thrown\.$/, tool) + assert.match(DESCRIPTIONS[tool], /Failures (?:resolve as \{status:"error"\|"rejected", error\.code\}; they are not thrown|return status:error\|rejected and error\.code; never throw)\.$/, tool) } assert.doesNotMatch(PTC_DESCRIPTIONS.result, /legacy ops_\*/) assert.match(PTC_DESCRIPTIONS.result, /not thrown errors/) From c38dbffcc2a980c8ac6d938d026aa579e0d90405 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:45:09 +0800 Subject: [PATCH 049/118] docs(documents): state table failure value semantics --- src/capabilities/documents.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/capabilities/documents.ts b/src/capabilities/documents.ts index a296d568..6453c6a9 100644 --- a/src/capabilities/documents.ts +++ b/src/capabilities/documents.ts @@ -18,7 +18,7 @@ export const DOCUMENT_PARAMETERS = Object.freeze({ }, ['path']), }) export const DOCUMENT_DESCRIPTIONS = Object.freeze({ - table_read: 'Read bounded CSV/TSV lexical string cells relative to session cwd. Preserves quoted multiline records and physical LF lineStart/lineEnd. Check scanComplete versus pageComplete; total/revision exist only after a stable full scan. Up to 32 MiB, 100000 data records, 1 MiB/record, 1024 columns and 30s; row paging cannot bypass scan gaps. Errors retain safe preceding rows with nextAction. Compare revisions before combining rescanned pages. No spreadsheet formulas or type coercion.', + table_read: 'Read bounded CSV/TSV lexical string cells relative to session cwd. Preserves quoted multiline records and physical LF lineStart/lineEnd. Check scanComplete versus pageComplete; total/revision exist only after a stable full scan. Up to 32 MiB, 100000 data records, 1 MiB/record, 1024 columns and 30s; row paging cannot bypass scan gaps. Errors retain safe preceding rows with nextAction. Compare revisions before combining rescanned pages. No spreadsheet formulas or type coercion. Failures return status:error|rejected and error.code; never throw.', }) export const DOCUMENT_FACTS: Record = { format: string, encoding: string, header: boolean, columns: strings, columnCount: integer, From 5669d03e8ae5be467cf1e99af0da9938607c2537 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:46:57 +0800 Subject: [PATCH 050/118] test(shell): measure the published host-compatible schema --- test/resident-tools.test.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/resident-tools.test.mjs b/test/resident-tools.test.mjs index 9a6fd577..dde2428e 100644 --- a/test/resident-tools.test.mjs +++ b/test/resident-tools.test.mjs @@ -7,6 +7,7 @@ import { FILE_TOOLS, publicToolName } from '../lib/policy.js' import { NativeFileTools } from '../lib/file/runtime.js' import { NATIVE_FILE_TOOLS } from '../lib/capabilities/registry.js' import { bashToolDefinition } from '../lib/shells/tool.js' +import { hostBashDefinition } from '../lib/shells/host-tool.js' import { assert, test, report, withTempDir, waitFor } from './lib/harness.mjs' import { bootDiscoveryHost, mintDiscoveryAgent, discoveryNextStep } from './lib/discovery.mjs' import { shutdown, scopedToolNames, registryNames, callTool } from './lib/host.mjs' @@ -46,12 +47,16 @@ await test('measure the full default Native surface including Bash and retain th const core = measure(assembly.tools) const discovery = assembly.tools.find(tool => tool.name === DISCOVER) const full = measure([...NATIVE_FILE_TOOLS.map(raw => new NativeFileTools({ctx:{},config:{}}).definition(raw)), discovery]) - const shell = bashToolDefinition({ file: 'fixture', source: 'test', subprocess: {} }) + const base = bashToolDefinition({ file: 'fixture', source: 'test', subprocess: {} }) + const shell = hostBashDefinition({ get: name => name === 'jobs' ? {} : undefined }, base, + () => { throw new Error('Schema measurement must not execute a command') }, () => dir) + assert.deepEqual(shell.parameters.required, ['description']) + assert.ok(shell.parameters.properties.run_in_background) const withShell = measure([...assembly.tools, shell]) - console.log(' cl100k_base/js-tiktoken1.0.21 compact host-schema JSON:', JSON.stringify({ core, fullFileCatalogue: full, corePlusBash: withShell })) + console.log(' cl100k_base/js-tiktoken1.0.21 compact host-schema JSON:', JSON.stringify({ core, fullFileCatalogue: full, hostBash: measure([shell]), corePlusBash: withShell })) assert.equal(core.tokens, 241, JSON.stringify(core)) assert.ok(full.tokens > 400 && full.tokens > core.tokens, 'retain honest full-surface measurement') - assert.equal(withShell.tokens, 610, 'BM-03 remains unresolved until the full default, including Bash, fits400') + assert.ok(withShell.tokens > 400, 'BM-03 remains unresolved until the full default, including host-compatible Bash, fits400') const text = assembly.sections.map(section => section.text).join('\n') for (const raw of FILE_TOOLS.filter(raw => raw !== 'read_many')) assert.equal(text.includes(publicToolName(raw)), false) assert.ok(text.includes(DISCOVER)) From a6bbeaf1401856bcebd6531ef4dd69f202136bb3 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:48:08 +0800 Subject: [PATCH 051/118] feat(documents): expose scoped DOCX paragraph readers --- docs/CHANGELOG.md | 7 ++ docs/native-kernel.md | 11 ++- ...37\347\224\237\345\206\205\346\240\270.md" | 7 +- dsh-plugin.json | 3 +- native/crates/ops-documents/src/docx.rs | 18 +++- src/capabilities/discovery.ts | 1 + src/capabilities/documents.ts | 15 ++- src/capabilities/registry.ts | 2 +- src/kernel/errors.ts | 2 + src/modes/native/documents.ts | 9 +- src/modes/ptc/continuation.ts | 4 +- test/capabilities.test.mjs | 6 +- test/docx-host.test.mjs | 93 +++++++++++++++++++ test/docx-worker.test.mjs | 1 + test/types/ptc-sdk.ts | 16 ++++ 15 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 test/docx-host.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 435e6f92..4d3b3b63 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +### Expose scoped DOCX paragraphs (#261, 15b/16) + +- Add discoverable Native main-body paragraphs and typed PTC outcomes with explicit excluded content, + safe prefixes on unsupported content, stable-scan-only continuation and scoped reference recovery. +- DOCX 的 Native/PTC 按需启用,明确正文范围与排除内容;缺口保留安全前缀且不伪造续取。 + ### Add bounded DOCX main-body worker observations (#261, 15b/16) - Read strict DOCX main-body paragraphs and table coordinates with explicit excluded-content diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 258e1639..c560e379 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -184,8 +184,8 @@ credential checks, cancellation and storage bindings apply unchanged. ## Bounded DOCX main-body observations (15b/16) -`doc_read` is a worker-only capability in this slice; later host/discovery integration remains -separate. Arguments are `{path, offset?, limit?}`: path must be absolute after host resolution +`doc_read` is discoverable-only: Native selects it through `ops_discover`, PTC through +`ops_sdk`; activation starts at the owning agent's next step. Arguments are `{path, offset?, limit?}`: path must be absolute after host resolution against current session cwd; offset is a 1-based paragraph index (1-100000, default 1), limit is 1-1000 (default 200). Each item is `{block,type:"paragraph",text}`; table paragraphs additionally carry 1-based table/row/cell coordinates. Empty paragraphs are retained. Tabs, line breaks, @@ -221,3 +221,10 @@ read-only sessions; transport or cache failures never imply a business mutation. Exact dependency versions, release dates, rationale and original notices are recorded in [document-dependencies.md](document-dependencies.md). + +Native displays paragraph indices, table coordinates and explicit scope/excludedContent notices, +including safe prefixes beside parse errors. PTC returns typed paragraphs inside Outcome; physical +gaps never get a looping continuation. Stable full scans with remaining paragraphs permit forward +read-only pages; compare revisions across calls. Skipped paragraphs never exceed observed blocks, +and FILE_CHANGED always invalidates requested-page completeness. Generated source metadata on +reference pages retains the declared main-body scope independently of stored-row completeness. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index eb1cb269..e2cd3257 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -149,7 +149,7 @@ Native 用带物理 LF 行范围的原位置 JSON 单元格展示,并明确提 ## 有界 DOCX 正文观察(15b/16) -本切片仅在 worker 暴露 `doc_read`,宿主与发现注册另行接入。参数 `{path, offset?, limit?}`: +`doc_read` 仅按需发现:Native 经 `ops_discover`、PTC 经 `ops_sdk` 选择,于所属代理下一步骤启用。参数 `{path, offset?, limit?}`: path 由宿主按当前会话 cwd 解析为绝对路径;offset 为从 1 开始的段落序号(1-100000,默认 1), limit 为 1-1000(默认 200)。每项为 `{block,type:"paragraph",text}`;表格内段落另有从 1 开始的 table/row/cell 坐标。保留空段落、制表符、换行、Unicode、实体字符及原文本空白。明确排除格式与 @@ -176,3 +176,8 @@ complete 仅表示存储记录分页结束。按 referenceRoot 读取。每次 只读会话不创建产物,传输或缓存失败不暗示业务写入。 精确依赖、发布日期、理由与原始许可记录见[文档解析依赖.md](文档解析依赖.md)。 + +Native 显示段落序号、表格坐标以及明确的 scope/excludedContent 范围提示,并在解析错误旁保留 +安全前缀。PTC 在 Outcome 中返回类型化段落;物理缺口不生成循环续取。稳定完整扫描且尚有后续 +段落时可只读前进分页,跨调用须比较 revision。skipped 不超过实际观察的 blocksObserved, +FILE_CHANGED 始终使所请求页不完整。引用页生成的 source 类型保留正文范围,独立于存储记录分页。 diff --git a/dsh-plugin.json b/dsh-plugin.json index ee45bd6e..c27edbdf 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -86,6 +86,7 @@ "read_many", "edit_many", "table_read", + "doc_read", "result" ], "shellPermission": "Per-call DSH sandbox policy and native subprocess" @@ -111,7 +112,7 @@ "targets": ["win32-x64", "linux-x64", "darwin-arm64"], "presentation": "PTC-only; reader/result/SDK loader resident, other APIs become visible on the next agent step", "runtime": "independent native Rust file worker", - "tools": ["ops_inspect_local_file","ops_grep","ops_glob","ops_replace","ops_write_file","ops_edit_file","ops_diff","ops_stat","ops_copy","ops_move","ops_remove","ops_read_many","ops_edit_many","ops_table_read","ops_batch","ops_result","ops_sdk"], + "tools": ["ops_inspect_local_file","ops_grep","ops_glob","ops_replace","ops_write_file","ops_edit_file","ops_diff","ops_stat","ops_copy","ops_move","ops_remove","ops_read_many","ops_edit_many","ops_table_read","ops_doc_read","ops_batch","ops_result","ops_sdk"], "permissions": "Per-call original DSH sandboxPolicy and sandbox.confine (host full/partial enforcement reported); full access bypasses confinement and permits explicit operation roots; subprocess owns cancellation and credential scrub" }, { diff --git a/native/crates/ops-documents/src/docx.rs b/native/crates/ops-documents/src/docx.rs index 10568f4b..0e17decf 100644 --- a/native/crates/ops-documents/src/docx.rs +++ b/native/crates/ops-documents/src/docx.rs @@ -21,6 +21,10 @@ struct Args { #[serde(default = "super::two_hundred")] limit: usize, } +fn page_complete(failure: Option<&Failure>, observed: usize, offset: usize, limit: usize) -> bool { + !failure.is_some_and(|error| error.code == "FILE_CHANGED") + && (failure.is_none() || observed >= offset + limit - 1) +} fn unsupported(name: &str) -> Failure { Failure::new( "UNSUPPORTED_DOCUMENT", @@ -351,7 +355,7 @@ pub fn read(request: &Request) -> Result { }; let mut output = json!({"status":if scan {"completed"} else {"partial"},"phase":"verify","changed":0,"effectState":"none","path":target.display,"format":"docx","scope":"main-body-paragraphs", "excludedContent":["formatting and rendered layout","headers and footers","footnotes and endnotes","comments","package metadata"], - "bytes":before.len(),"blocksObserved":page.observed,"skipped":args.offset-1,"scanComplete":scan,"pageComplete":scan || page.observed>=args.offset+args.limit-1, + "bytes":before.len(),"blocksObserved":page.observed,"skipped":(args.offset-1).min(page.observed),"scanComplete":scan,"pageComplete":page_complete(failure.as_ref(), page.observed, args.offset, args.limit), "complete":scan&&!more,"truncated":more,"truncatedBy":cause,"nextOffset":next,"items":[]}); if scan { output["total"] = json!(page.observed); @@ -384,3 +388,15 @@ pub fn read(request: &Request) -> Result { output["items"] = json!(page.items); Ok(output) } + +#[cfg(test)] +mod completeness_tests { + #[test] + fn changed_files_never_claim_the_requested_page_is_complete() { + let changed = ops_protocol::Failure::new("FILE_CHANGED", "fixture"); + assert!(!super::page_complete(Some(&changed), 10, 1, 2)); + let gap = ops_protocol::Failure::new("UNSUPPORTED_DOCUMENT", "fixture"); + assert!(super::page_complete(Some(&gap), 10, 1, 2)); + assert!(!super::page_complete(Some(&gap), 1, 1, 2)); + } +} diff --git a/src/capabilities/discovery.ts b/src/capabilities/discovery.ts index 87d5e0c7..91b1cd28 100644 --- a/src/capabilities/discovery.ts +++ b/src/capabilities/discovery.ts @@ -3,6 +3,7 @@ import type { ParameterSchema } from './types.js' /** Reviewed, currently implemented advanced operations. Future formats are absent. */ export const DISCOVERY_TERMS = Object.freeze({ + doc_read: 'docx word document paragraphs body text tables zip xml unicode', table_read: 'table csv tsv delimiter quoted records columns rows encoding utf16', inspect_local_file: 'read inspect single file text hex binary bounded line windows', edit_many: 'edit patch multiple files atomic per file revision cas', diff --git a/src/capabilities/documents.ts b/src/capabilities/documents.ts index 6453c6a9..659e13c7 100644 --- a/src/capabilities/documents.ts +++ b/src/capabilities/documents.ts @@ -8,6 +8,11 @@ const boolean: JsonSchemaNode = { type: 'boolean' } const strings: JsonSchemaNode = { type: 'array', items: string } const object = (properties: Record, required: string[] = []): ParameterSchema => ({ type: 'object', properties, required, additionalProperties: false }) export const DOCUMENT_PARAMETERS = Object.freeze({ + doc_read: object({ + path: { ...string, description: 'Regular DOCX file path relative to the current session cwd. Binary DOC is unsupported.' }, + offset: { type: 'integer', default: 1, description: '1-100000 main-body paragraphs, 1-based.' }, + limit: { type: 'integer', default: 200, description: '1-1000 paragraphs.' }, + }, ['path']), table_read: object({ path: { ...string, description: 'Regular file path relative to the current session cwd.' }, format: { type: 'string', enum: ['csv', 'tsv'], description: 'Default tsv for .tsv paths, otherwise csv.' }, @@ -19,8 +24,10 @@ export const DOCUMENT_PARAMETERS = Object.freeze({ }) export const DOCUMENT_DESCRIPTIONS = Object.freeze({ table_read: 'Read bounded CSV/TSV lexical string cells relative to session cwd. Preserves quoted multiline records and physical LF lineStart/lineEnd. Check scanComplete versus pageComplete; total/revision exist only after a stable full scan. Up to 32 MiB, 100000 data records, 1 MiB/record, 1024 columns and 30s; row paging cannot bypass scan gaps. Errors retain safe preceding rows with nextAction. Compare revisions before combining rescanned pages. No spreadsheet formulas or type coercion. Failures return status:error|rejected and error.code; never throw.', + doc_read: 'Read bounded DOCX main-body paragraphs relative to session cwd, preserving Unicode text and table/row/cell coordinates. Scope excludes layout, headers/footers, notes, comments and metadata; complete applies only to that scope. Standard stored/deflated ZIP, strict UTF-8/BOM UTF-16; unsupported content and parse errors retain safe preceding paragraphs with nextAction. Check scanComplete versus pageComplete; total/revision require a stable full scan. 32 MiB archive, 8 MiB main XML, 1 MiB/paragraph, 100000 paragraphs, 30s. Paging cannot bypass physical gaps; compare revisions across calls. No binary DOC, macros, external fetch or extraction to disk. Failures return status:error|rejected and error.code; never throw.', }) export const DOCUMENT_FACTS: Record = { + scope: string, excludedContent: strings, blocksObserved: integer, format: string, encoding: string, header: boolean, columns: strings, columnCount: integer, bytes: integer, bytesRead: integer, rowsObserved: integer, skipped: integer, scanComplete: boolean, pageComplete: boolean, complete: boolean, truncated: boolean, @@ -28,7 +35,7 @@ export const DOCUMENT_FACTS: Record = { } export const DOCUMENT_SOURCE: JsonSchemaNode = { type: 'object', properties: { ...DOCUMENT_FACTS, root: string, itemEncoding: string, capturedItems: integer, captureComplete: boolean } } -export const DOCUMENT_OUTPUT: JsonSchemaNode = { +const TABLE_OUTPUT: JsonSchemaNode = { type: 'object', properties: { ...DOCUMENT_FACTS, status: string, phase: string, changed: integer, effectState: string, path: string, items: { type: 'array', items: object({ row: integer, cells: strings, lineStart: integer, lineEnd: integer }, ['row', 'cells', 'lineStart', 'lineEnd']) }, @@ -36,5 +43,9 @@ export const DOCUMENT_OUTPUT: JsonSchemaNode = { source: DOCUMENT_SOURCE, storageError: { type: 'object' }, }, } +const DOCX_OUTPUT: JsonSchemaNode = { ...TABLE_OUTPUT, properties: { ...TABLE_OUTPUT.properties, + items: { type: 'array', items: object({ block: integer, type: { type: 'string', enum: ['paragraph'] }, text: string, + table: integer, row: integer, cell: integer }, ['block', 'type', 'text']) }, +} } export function isDocumentTool(tool: string): tool is keyof typeof DOCUMENT_PARAMETERS { return Object.hasOwn(DOCUMENT_PARAMETERS, tool) } -export function documentOutput(tool: string): JsonSchemaNode | undefined { return isDocumentTool(tool) ? DOCUMENT_OUTPUT : undefined } +export function documentOutput(tool: string): JsonSchemaNode | undefined { return tool === 'doc_read' ? DOCX_OUTPUT : tool === 'table_read' ? TABLE_OUTPUT : undefined } diff --git a/src/capabilities/registry.ts b/src/capabilities/registry.ts index 9d1f9647..c03e6a53 100644 --- a/src/capabilities/registry.ts +++ b/src/capabilities/registry.ts @@ -10,7 +10,7 @@ import type { CapabilityMetadata, Component, SchemaCapability } from './types.js export type FileToolName = keyof typeof fileParameters export type PtcToolName = keyof typeof ptcParameters // Preserve the published registration order independently of schema object key order. -const fileOrder = ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read'] as const satisfies readonly FileToolName[] +const fileOrder = ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read', 'doc_read'] as const satisfies readonly FileToolName[] const fileMutations = new Set(['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) const allPresentations = Object.freeze(['native', 'ptc', 'both'] as const) diff --git a/src/kernel/errors.ts b/src/kernel/errors.ts index bd489c6f..f9c2d12c 100644 --- a/src/kernel/errors.ts +++ b/src/kernel/errors.ts @@ -23,6 +23,8 @@ export const ERROR_POLICIES: Readonly> = Object.free CROSS_DEVICE: invalid, ERR_INVALID_ENCODING: invalid, TABLE_PARSE_ERROR: invalid, + DOCUMENT_PARSE_ERROR: invalid, + UNSUPPORTED_DOCUMENT: settled, FILE_CHANGED: changed, REFERENCE_EXPIRED: changed, AUTHORITY_CHANGED: changed, diff --git a/src/modes/native/documents.ts b/src/modes/native/documents.ts index 49551e53..1a14b18a 100644 --- a/src/modes/native/documents.ts +++ b/src/modes/native/documents.ts @@ -3,15 +3,18 @@ type Row = Record const object = (value: unknown): value is Row => !!value && typeof value === 'object' && !Array.isArray(value) const show = (value: unknown): string => (JSON.stringify(value) ?? 'null').replace(/[\u0085\u2028\u2029]/gu, char => '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0')) const facts = (value: Row, keys: string[]): string => keys.filter(key => value[key] !== undefined).map(key => key + '=' + show(value[key])).join('; ') -export function renderDocument(_tool: string, args: Row, value: Row): string[] { +export function renderDocument(tool: string, args: Row, value: Row): string[] { const items = Array.isArray(value.items) ? value.items.filter(object) : [] const lines = ['=== DOCUMENT: ' + show(value.path ?? args.path ?? '(path unavailable)') + ' ===', '[Observation: ' + facts(value, ['status', 'format', 'encoding', 'header', 'bytes', 'bytesRead', 'revision', 'columnCount']) + '.]', - '[Coverage: ' + facts(value, ['scanComplete', 'pageComplete', 'complete', 'rowsObserved', 'skipped', 'total', 'omitted', 'nextOffset', 'truncatedBy']) + '.]'] + '[Coverage: ' + facts(value, ['scanComplete', 'pageComplete', 'complete', 'rowsObserved', 'blocksObserved', 'skipped', 'total', 'omitted', 'nextOffset', 'truncatedBy']) + '.]'] + if (typeof value.scope === 'string') lines.push('[Scope: ' + show(value.scope) + '; excludedContent=' + show(value.excludedContent) + '. Completeness applies only to this scope.]') if (Array.isArray(value.columns)) lines.push('Columns: ' + show(value.columns)) if (object(value.error)) lines.push('Error: ' + facts(value.error, ['code', 'message', 'effectState', 'nextAction'])) if (value.scanComplete === false || object(value.error)) lines.push('[Notice: This observation is not exhaustive. Only preceding complete records are shown; page offsets do not recover a physical scan gap.]') - for (const item of items) lines.push('Row ' + show(item.row) + ' (LF lines ' + show(item.lineStart) + '-' + show(item.lineEnd) + ') | ' + show(item.cells)) + for (const item of items) lines.push(tool === 'table_read' + ? 'Row ' + show(item.row) + ' (LF lines ' + show(item.lineStart) + '-' + show(item.lineEnd) + ') | ' + show(item.cells) + : 'Paragraph ' + show(item.block) + ' [' + facts(item, ['table', 'row', 'cell']) + '] | ' + show(item.text)) if (!items.length) lines.push('(no records in this displayed range)') lines.push('[Notice: displayedRecords=' + items.length + '. Scan, requested range, and displayed content have separate completeness.]') if (typeof value.nextAction === 'string') lines.push('Next: ' + show(value.nextAction)) diff --git a/src/modes/ptc/continuation.ts b/src/modes/ptc/continuation.ts index 608b4e5d..a199c03b 100644 --- a/src/modes/ptc/continuation.ts +++ b/src/modes/ptc/continuation.ts @@ -2,7 +2,7 @@ import type { CanonicalData, JsonValue } from './outcome.js' const object = (value: unknown): CanonicalData | undefined => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as CanonicalData : undefined const integer = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0 -const readers = ['inspect_local_file', 'read_many', 'glob', 'grep', 'diff', 'result', 'table_read'] as const +const readers = ['inspect_local_file', 'read_many', 'glob', 'grep', 'diff', 'result', 'table_read', 'doc_read'] as const export type ContinuationTool = typeof readers[number] export interface RetrievalContinuation { version: 1, tool: ContinuationTool, args: CanonicalData } const PREFIX = 'ops-next-v1:' @@ -53,7 +53,7 @@ export function continuationFor(tool: string, rawArgs: unknown, data: CanonicalD if ((tool === 'grep' || tool === 'glob') && data.scanComplete === true && typeof data.continuation === 'string') { return encode(tool, { ...args, continuation: data.continuation, ...(integer(data.nextOffset) ? { offset: data.nextOffset } : {}) }) } - if (tool === 'table_read') { + if (tool === 'table_read' || tool === 'doc_read') { const offset = args.offset ?? 1 return data.scanComplete === true && typeof data.revision === 'string' && /^[a-f0-9]{64}$/u.test(data.revision) && integer(offset) && offset > 0 && integer(data.nextOffset) && data.nextOffset > offset diff --git a/test/capabilities.test.mjs b/test/capabilities.test.mjs index 4ea6ed71..267d4c3c 100644 --- a/test/capabilities.test.mjs +++ b/test/capabilities.test.mjs @@ -10,9 +10,9 @@ import { BASH_TOOL_NAME } from '../lib/shells/constants.js' import { assert, test, report } from './lib/harness.mjs' await test('the catalogue covers every owned file, PTC and shell tool exactly once', () => { - assert.equal(CAPABILITIES.length, 19) + assert.equal(CAPABILITIES.length, 20) assert.equal(new Set(CAPABILITIES.map(item => item.rawName)).size, CAPABILITIES.length) - assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read']) + assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read', 'doc_read']) assert.deepEqual(FILE_MUTATIONS, ['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) assert.deepEqual(PTC_TOOLS, ['batch', 'result']) assert.deepEqual(IN_PROCESS_TOOLS, ['bash']) @@ -55,7 +55,7 @@ const snapshot = (names, definition) => names.map(name => { await test('business file and batch definitions retain their original serialized contracts', () => { // Captured before this migration, covering every argument/output contract and published description. const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') - assert.equal(hash(snapshot(FILE_TOOLS.filter(name => !['glob', 'grep', 'read_many', 'edit_many', 'table_read'].includes(name)), fileDefinition)), 'f32e33306b4052fdb3d19a11e0fe9d65c255a87d2d485de08a9d7b1275e958fc') + assert.equal(hash(snapshot(FILE_TOOLS.filter(name => !['glob', 'grep', 'read_many', 'edit_many', 'table_read', 'doc_read'].includes(name)), fileDefinition)), 'f32e33306b4052fdb3d19a11e0fe9d65c255a87d2d485de08a9d7b1275e958fc') const batch = ptcDefinition('batch', async () => null) assert.equal(batch.parameters, ptcParameters.batch); assert.ok(batch.description.startsWith(ptcDescriptions.batch)); assert.match(batch.description, /PTC returns Outcome/) assert.ok(batch.output.schema.oneOf.every(branch => branch.properties.status)) diff --git a/test/docx-host.test.mjs b/test/docx-host.test.mjs new file mode 100644 index 00000000..9ca6dca1 --- /dev/null +++ b/test/docx-host.test.mjs @@ -0,0 +1,93 @@ +/** Scoped DOCX adapters retain main-body scope and safe prefix data. */ +import fs from 'node:fs' +import path from 'node:path' +import { assertSupportedJsonSchema, jsonSchemaToTs } from '@deepseek-ai/dsh-tools' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { shutdown } from './lib/host.mjs' +import { requireWorker } from './lib/boundary.mjs' +import { docx, paragraph } from './lib/document-fixture.mjs' +import { documentHost, documentNames, documentCall, documentCode, activateDocument } from './lib/document-host.mjs' +import { capability } from '../lib/capabilities/registry.js' +import { publicToolName } from '../lib/policy.js' +import { canonicalOutput } from '../lib/modes/ptc/canonical-schema.js' +import { decodeContinuation } from '../lib/modes/ptc/continuation.js' +import { definition } from '../lib/file/contract.js' +requireWorker('docx-host') +const TOOL = publicToolName('doc_read') +const render = value => definition('doc_read', async () => value).output.render({ path: 'body.docx' }, value)[0].text +await test('DOCX catalogue and generated schema preserve strict paragraph types and declared main-body limits', () => { + const entry = capability('doc_read'); assert.equal(entry.availability, 'discoverable'); assert.equal(entry.mutates, false) + assert.equal(entry.parameters.additionalProperties, false); assert.deepEqual(entry.parameters.required, ['path']) + const schema = canonicalOutput('doc_read'); assertSupportedJsonSchema(schema) + assert.equal(schema.properties.items.items.properties.text.type, 'string'); assert.equal(schema.properties.items.items.properties.cells, undefined) + assert.equal(schema.properties.source.properties.blocksObserved.type, 'integer') + assert.match(jsonSchemaToTs(schema), /type: "paragraph"/); assert.match(entry.description, /main-body|Scope excludes/) +}) +await test('real Native and PTC DOCX discovery remain scoped and typed pages retain main-body coverage', () => withTempDir('docx-host-page', async root => { + const cwd = path.join(root, 'nested'); fs.mkdirSync(cwd) + fs.writeFileSync(path.join(cwd, 'body.docx'), docx(paragraph('中😀') + '' + paragraph('0001') + '', { deflate: true })) + const { ctx, agent } = await documentHost(root, ['doc_read'], { real: true }) + try { + const native = await agent('native', { cwd }), sibling = await agent(), code = await agent('ptc', { cwd }) + assert.equal(documentNames(ctx, native).includes(TOOL), false) + await activateDocument(ctx, native, 'doc_read'); await activateDocument(ctx, code, 'doc_read', 'ptc') + assert.equal(documentNames(ctx, sibling).includes(TOOL), false) + const raw = await documentCall(ctx, native, 'doc_read', { path: 'body.docx', limit: 1 }) + assert.equal(raw.format, 'docx'); assert.equal(raw.scope, 'main-body-paragraphs'); assert.equal(raw.scanComplete, true) + assert.equal(raw.complete, false); assert.equal(raw.total, 2); assert.equal(raw.items[0].text, '中😀') + const text = render(raw); assert.match(text, /Paragraph 1/); assert.match(text, /main-body-paragraphs/) + assert.match(text, /headers and footers/); assert.match(text, /Completeness applies only to this scope/) + const result = await documentCode(ctx, code, `return await tools.${TOOL}({path:'body.docx',limit:1});`) + assert.equal(result.status, 'partial'); assert.equal(result.data.scope, 'main-body-paragraphs') + const next = decodeContinuation(result.continuation); assert.equal(next.tool, 'doc_read'); assert.equal(next.args.offset, 2) + const tail = await documentCall(ctx, code, next.tool, next.args) + assert.equal(tail.status, 'completed'); assert.equal(tail.data.revision, result.data.revision) + assert.deepEqual(tail.data.items[0], { block: 2, type: 'paragraph', text: '0001', table: 1, row: 1, cell: 1 }) + } finally { await shutdown(ctx) } +})) +await test('unsupported late DOCX content preserves paragraphs and scope in both projections without looping cursors', () => withTempDir('docx-host-gap', async root => { + fs.writeFileSync(path.join(root, 'body.docx'), docx(paragraph('safe prefix') + '')) + const { ctx, agent } = await documentHost(root, ['doc_read']) + try { + const native = await agent(), code = await agent('ptc') + await activateDocument(ctx, native, 'doc_read'); await activateDocument(ctx, code, 'doc_read', 'ptc') + const raw = await documentCall(ctx, native, 'doc_read', { path: 'body.docx', limit: 1 }) + assert.equal(raw.scanComplete, false); assert.equal(raw.pageComplete, true); assert.equal(raw.total, undefined) + assert.match(render(raw), /UNSUPPORTED_DOCUMENT/); assert.match(render(raw), /Paragraph 1.*safe prefix/); assert.match(render(raw), /not exhaustive/) + const result = await documentCall(ctx, code, 'doc_read', { path: 'body.docx', limit: 1 }) + assert.equal(result.status, 'error'); assert.equal(result.error.code, 'UNSUPPORTED_DOCUMENT'); assert.equal(result.error.effectState, 'none') + assert.equal(result.error.retry, 'never'); assert.equal(result.continuation, undefined); assert.equal(result.data.items[0].text, 'safe prefix') + assert.match(result.error.nextAction, /dedicated DOCX reader/) + } finally { await shutdown(ctx) } +})) +await test('read-only DOCX reads retain credential refusal and explicit unsupported binary DOC values', () => withTempDir('docx-host-authority', async root => { + fs.writeFileSync(path.join(root, 'body.docx'), docx(paragraph('readonly'))) + fs.writeFileSync(path.join(root, 'id_ed25519.docx'), docx(paragraph('SECRET'))) + fs.writeFileSync(path.join(root, 'old.doc'), Buffer.from('d0cf11e0a1b11ae1', 'hex')) + const before = fs.readdirSync(root).sort(), { ctx, agent } = await documentHost(root, ['doc_read']) + try { + const owner = await agent('ptc', { policy: 'read-only' }); await activateDocument(ctx, owner, 'doc_read', 'ptc') + assert.equal((await documentCall(ctx, owner, 'doc_read', { path: 'body.docx' })).status, 'completed') + const denied = await documentCall(ctx, owner, 'doc_read', { path: 'id_ed25519.docx' }) + assert.equal(denied.error.code, 'CREDENTIAL_DENIED'); assert.equal(denied.error.effectState, 'none'); assert.doesNotMatch(JSON.stringify(denied), /SECRET/) + const old = await documentCall(ctx, owner, 'doc_read', { path: 'old.doc' }) + assert.equal(old.error.code, 'UNSUPPORTED_DOCUMENT'); assert.equal(old.continuation, undefined) + assert.deepEqual(fs.readdirSync(root).sort(), before) + } finally { await shutdown(ctx) } +})) +await test('large DOCX paragraphs spill with exact paragraph chunks and independently stated source scope', () => withTempDir('docx-host-spill', async root => { + fs.writeFileSync(path.join(root, 'body.docx'), docx(paragraph('😀'.repeat(40000)), { deflate: true })) + const { ctx, agent } = await documentHost(root, ['doc_read', 'result']) + try { + const owner = await agent(); await activateDocument(ctx, owner, 'doc_read'); await activateDocument(ctx, owner, 'result') + const raw = await documentCall(ctx, owner, 'doc_read', { path: 'body.docx' }) + assert.equal(raw.reference.producerTool, 'doc_read'); assert.equal(raw.complete, false); assert.equal(raw.scanComplete, true) + const content = render(raw); assert.ok(Buffer.byteLength(JSON.stringify([{ type: 'text', text: content }])) <= 65536) + assert.match(content, /ref:\/\//); assert.match(content, /main-body-paragraphs/) + const page = await documentCall(ctx, owner, 'result', { action: 'page', id: raw.reference.refId, root: raw.referenceRoot, limit: 1 }) + assert.equal(page.source.scope, 'main-body-paragraphs'); assert.equal(page.source.blocksObserved, 1) + assert.equal(page.source.captureComplete, true); assert.ok(page.source.excludedContent.includes('headers and footers')) + assert.equal(page.items[0].kind, 'item-json-chunk'); assert.equal(page.items[0].byteOffset, 0) + } finally { await shutdown(ctx) } +})) +report('docx-host') diff --git a/test/docx-worker.test.mjs b/test/docx-worker.test.mjs index e0e331a9..c26f55b0 100644 --- a/test/docx-worker.test.mjs +++ b/test/docx-worker.test.mjs @@ -45,6 +45,7 @@ await test('paragraph paging reports full totals separately and empty documents const file = fileAt(root, docx(Array.from({ length: 205 }, (_, i) => paragraph(String(i + 1))).join(''))) const first = await run(root, { path: file }); assert.equal(first.items.length, 200); assert.equal(first.nextOffset, 201); assert.equal(first.total, 205); assert.equal(first.scanComplete, true); assert.equal(first.pageComplete, true); assert.equal(first.complete, false) const last = await run(root, { path: file, offset: 201, limit: 5 }); assert.deepEqual(last.items.map(i => i.text), ['201', '202', '203', '204', '205']); assert.equal(last.complete, true) + const beyond = await run(root, { path: file, offset: 999 }); assert.equal(beyond.skipped, 205); assert.deepEqual(beyond.items, []); assert.equal(beyond.complete, true) const empty = await run(root, { path: fileAt(root, docx('')) }); assert.equal(empty.total, 0); assert.deepEqual(empty.items, []); assert.equal(empty.complete, true) for (const args of [{ offset: 0 }, { offset: 100001 }, { limit: 0 }, { limit: 1001 }, { unknown: true }]) failure(await run(root, { path: file, ...args }), 'INVALID_ARGUMENT') }) diff --git a/test/types/ptc-sdk.ts b/test/types/ptc-sdk.ts index 61f76c46..3d8bdb05 100644 --- a/test/types/ptc-sdk.ts +++ b/test/types/ptc-sdk.ts @@ -54,3 +54,19 @@ async function tables() { // @ts-expect-error Encoding is a finite strict-decoding contract. void tools.ops_table_read({ path: 'rows.csv', encoding: 'latin1' }) void tables + +async function paragraphs() { + const value = await tools.ops_doc_read({ path: 'body.docx', offset: 1, limit: 10 }) + if (value.status === 'completed' || value.status === 'partial') { + const text: string | undefined = value.data.items?.[0]?.text + const kind: 'paragraph' | undefined = value.data.items?.[0]?.type + const scope: string | undefined = value.data.scope + // @ts-expect-error Document paragraphs are not table cell arrays. + void value.data.items?.[0]?.cells + return { text, kind, scope } + } + return value.data?.items?.[0]?.text +} +// @ts-expect-error DOCX decoding comes from the package, not a caller-supplied encoding. +void tools.ops_doc_read({ path: 'body.docx', encoding: 'utf-8' }) +void paragraphs From 5dd8668d7255e297587a4c1451ae55fe8a0ec9f4 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:13:34 +0800 Subject: [PATCH 052/118] feat(shell): leave PowerShell with the host --- .github/workflows/release.yml | 8 +- NOTICE | 17 +- README.en.md | 4 +- README.md | 4 +- bin/check-release.mjs | 5 +- bin/dsh-ops.mjs | 6 + docs/CHANGELOG.md | 9 + docs/PROVENANCE.md | 17 + docs/component-reference.md | 14 +- docs/configuration-reference.md | 1 + docs/release-process.md | 10 +- docs/usage.md | 17 +- ...77\347\224\250\346\214\207\345\215\227.md" | 17 +- ...04\344\273\266\345\217\202\350\200\203.md" | 14 +- ...15\347\275\256\345\217\202\350\200\203.md" | 1 + dsh-plugin.json | 4 +- locale/en.json | 2 +- locale/zh.json | 2 +- package-lock.json | 16 - package.json | 5 +- packages/README.md | 53 +-- scripts/build-platform-packages.mjs | 56 +--- scripts/lib/build/assemble.js | 26 +- scripts/lib/build/publish-dir.js | 2 +- src/cli/pwsh-upgrade.ts | 14 + src/cli/report.js | 2 +- src/components/shell/index.js | 2 +- src/components/shell/locale/en.json | 4 +- src/components/shell/locale/zh.json | 4 +- src/components/shell/package.json | 4 +- src/config.js | 5 +- src/index.js | 13 +- src/policy.ts | 7 +- src/session-pwsh.js | 63 ---- src/shells/paths.js | 21 +- src/shells/publish.js | 14 +- src/shells/pwsh-upgrade.ts | 72 ++++ src/shells/resolve.js | 62 +--- test/bin.test.mjs | 18 +- test/hardening.test.mjs | 20 -- test/policy.test.mjs | 9 +- test/pwsh-upgrade.test.mjs | 153 +++++++++ test/release-scope.test.mjs | 11 + test/shells-patch.test.mjs | 310 +----------------- test/shells-resolution.test.mjs | 24 +- 45 files changed, 437 insertions(+), 705 deletions(-) create mode 100644 src/cli/pwsh-upgrade.ts delete mode 100644 src/session-pwsh.js create mode 100644 src/shells/pwsh-upgrade.ts create mode 100644 test/pwsh-upgrade.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f95b221..77523966 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,9 +121,7 @@ jobs: gate: name: Verify the release closure and assemble the publish directory needs: native - # Assembly is Windows-only by design: the remaining platform payload this - # release publishes is a Windows payload. The gate reads every target's bytes and - # does not need to execute them. + # Windows validates the IPC payload while checking every target artifact. runs-on: windows-latest timeout-minutes: 45 env: @@ -190,8 +188,8 @@ jobs: run: npm run verify:release - name: Assemble and pack the publish directory - # Materializes the remaining Windows platform package from its pinned upstream - # assets, packs everything, and writes provenance.json + SHA256SUMS under + # Packs the main plugin without shell payload dependencies and writes + # provenance.json + SHA256SUMS under # $env:DIST. A non-zero exit fails the run; nothing is published from here. run: node scripts/build-platform-packages.mjs --dist "$env:DIST" diff --git a/NOTICE b/NOTICE index d5b479bd..ec456ec5 100644 --- a/NOTICE +++ b/NOTICE @@ -25,12 +25,9 @@ because it contains two bodies of work under two licenses: `vendor/fastctx/UPSTREAM.md` and `vendor/fastctx/FORK.md` in the repository history and in the published tarballs of earlier versions. -The published package also declares this distribution's own platform -packages as OPTIONAL dependencies, and the same packages — this -distribution's own tarballs, and nothing upstream's — are the assets -attached to the GitHub Release for a tag. They are optional in the -dependency sense, and neither of them is a payload at all: no upstream -binary travels in this distribution. +The published package and GitHub release no longer carry shell payload +packages or declare them as npm dependencies. Shell binaries are obtained +separately from their upstream distributors, only on operator request. * Bash is an explicit optional download, not an npm dependency or a release payload. This distribution does not contain, forward, repackage, or @@ -47,13 +44,17 @@ binary travels in this distribution. Git project (https://github.com/git/git). No GPL-2.0-covered component is part of this distribution. - * `@dsh-ops/pwsh--` — a PIN, not a redistribution. + * PowerShell remains managed by the host. `dsh-ops upgrade-host-pwsh` + previews a standard Windows installation and `--yes` executes WinGet. + It does not override the host executor or remove existing copies. + The legacy provisioner retains a PIN, not a redistribution. This distribution does not contain, forward, repackage, or redistribute PowerShell. The pin records the upstream release (`PowerShell-7.6.6-win-x64.zip`, PowerShell `v7.6.6`), the official URL that publishes it, and its SHA-256; `dsh-ops provision-shells --pwsh` reads the pin, downloads that asset on the user's own - machine, and verifies the digest there. PowerShell is distributed by + machine, and verifies the digest there; OPS does not automatically + activate that legacy copy. PowerShell is distributed by Microsoft under the MIT licence; that licence text, its copyright, and the corresponding source belong to Microsoft and to the PowerShell project (https://github.com/PowerShell/PowerShell). diff --git a/README.en.md b/README.en.md index 4dd32450..977c2c80 100644 --- a/README.en.md +++ b/README.en.md @@ -28,7 +28,7 @@ Main features of this plugin: - A set of more advanced always-on tools written in Rust. Compared with the built-ins, these tools use fewer input and output tokens, run faster, cover more ground, and impose less mental load on the AI; they also carry brief hash checks, line/position info, and similar messages to reduce token overhead. - Planned on the [roadmap](docs/roadmap.md): everyday tools such as DOC/PDF operations, web search, and crawl4ai-style HTML-to-Markdown tools, added through progressive disclosure so the AI avoids writing one-off code or scripts to reach its goal. **The current release does not ship them yet.** - Provides an option to disable the built-in tools, to maximize savings on the AI's mental load and the fixed system prompt. -- On Windows it additionally provides the `ops_bash` tool — the AI is far more fluent with bash than with pwsh. Run `dsh-ops bash-runtime install` to acquire the verified optional Bash, or select an existing Git Bash through `bashPath`. Bash is no longer an npm dependency. +- On Windows it additionally provides the `ops_bash` tool — the AI is far more fluent with bash than with pwsh. Run `dsh-ops bash-runtime install` to acquire the verified optional Bash, or select an existing Git Bash through `bashPath`. Neither Bash nor PowerShell is an npm payload dependency. `dsh-ops upgrade-host-pwsh` previews a standard Windows PowerShell 7 installation; add `--yes` to execute it, then restart DSH. Custom host `pwshPath` settings remain unchanged. Plugin traits: @@ -104,7 +104,7 @@ Enable the `dsh-ops/tui` row in dsh-TUI and restart to add the OPS sidebar witho Plugin source is distributed under **`MIT AND Apache-2.0`**: everything outside `licenses/fastctx/` is MIT; FastCtx is Apache-2.0. See [NOTICE](NOTICE), `licenses/fastctx/LICENSE-APACHE`, and `licenses/fastctx/NOTICE`. -The separate Windows payload packages are distributed under their upstream components' licenses: Git for Windows includes GPL and other licensed components, and PowerShell includes MIT and third-party components. Their original licenses, notices, and provenance records are kept with the payloads; the plugin's MIT license does not replace those. See [PROVENANCE.md](docs/PROVENANCE.md). +Shell binaries are obtained separately from their upstream distributors, under their upstream licenses. Git for Windows includes GPL and other licensed components, and PowerShell includes MIT and third-party components. The plugin's MIT license does not replace those licenses. See [PROVENANCE.md](docs/PROVENANCE.md). ## Acknowledgements diff --git a/README.md b/README.md index 88844929..e3b3a32b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ - 提供一组更高级的tools常驻工具,由rust编写。这些tools工具相对于原版,输入和输出token都更少,运行速度更快,且覆盖面更广,给ai造成的心智负担更低,同时附带简短的哈希校验、行数位置等消息以减少 - 已在[路线图](docs/roadmap.md)中规划:以渐进式披露的形式补上 doc/pdf 操作、网页搜索、html 转 markdown 类 crawl4ai 工具等日常常用的 tools 工具,让 ai 不必重新编写代码或脚本来实现自己的目标。**当前版本尚未提供这些工具。** - 提供关闭原版tools以最大化节省ai心智负担和系统固定提示词的可选项 -- 在windows平台下额外提供tools:ops_bash的支持——ai使用bash远比ai使用pwsh更流畅。可运行 `dsh-ops bash-runtime install` 单独下载经过校验的 Bash,或通过 `bashPath` 选择已有 Git Bash;Bash 不再作为 npm 依赖安装 +- 在windows平台下额外提供tools:ops_bash的支持——ai使用bash远比ai使用pwsh更流畅。可运行 `dsh-ops bash-runtime install` 单独下载经过校验的 Bash,或通过 `bashPath` 选择已有 Git Bash;Bash 与 PowerShell 都不再作为 npm 载荷依赖安装。`dsh-ops upgrade-host-pwsh` 预览标准 Windows PowerShell 7 安装,添加 `--yes` 才执行,之后重启 DSH;自定义宿主 `pwshPath` 配置保持不变 插件特点: @@ -109,7 +109,7 @@ npx --yes dsh-ops@latest uninstall --profile desktop 插件源码分发采用 **`MIT AND Apache-2.0`**:`licenses/fastctx/` 之外为 MIT;FastCtx 为 Apache-2.0。见 [NOTICE](NOTICE)、`licenses/fastctx/LICENSE-APACHE` 与 `licenses/fastctx/NOTICE`。 -独立的 Windows 载荷包另按上游组件许可证分发:Git for Windows 包含 GPL 等许可组件,PowerShell 包含 MIT 与第三方组件。其原始许可证、声明及来源记录随载荷保留;插件的 MIT 许可不替代这些许可。详见 [PROVENANCE.md](docs/PROVENANCE.md)。 +Shell 二进制由用户从上游发行方另行获取,并遵守上游许可证:Git for Windows 包含 GPL 等许可组件,PowerShell 包含 MIT 与第三方组件;插件的 MIT 许可不替代这些许可。详见 [PROVENANCE.md](docs/PROVENANCE.md)。 ## 致谢 diff --git a/bin/check-release.mjs b/bin/check-release.mjs index ea309791..183c8b47 100644 --- a/bin/check-release.mjs +++ b/bin/check-release.mjs @@ -22,10 +22,9 @@ if (!sameSet(pkg.os ?? [], PLATFORMS.map(target => target.split('-')[0]).filter( for (const id of ['dsh-ops-file', 'dsh-ops-shell']) { if (!sameSet(components.find(component => component.id === id)?.targets ?? [], PLATFORMS)) throw Error(`${id} target declarations disagree`) } -if (pkg.optionalDependencies?.['@dsh-ops/bash-win32-x64'] || pkg.dependencies?.['@dsh-ops/bash-win32-x64']) throw Error('Bash must be an explicit optional acquisition, not a package dependency') -for (const kind of ['pwsh']) { +for (const kind of ['bash', 'pwsh']) { const name = `@dsh-ops/${kind}-win32-x64` - if (pkg.optionalDependencies?.[name] !== '0.2.1') throw Error(`${name} must remain the pinned Windows-only optional dependency`) + if (pkg.optionalDependencies?.[name] || pkg.dependencies?.[name]) throw Error(`${name} must not be a package dependency; shell acquisition is an explicit operator action`) } if (pkg.scripts?.postinstall) throw Error('Release must not download shell payloads from postinstall') const runtimeDir = path.join(root, 'runtime') diff --git a/bin/dsh-ops.mjs b/bin/dsh-ops.mjs index a7ede90c..5e29a772 100644 --- a/bin/dsh-ops.mjs +++ b/bin/dsh-ops.mjs @@ -20,6 +20,7 @@ import { profileCommand } from '../lib/profile-install.js' import { cargoBuild, findCargo } from '../lib/rust.js' import { commandLadder } from '../lib/cli/report.js' import { commandProvisionShells } from '../lib/cli/provision-shells.js' +import { commandUpgradeHostPwsh } from '../lib/cli/pwsh-upgrade.js' import { commandBashRuntime } from '../lib/cli/bash-runtime.js' import { commandUninstall } from '../lib/cli/uninstall.js' @@ -51,6 +52,9 @@ Usage: dsh-ops bash-runtime [status|install|remove|git|managed] [--yes] [--dry-run] Manage optional Bash; git/managed prints a bashPath selection for the host config editor. remove previews unless --yes. + dsh-ops upgrade-host-pwsh [--yes] [--dry-run] + Preview/run standard Windows PowerShell 7 installation through + WinGet. Restart DSH; custom host pwshPath remains authoritative. dsh-ops help This text Environment: @@ -111,6 +115,8 @@ async function main() { return 2 } switch (command) { + case 'upgrade-host-pwsh': + return commandUpgradeHostPwsh({ yes: values.yes, dryRun: values['dry-run'] }) case 'bash-runtime': return commandBashRuntime(positionals[1], { yes: values.yes, dryRun: values['dry-run'], force: values.force, configFile: values.config }) case 'ladder': diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8192eb97..43ce4f4b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,15 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Leave PowerShell with its host (#261) + +- Remove the PowerShell npm payload dependency, shell payload release assembly and automatic + host executor overlay. Existing host settings, Windows PowerShell 5.1 and legacy copies stay intact. +- Add a strict TypeScript manager and `upgrade-host-pwsh` CLI with preview by default, + explicit WinGet execution, honest exit/cancellation effects and restart/custom-path guidance. +- 移除 PowerShell 载荷依赖和宿主执行器覆盖;新增默认预览、显式执行的宿主升级命令, + 保留已有设置及旧副本,并说明取消后的未知副作用、重启与自定义路径要求。 + - Return host-compatible Bash argument failures as values with stable retry/effect metadata; interrupted or failed running commands require read-only inspection before replay. Bash 参数失败返回结构化值;运行后的未知副作用禁止自动重试。 - Cancel queued job starters when host admission fails after accepting the run hook; never retry that call in the foreground. 宿主接受任务启动回调后若准入失败,取消排队启动且不转为前台重试。 diff --git a/docs/PROVENANCE.md b/docs/PROVENANCE.md index 93b645eb..1883a9f9 100644 --- a/docs/PROVENANCE.md +++ b/docs/PROVENANCE.md @@ -3,6 +3,23 @@ What this repository is, where its parts came from, what was verified, and what was not. +## Unreleased: optional shells and host PowerShell + +The npm package and release assembly no longer depend on or publish shell payload packages. +Bash is an explicit pinned upstream download. PowerShell is selected entirely by the host; +OPS no longer changes its `pwshPath` or activates legacy provisioned copies. The exported strict +TypeScript manager previews a standard Windows installation and executes WinGet only on request. +It follows [Microsoft's installation guidance](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows) +and preserves installer prompts. WinGet's `0x8A15002B` is reported only as no applicable update; +cancellation after launch has unknown effects. No system installer was run during validation: +fixture runners cover argv, exits, cancellation and preservation, while a real child tests spawn reporting. + +npm 包与发布组装不再依赖或发布 shell 载荷包;Bash 采用显式固定版本下载。 +PowerShell 完全由宿主选择,OPS 不再修改 `pwshPath` 或启用旧托管副本。 +严格 TypeScript 管理器默认预览,显式请求才通过 WinGet 安装标准 PowerShell,保留安装提示。 +验证未运行系统安装器:fixture 覆盖参数、退出码、取消及保留行为,真实子进程验证启动反馈。 +下文旧版本的载荷与覆盖机制仅是历史记录,不描述当前行为。 + ## Unreleased: host Bash contract The shell contract, approval and job adapters in `src/shells/` adapt diff --git a/docs/component-reference.md b/docs/component-reference.md index 02fb4164..55c0bfb2 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -6,7 +6,7 @@ What each of the six components publishes, with parameters and permission requir | Row `name` | Description | Default | Published tools | | --- | --- | --- | --- | -| `dsh-ops/shell` | Prefer bash as the terminal, with PowerShell 7 for operations bash cannot cover | On | `ops_bash`; host `pwsh` uses bundled PowerShell 7 | +| `dsh-ops/shell` | Prefer bash as the terminal, with host PowerShell for operations bash cannot cover | On | `ops_bash`; host `pwsh` keeps its own executor | | `dsh-ops/file` | Faster, higher-performance Rust file retrieval with more concise, token-efficient output | On | `ops_read_many`; advanced tools load through discovery | | `dsh-ops/ptc` | Independent Rust safe batches and bounded result references, only in pure PTC sessions | Off | `ops_batch`, `ops_result` | | `dsh-ops/panel` | Data capture, off at the switch: new conversations write `/.dsh-ops/panel/.json` plus the viewer `panel.html` | Row on, `capture: false` | None; see the [configuration reference](configuration-reference.md) | @@ -29,7 +29,7 @@ The former `dsh-ops/background` component (FastCtx background jobs: `ops_run`, ` | `ops_bash` | Preferred general bash command executor; `command` for short commands, `script_path` for long scripts | Confined in restricted modes; matched Windows x64 private IPC runtime | | `ops_batch` | Preflight and execute copy / move / remove with audit and recovery | See the PTC notes | | `ops_result` | Bounded result references: put, page, release | See the PTC notes | -| Host `pwsh` | Windows-native operations using bundled PowerShell 7 | Host policy | +| Host `pwsh` | Windows-native operations using the host-selected PowerShell | Host policy | ¹ **Fixed file operations use a trusted-operation policy fence, matching DSH fs-sandbox, not arbitrary-code isolation.** OS-readable paths are readable in all modes; read-only rejects replacement, including its dry run; workspace-write permits writes under the workspace and this session's private temporary directory, and full access adds no write fence. The fixed Rust worker uses host subprocess governance plus no-follow traversal, budgets and CAS. Bash/run_code still require OS confinement; no unconfined fallback exists. On Windows, owned workspaces automatically receive directory-only WRITE_OWNER for the host grant prerequisite, with DACL backups; no recursive ACL edits or ownership changes. That preparation runs the package's own script as the current user before sandbox confinement applies, only in workspace-write, with `-ExecutionPolicy Bypass` (Group Policy still wins). @@ -152,13 +152,13 @@ Bounded session-owned result references: PTC can put object rows; Native file to ## `dsh-ops/shell` — terminal and shells -Routing: **toolkit → bash → PowerShell 7**. +Routing: **toolkit → bash → host PowerShell**. - `ops_bash`: the preferred general command executor. Use `command` for short commands and `script_path` for long scripts. Cancellation also stops pending sandbox preparation; a late preparation result cannot start the command. When cancellation races a timeout, the first cause is reported. Relative `workdir` paths (including `.`) resolve against the calling session's workspace, not the host process directory. Omitting `workdir` or passing a blank string uses that workspace; absolute paths remain absolute. Missing or non-directory targets are rejected before execution, with the resolved path in the error. On Windows, `\child` uses the session's drive; a drive-relative path such as `C:child` on a different drive must be written as an absolute path. -- Host `pwsh`: Windows-native operations, using bundled PowerShell 7. +- Host `pwsh`: Windows-native operations, using the host-selected PowerShell. `ops_bash` requires a non-empty `description` and keeps the host Bash fields and result kinds: `foreground`, `background`, or `promoted`. Its default timeout is 120 seconds and the cap is 600 seconds. When the host jobs service is available, `run_in_background: true` returns a job ID immediately; a foreground timeout promotes the same running job. Use the host `job_output` and `job_kill` tools to collect or stop it. Without host jobs, the background parameter is absent and a foreground timeout stops the command. Unknown arguments return a failed foreground value before execution. The compatible string `error` remains; `failure` adds stable code, effect state and retry guidance. Invalid arguments have no command effects and require input changes. After execution begins, timeouts, cancellation and nonzero exits report unknown effects and prohibit automatic replay until read-only inspection. If job admission fails after accepting its run hook, the queued starter is cancelled and no foreground retry occurs. @@ -166,12 +166,12 @@ Routing: **toolkit → bash → PowerShell 7**. `script_path` (instead of `command`), `graceMs`, and `includeDiagnostics` remain explicit OPS compatibility extensions. They do not change the host command contract or its timeout limits. -"Prefer bash as the terminal" means prompting the model to use `ops_bash` first; it **does not change the system default terminal, PATH or existing host shell tool names**. When the shell component is on, the official configuration lifecycle temporarily sets the PowerShell 7 path for `pwsh-sandbox`; when off, it restores the executor's latest original configuration without writing profile configuration. Switching executor configuration does not cancel already-running host commands. +"Prefer bash as the terminal" means prompting the model to use `ops_bash` first; it **does not change the system default terminal, PATH or existing host shell tool names**. OPS never overlays the host PowerShell executor configuration. Use `dsh-ops upgrade-host-pwsh` to preview an explicit standard PowerShell 7 installation; `--yes` executes it. Restart DSH afterwards, and review any custom host `pwshPath` in host settings. Existing running commands and old provisioned copies are not removed. The routing table the model sees is rendered by the plugin from what it actually published: ``` -# Shell routing: bash → PowerShell 7 +# Shell routing: bash → host PowerShell | Task | Tool | | --- | --- | @@ -181,7 +181,7 @@ The routing table the model sees is rendered by the plugin from what it actually | Find paths | ops_glob | | Batch replacement; use host edit for precise edits | ops_replace | | General commands / builds / git / pipelines / scripts — preferred command executor | ops_bash | -| Windows-native cmdlets / registry / services only | pwsh (bundled PowerShell 7 via host) | +| Windows-native cmdlets / registry / services only | pwsh (host PowerShell) | ``` --- diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 80b1dd43..02224b50 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -97,6 +97,7 @@ config: - `publishBashTool`: publish `ops_bash` using current session policy; restricted modes require the host sandbox, full access uses the host subprocess service. - `promptPolicy`: whether to inject the component's runtime guidance; use `extraGuidance` to append instructions. - `required`: whether to reject activation when the native worker is unavailable. +- Host PowerShell is outside OPS runtime selection: no bundled executable or `pwshPath` override. `upgrade-host-pwsh` previews a standard Windows installation; `--yes` runs it. Restart DSH and review any custom host `pwshPath` afterwards. - `shellPolicy`: defaults to `advise`; `deny-host-shell` rejects host shells in `deniedHostTools` (default `[pwsh, bash, pwsh_persistent]`). This also disables third-layer pwsh; enable cautiously. - `allowSystemShellFallback`: defaults to `false`. Without a bundled/provisioned bash, do not discover system PATH or well-known installations (**on a POSIX host this key has no effect**: the shell component already resolves the host bash and there is no pinned payload to fall back from). Explicit `true` opts into an unpinned system bash that the operator must trust and maintain. Explicit `bashPath` is a separate operator selection, not an automatic fallback. See the update configuration modification area in the [usage guide](usage.md) for the first-start migration. - `shellPolicy` is independent: `deny-host-shell` masks/refuses host tools; disabling fallback constrains the plugin's bash resolver, not the host's own `pwsh`, and is not a filesystem sandbox. diff --git a/docs/release-process.md b/docs/release-process.md index 4a60dd36..75f1ef87 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -1,6 +1,6 @@ # 发布流程与如何启用 -对象是 `.github/workflows/release.yml`(workflow 名 `Release`)。发布目标只有一个:**GitHub Release**(tag + 自动 notes + 三个可安装 tarball + `SHA256SUMS`)。npm publish 不在这条流水线里,理由见下。 +对象是 `.github/workflows/release.yml`(workflow 名 `Release`)。发布目标只有一个:**GitHub Release**(tag + 自动 notes + 一个可安装主包 tarball + `SHA256SUMS`)。npm publish 不在这条流水线里,理由见下。 ## 当前状态:`disabled_manually`,推 tag 不会发布 @@ -40,9 +40,9 @@ Sync master with main | active - 下载三个 artifact 分别放回 `runtime/win32-x64`、`runtime/linux-x64`、`runtime/darwin-arm64`,不把各目标的 `provenance.json` 展平覆盖。 - 单独编译 gate 宿主 worker 到 `native/target/release` 供固定路径的真实套件使用;这份测试 build 不替换来自冷构建矩阵的 staged 发布产物。 - **`npm run verify:release`**(`validate-manifest` + 全部仓库测试 + `bin/check-release.mjs`)是硬门禁:三目标缺一、receipt 与字节哈希不符、Windows IPC 二进制/源码哈希不符、`dsh-plugin.json` 与 runtime 白名单不一致,任一都失败。 - - 之后才 `build-platform-packages.mjs --dist`(组装 Windows 两个 payload 包 + 主包并打 tarball,写 `provenance.json` 与 `SHA256SUMS`),再 `... check --dist`(只重读已记录的摘要,不下载不构建)。 + - 之后才 `build-platform-packages.mjs --dist`(只组装主包并打 tarball,不再下载或发布 shell 载荷包,写 `provenance.json` 与 `SHA256SUMS`),再 `... check --dist`(只重读已记录的摘要,不下载不构建)。 - dist 目录以 `release-dist` 上传,`if-no-files-found: error`。 - - 组装必须跑在 Windows:`build-platform-packages.mjs` 只发布 Windows payload(默认 `--platform win32`)。 + - 组装当前仍在 Windows gate 执行;`build-platform-packages.mjs` 保留兼容命令名,但不再产生平台 shell 包。Windows 构建和测试使用显式获取的固定 Bash fixture。 3. **`publish`(`ubuntu-24.04`,`needs: gate`,仅 tag)** - 唯一持有 `contents: write` 的 job;不用任何 secret,只用 Actions 自带 `github.token`。 - `gh release create ... --verify-tag` 创建,已存在则 `gh release upload --clobber` 覆盖资产;tarball 数量为 0 或缺 `SHA256SUMS` 时显式报错退出,不会发出空 Release。 @@ -51,7 +51,7 @@ Sync master with main | active ## npm 发布:不在本工作流内(要改需 admin) - 本仓库的 GitHub Release 与 npm registry 是两个渠道;[PROVENANCE.md](PROVENANCE.md) 明确记录"GitHub 发布不会自动发布 npm",而 `npm publish` 需要 `NPM_TOKEN`。 -- 这条工作流**不引用任何 secret**:写一个引用未知 secret 的步骤,结果只会是启用后第一次运行才发现它不存在。当前流程仍是维护者本机 `npm publish`(先平台包、后主包,注意凭据选择与 hard link 限制,见 [release-0.2.1.md](release-0.2.1.md))。 +- 这条工作流**不引用任何 secret**:写一个引用未知 secret 的步骤,结果只会是启用后第一次运行才发现它不存在。当前流程仍是维护者本机 `npm publish`(当前只发布主包;旧版平台包流程见 [release-0.2.1.md](release-0.2.1.md),不再适用于此版本)。 - 若将来要让 CI 发布 npm,需要 admin 做的事:在仓库 secrets 里添加 `NPM_TOKEN`(带 2FA bypass 的 granular token),并新增一个 `needs: gate`、`permissions: contents: read` 的 job 显式读取 `secrets.NPM_TOKEN`——**这一半没有实现,属人为占位**。 ## 如何启用(需要 admin) @@ -71,7 +71,7 @@ gh api repos/T-Auto/dsh-ops/actions/workflows --jq '.workflows[]|select(.name==" ## 启用后:先手动干跑一次,再打 tag 1. Actions → Release → **Run workflow**,分支选 `main`,不选 tag。预期:`native` 矩阵三个分支全绿、`gate` 绿、`publish` **skipped**,并产出 `native-win32-x64`/`native-linux-x64`/`native-darwin-arm64`/`release-dist` 四个 artifact。 -2. 检查 `release-dist` 里有三个 tarball、`SHA256SUMS`、`provenance.json`;把 `SHA256SUMS` 与 run summary 里的摘要对上。 +2. 检查 `release-dist` 里有一个主包 tarball、`SHA256SUMS`、`provenance.json`;把 `SHA256SUMS` 与 run summary 里的摘要对上。 3. 干跑全绿之前**不要**打 tag:`push` tag 会立即发布,而 Release 一旦创建就对外可见(可以改 notes、可以替换资产,但"没发布过"这件事收不回来)。 4. 需要真正发布时:`git tag v` → push tag(或按上面第 1 步改用 tag 作为 ref 手动 dispatch)。tag 与 `package.json` 版本必须一致,否则 `gate` 会拒绝。 5. 万一中断:重新 dispatch 同一个 tag 即可,`gh release upload --clobber` 会替换资产而不是叠一份。 diff --git a/docs/usage.md b/docs/usage.md index 46c62a18..9bc3f8b3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -46,7 +46,7 @@ capability matrix and the known limitations. ### Official plugin marketplace -In the target DSH application's plugin marketplace, enter the npm package name **`dsh-ops`**, install, then enable. Installation belongs to the currently running profile, which is `desktop` for the desktop app. Bash is acquired separately through the runtime manager; PowerShell 7 remains an optional payload dependency in this slice. Installation runs no shell downloader and does not change system PATH. +In the target DSH application's plugin marketplace, enter the npm package name **`dsh-ops`**, install, then enable. Installation belongs to the currently running profile, which is `desktop` for the desktop app. Neither Bash nor PowerShell binaries are npm dependencies. Bash is acquired separately through the runtime manager; PowerShell stays under host control. Installation runs no shell downloader and does not change system PATH. ### Optional Bash runtime @@ -106,6 +106,20 @@ Legacy explicitly provisioned `/dsh-ops/` files (provisioned shells, a --- +## Host PowerShell on Windows + +OPS no longer bundles PowerShell or changes the host's `pwshPath`. Windows PowerShell 5.1, existing custom executors and old OPS provisioned copies remain untouched. To install or upgrade the standard PowerShell 7 installation, first inspect the plan: + +```console +dsh-ops upgrade-host-pwsh +dsh-ops upgrade-host-pwsh --yes --dry-run +dsh-ops upgrade-host-pwsh --yes +``` + +Only `--yes` executes WinGet; `--dry-run` always previews. The action uses the exact stable `Microsoft.PowerShell` package from the `winget` source and retains WinGet's installer and agreement prompts. It may require Windows elevation. Missing WinGet produces guidance without attempting installation; see [Microsoft's Windows installation guide](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows). + +Restart DSH after successful installation so the host probes its executor again. An explicit host `pwshPath` remains authoritative: review it in host settings if it points elsewhere. A `no-update` result means WinGet found no applicable update for this package, not that every custom PowerShell copy is current. Cancellation after the installer starts has unknown effects; check the standard installation before retrying. The older `provision-shells --pwsh` command remains an explicit compatibility download, but OPS does not activate its copy automatically. + ## Command-line interface ```console @@ -117,6 +131,7 @@ dsh-ops build-ptc Build the independent native Rust worker (all pla dsh-ops uninstall [--yes] [--purge-fastctx] dsh-ops ladder [--json] [--config ] prompt ladder actually renders; nothing is spawned +dsh-ops upgrade-host-pwsh [--yes] [--dry-run] dsh-ops provision-shells [--bash] [--pwsh] [--force] [--dry-run] [--pin ] their digests, and install them under /dsh-ops/shells ``` diff --git "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" index d67b1788..e78d7e2d 100644 --- "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -43,7 +43,7 @@ PowerShell 7 在 macOS 上不可用。能力矩阵与已知限制见 [macOS arm6 ### 官方插件市场 -在目标 DSH 应用的插件市场中输入 npm 包名 **`dsh-ops`**,安装后选择启用。安装归属于当前运行的 profile,桌面端为 `desktop`。Bash 通过独立运行时管理器获取;本切片中 PowerShell 7 暂时保留为可选载荷依赖。安装不运行 shell 下载脚本,不改系统 PATH。 +在目标 DSH 应用的插件市场中输入 npm 包名 **`dsh-ops`**,安装后选择启用。安装归属于当前运行的 profile,桌面端为 `desktop`。Bash 与 PowerShell 二进制都不再作为 npm 依赖安装。Bash 通过独立运行时管理器获取;PowerShell 继续由宿主管理。安装不运行 shell 下载脚本,不改系统 PATH。 ### 可选 Bash 运行时 @@ -103,6 +103,20 @@ npx --yes dsh-ops@latest uninstall --profile desktop --- +## Windows 宿主 PowerShell + +OPS 不再捆绑 PowerShell,也不修改宿主 `pwshPath`。Windows PowerShell 5.1、已有的自定义执行器以及旧 OPS 托管副本都保留。安装或升级标准 PowerShell 7 安装前,先查看执行计划: + +```console +dsh-ops upgrade-host-pwsh +dsh-ops upgrade-host-pwsh --yes --dry-run +dsh-ops upgrade-host-pwsh --yes +``` + +只有 `--yes` 才运行 WinGet;`--dry-run` 始终只预览。该操作精确选择 `winget` 源的稳定版 `Microsoft.PowerShell`,保留 WinGet 的安装器与协议提示,可能要求 Windows 提权。缺少 WinGet 时仅返回操作指引,不尝试安装;详见 [Microsoft 的 Windows 安装指南](https://learn.microsoft.com/zh-cn/powershell/scripting/install/install-powershell-on-windows)。 + +成功后重启 DSH,让宿主重新探测执行器。显式配置的宿主 `pwshPath` 仍然优先;如它指向其它位置,请在宿主设置中检查。结果 `no-update` 只说明 WinGet 未找到此包的适用更新,不代表全部自定义 PowerShell 副本都是最新版。安装器已启动后取消操作,副作用状态为未知;重试前先检查标准安装。旧命令 `provision-shells --pwsh` 仍保留为显式兼容下载,但 OPS 不会自动启用其副本。 + ## 命令行接口 ```console @@ -114,6 +128,7 @@ dsh-ops build-ptc Build the independent native Rust worker (all pla dsh-ops uninstall [--yes] [--purge-fastctx] dsh-ops ladder [--json] [--config ] prompt ladder actually renders; nothing is spawned +dsh-ops upgrade-host-pwsh [--yes] [--dry-run] dsh-ops provision-shells [--bash] [--pwsh] [--force] [--dry-run] [--pin ] their digests, and install them under /dsh-ops/shells ``` diff --git "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" index 03171391..811071b6 100644 --- "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" +++ "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" @@ -6,7 +6,7 @@ | 配置行 `name` | 介绍 | 默认 | 发布的工具 | | --- | --- | --- | --- | -| `dsh-ops/shell` | 将默认终端改为 bash,以及 bash 覆盖不到时提供 PowerShell 7 | 开 | `ops_bash`;宿主 `pwsh` 使用随包 PowerShell 7 | +| `dsh-ops/shell` | 优先使用 bash,无法覆盖时使用宿主 PowerShell | 开 | `ops_bash`;宿主 `pwsh` 保持其原执行器 | | `dsh-ops/file` | 更快、更高性能、输出更精简更省 token 的 Rust 文件检索 | 开 | `ops_read_many`;高级工具通过发现加载 | | `dsh-ops/ptc` | 独立 Rust 安全批处理和有界结果引用,仅纯 PTC 会话加载 | 关 | `ops_batch`、`ops_result` | | `dsh-ops/panel` | 数据采集,开关默认关:新对话写入 `/.dsh-ops/panel/.json` 与读取它的 `panel.html` | 行开,`capture: false` | 无;见[配置参考](配置参考.md) | @@ -29,7 +29,7 @@ | `ops_bash` | 优先的通用 bash 命令执行器;短命令用 `command`,长脚本用 `script_path` | 受限模式经 DSH 沙箱;Windows x64 使用匹配的私有 IPC 副本 | | `ops_batch` | 预检并执行 copy / move / remove,带审计与恢复 | 见 PTC 说明 | | `ops_result` | 有界结果引用:写入、分页、释放 | 见 PTC 说明 | -| 宿主 `pwsh` | 使用随包 PowerShell 7 的 Windows 原生操作 | 沿用宿主策略 | +| 宿主 `pwsh` | 使用宿主选择的 PowerShell 的 Windows 原生操作 | 沿用宿主策略 | ¹ **固定文件操作遵循 DSH fs-sandbox 同类的可信操作权限检查,不执行模型代码。** 所有模式允许 OS 可读路径的读取;read-only 拒绝替换(含其 dry run),workspace-write 允许工作区及本会话私有临时目录内替换,full-access 不额外限制写路径。通过宿主 subprocess 执行固定 Rust worker,保留无链接遍历、预算及 CAS;Bash/run_code 仍通过 OS 沙箱,绝不在失败后裸执行。Windows 启动前只为当前用户拥有的工作区根准备目录自身 WRITE_OWNER 并备份 DACL,不递归、不改所有者。该准备步骤以当前用户身份、在沙箱约束生效之前运行包内自带的脚本,仅在工作区内修改模式下执行,并传 `-ExecutionPolicy Bypass`(组策略仍优先)。 @@ -152,13 +152,13 @@ ## `dsh-ops/shell` —— 终端与 shell -执行顺序:**工具包 → bash → PowerShell 7**。 +执行顺序:**工具包 → bash → 宿主 PowerShell**。 - `ops_bash`:优先的通用命令执行器。短命令用 `command`,长脚本用 `script_path`。 取消也会终止尚未完成的沙箱准备;迟到的准备结果不能再启动命令。取消与超时同时发生时,报告最先触发的原因。 相对 `workdir`(包括 `.`)按调用会话的工作区解析;省略或传空白字符串时使用该工作区,绝对路径保持绝对路径语义。不存在或不是目录的目标会在执行前被拒绝,错误中包含解析后的路径。 Windows 上的 `\child` 使用会话所在盘符;`C:child` 这类位于另一盘符的相对路径需改写为绝对路径。 -- 宿主 `pwsh`:Windows 原生操作,使用随包 PowerShell 7。 +- 宿主 `pwsh`:Windows 原生操作,使用宿主选择的 PowerShell。 `ops_bash` 必须提供非空 `description`,输入字段和结果类型与宿主 Bash 保持一致:`foreground`、`background` 或 `promoted`。默认超时 120 秒,上限 600 秒。宿主 jobs 服务可用时,`run_in_background: true` 立即返回 job ID;前台等待超时会把同一个运行中任务交给宿主管理。用宿主 `job_output` 收集输出、`job_kill` 停止任务。没有 jobs 服务时不声明后台参数,前台超时会停止命令。未知参数在执行前被拒绝。 @@ -168,12 +168,12 @@ Bash 参数错误以失败的 foreground 值返回,不抛出异常。兼容的 `script_path`(替代 `command`)、`graceMs` 和 `includeDiagnostics` 是保留的 OPS 兼容扩展,不改变宿主命令契约或超时上限。 -“默认终端改为 bash”指模型提示优先使用 `ops_bash`,**不修改系统默认终端、PATH 或宿主原有 shell 工具名称**。Shell 组件开时,通过官方配置生命周期临时指定 `pwsh-sandbox` 的 PowerShell 7 路径;关时按执行器最新原配置恢复,不写 profile 配置。执行器配置切换不代表取消已在运行的宿主命令。 +“默认终端改为 bash”指模型提示优先使用 `ops_bash`,**不修改系统默认终端、PATH 或宿主原有 shell 工具名称**。OPS 不覆盖宿主 PowerShell 执行器配置。运行 `dsh-ops upgrade-host-pwsh` 预览标准 PowerShell 7 的显式安装计划,添加 `--yes` 才执行。之后重启 DSH,并在宿主设置中检查自定义 `pwshPath`。已有运行命令和旧托管副本都不会被移除。 模型侧看到的路由表由插件按其实际发布情况渲染: ``` -# Shell routing: bash → PowerShell 7 +# Shell routing: bash → host PowerShell | Task | Tool | | --- | --- | @@ -183,7 +183,7 @@ Bash 参数错误以失败的 foreground 值返回,不抛出异常。兼容的 | Find paths | ops_glob | | Batch replacement; use host edit for precise edits | ops_replace | | General commands / builds / git / pipelines / scripts — preferred command executor | ops_bash | -| Windows-native cmdlets / registry / services only | pwsh (bundled PowerShell 7 via host) | +| Windows-native cmdlets / registry / services only | pwsh (host PowerShell) | ``` --- diff --git "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" index 0a78c3b4..ba954397 100644 --- "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" +++ "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" @@ -90,6 +90,7 @@ config: - `publishBashTool`:是否发布 `ops_bash`;读取最新会话权限,受限模式需宿主 sandbox,完全权限使用宿主 subprocess。 - `promptPolicy`:是否注入该组件的运行时说明;可用 `extraGuidance` 追加指导。 - `required`:原生 worker 不可用时是否拒绝激活。 +- 宿主 PowerShell 不参与 OPS 运行时选择:不再捆绑执行器或覆盖 `pwshPath`。`upgrade-host-pwsh` 预览标准 Windows 安装,`--yes` 才执行。之后重启 DSH,并检查自定义宿主 `pwshPath`。 - `shellPolicy`:默认 `advise`;`deny-host-shell` 拒绝 `deniedHostTools` 列表中的宿主 shell(默认 `[pwsh, bash, pwsh_persistent]`)。这也会禁用第三层 pwsh,谨慎开启。 - `allowSystemShellFallback`:默认 `false`,随包/已 provision 的 bash 缺失时不探测系统 PATH 或常见安装目录(**POSIX 主机上该键不起作用**:shell 组件本来就解析宿主 bash,这里没有 pin 过的载荷可"回退")。只有显式设 `true` 才使用未固定摘要的系统 bash,请自行信任与维护该执行器。显式 `bashPath` 是另一种操作者选择,并非自动回退。首次升级启动的迁移见 [使用指南](使用指南.md) 的「更新配置修改区」。 - `shellPolicy` 与此开关独立:`deny-host-shell` 遮蔽/拒绝宿主工具;禁止系统回退只约束插件自己的 bash 解析,不关闭宿主 `pwsh`,也不提供文件系统沙箱。 diff --git a/dsh-plugin.json b/dsh-plugin.json index 11c5eeac..9ed37f15 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -41,7 +41,7 @@ { "icon": "./docs/assets/icon.png", "title": "dsh-ops", - "description": "Provides bash, PowerShell 7, and a suite of high-performance tools written in Rust for DSH to enhance token efficiency", + "description": "Provides optional Bash and native Rust tools for DSH to improve token efficiency; PowerShell remains host-managed", "locales": [ "locale/en.json", "locale/zh.json" @@ -57,7 +57,7 @@ { "name": "dsh-ops:repository-tooling:shell", "order": 3251, - "summary": "Prefer bash; use the bundled PowerShell 7 for Windows-native functionality where that payload exists; removed when the shell component is off." + "summary": "Prefer bash; use visible host PowerShell for Windows-native functionality without overriding its executor; removed when the shell component is off." }, { "name": "dsh-ops:repository-tooling:ptc", diff --git a/locale/en.json b/locale/en.json index 7db455e4..d87d701e 100644 --- a/locale/en.json +++ b/locale/en.json @@ -1,6 +1,6 @@ { "meta": { "title": "dsh-ops", - "description": "Provides bash, PowerShell 7, and a suite of high-performance tools written in Rust for DSH to enhance token efficiency" + "description": "Provides optional Bash and native Rust tools for DSH to improve token efficiency; PowerShell remains host-managed" } } diff --git a/locale/zh.json b/locale/zh.json index 10cf7277..4fa75954 100644 --- a/locale/zh.json +++ b/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { "title": "dsh-ops", - "description": "为dsh提供bash、powershell7及一系列rust编写的高性能tools来提高token效率" + "description": "为 DSH 提供可选 Bash 和原生 Rust 工具以提高 token 效率;PowerShell 继续由宿主管理" } } diff --git a/package-lock.json b/package-lock.json index 00ddb0a6..30df2750 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,9 +43,6 @@ "engines": { "node": "^22.19 || >=24" }, - "optionalDependencies": { - "@dsh-ops/pwsh-win32-x64": "0.2.1" - }, "peerDependencies": { "@deepseek-ai/cordis": "^4.0.4", "@deepseek-ai/dsh": "^0.2.0-rc.2" @@ -776,19 +773,6 @@ "@standard-schema/spec": "^1.1.0" } }, - "node_modules/@dsh-ops/pwsh-win32-x64": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@dsh-ops/pwsh-win32-x64/-/pwsh-win32-x64-0.2.1.tgz", - "integrity": "sha512-vEB3LccHpl73Buq0ErfDTEMRfwgWNJDRy3agZBNSQwaAv028zMO79pEGPgFVEjjboLoUyOG2+NunMKm4Gszq8w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@dsh-std/core": { "version": "0.1.1-rc.2", "resolved": "https://registry.npmjs.org/@dsh-std/core/-/core-0.1.1-rc.2.tgz", diff --git a/package.json b/package.json index fbbadfd2..4c522567 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "x64", "arm64" ], - "description": "Native file and PTC tools for DSH on Windows x64, Linux x64 and macOS Apple Silicon; Windows-only optional Bash and PowerShell 7 payloads", + "description": "Native file and PTC tools for DSH on Windows x64, Linux x64 and macOS Apple Silicon; optional managed Bash and host PowerShell integration", "icon": "./docs/assets/icon.png", "keywords": [ "dsh", @@ -148,9 +148,6 @@ "optional": true } }, - "optionalDependencies": { - "@dsh-ops/pwsh-win32-x64": "0.2.1" - }, "devDependencies": { "@deepseek-ai/cordis": "4.0.4", "@deepseek-ai/dsh-agent-loop": "0.2.0-rc.2", diff --git a/packages/README.md b/packages/README.md index c6ee59a5..0a13fdd2 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,52 +1,7 @@ -# Platform package skeletons +# Historical platform package skeletons -The two directories here are the **skeletons** of the platform packages this -plugin publishes, one per package, named `--`: +These directories record the older shell payload package layout. Current releases publish only the main plugin: `describePackages()` returns no shell packages, and the npm manifest declares neither Bash nor PowerShell payload dependencies. The old skeletons and assembly helpers remain for historical provenance and fixture checks. -| Skeleton | Published as | What it carries | -| --- | --- | --- | -| `bash-win32-x64/` | the bash package for Windows x64 | Git for Windows PortableGit, the pinned upstream release, unchanged | -| `pwsh-win32-x64/` | the pwsh package for Windows x64 | PowerShell 7, the pinned upstream release, unchanged | +Bash is now an explicit verified upstream download through `dsh-ops bash-runtime install`. PowerShell stays host-managed; `dsh-ops upgrade-host-pwsh` previews a standard Windows installation and `--yes` executes it. See [usage](../docs/usage.md). -Each skeleton holds only what is committed by hand: the package metadata and -this documentation. **No binary is ever committed here.** - -## What the build puts in each package - -`scripts/build-platform-packages.mjs` writes every package to -`/stage/--/`: - -```text -bash-win32-x64/ (and pwsh-win32-x64/) -├── package.json name and version injected, the rest from the skeleton -├── provenance.json the pin: repository, tag, asset name, URL, sha256, -│ bytes, the measured unpacked size (files and bytes), -│ unpacked layout, entry point and its digest, licence, -│ and copyright -├── bin/… the upstream runtime tree, digest-verified against the pin -└── README.md -``` - -**The pin is data, not a copy of the digests in prose.** `provenance.json` is -generated from the pin record read from `lib/shells.js`. The archive is -verified against the pin before it is unpacked, and the entry point against its -own recorded digest after. `--verify-upstream` re-derives each asset digest from -the live upstream release and deletes the download again, so a stale pin is one -command away from being caught. - -## Where the names come from - -**The package name lives in exactly one module.** `lib/shells.js` -(`PLATFORM_PACKAGES`) owns the shell names and therefore the scope. The build -script imports them and never spells a name out, which is why the skeleton -`package.json` files carry neither `name` nor `version` — a re-scoped release -is one edit in `lib/`, not a search-and-replace here. - -The version of a platform package is always the version of the main package -(`package.json`), injected at build time so the two can never disagree. - -## Injection - -The main package's published copy declares both platform packages as exact -`optionalDependencies`, injected by the build script into the publish directory -only. An install on a platform they do not cover skips them instead of failing. +这些目录保留旧 shell 载荷包布局。当前仅发布主插件,不再声明、组装或发布 Bash/PowerShell npm 载荷包;旧骨架与辅助函数仅供历史来源记录及 fixture 检查。Bash 通过显式命令下载,PowerShell 由宿主管理,详见[使用指南](../docs/使用指南.md)。 diff --git a/scripts/build-platform-packages.mjs b/scripts/build-platform-packages.mjs index 07cdae45..253405ae 100644 --- a/scripts/build-platform-packages.mjs +++ b/scripts/build-platform-packages.mjs @@ -1,57 +1,13 @@ #!/usr/bin/env node /** - * Build and verify the remaining PowerShell platform package. Bash is acquired separately. - * - * The PowerShell package carries the complete, digest-verified Windows runtime - * with its licenses preserved. It is injected as an exact optional dependency. Downloads happen during release assembly, - * never in an install lifecycle hook. - * - * `--verify-upstream` keeps the packages honest: it downloads the pinned assets, - * checks each one against the sha256 recorded here, and deletes them again. It - * never runs as part of `all`. - * - * NAMES AND PINS ARE NOT DEFINED HERE WHEN SOMETHING ELSE OWNS THEM. The scope - * and the shell package names come from `lib/shells.js` (`PLATFORM_PACKAGES`). - * The shell pins are read from `lib/shells.js` too - * when that module exports a pin table or a pin function (see - * {@link resolveShellPin} for the shapes accepted); a module that exports - * neither is an error, never a silent second copy of the pins in this file. - * Re-scoping the release is therefore one edit in one module, and the pins have - * exactly one definition per shell. - * - * PIPELINE. Each step is re-runnable on its own: - * - * assemble the package directory, from the committed skeletons - * in `packages/--/` plus the verified - * upstream tree and a per-package `provenance.json` - * pack `npm pack` each directory, then write `SHA256SUMS` and the - * release-wide `provenance.json` - * publish-dir copy the main package into a temporary publish directory, - * inject the remaining platform payload and pack it - * check verify every artifact without downloading or building - * fetch download the pinned upstream assets and keep them - * verify-upstream download them, verify the digests, delete them again - * - * Everything lands under `--dist` (default: `../dist`, a sibling of this - * checkout so a build never writes into the source tree). + * Assemble the main release artifact and verify its provenance and checksum manifest. + * Shell payloads are no longer npm packages. Legacy subcommands remain accepted; + * their shell package list is empty and they acquire no shell archives. * * Usage: - * node scripts/build-platform-packages.mjs [command] [flags] - * - * Commands (default: `all`): - * all | assemble | pack | publish-dir | check | fetch | verify-upstream - * - * Flags: - * --platform

target platform (default: win32 — the only one published) - * --arch target architecture (default: x64) - * --dist artifact directory (default: ../dist) - * --proxy HTTP proxy for downloads (default: $HTTPS_PROXY/$HTTP_PROXY) - * --no-proxy ignore the proxy environment - * --verify-upstream alias for the `verify-upstream` command - * --force re-download assets and rebuild stages - * --check alias for the `check` command + * node scripts/build-platform-packages.mjs [all|publish-dir|check] [--dist ] * - * @module dsh-ops/build-platform-packages + * @module dsh-ops/build/platform-packages */ import fs from 'node:fs' @@ -64,8 +20,6 @@ import { printArtifactTable, stepPack } from './lib/build/pack.js' import { stepPublishDir } from './lib/build/publish-dir.js' import { REPO_ROOT, log } from './lib/build/util.js' -/** The packages this release publishes, in publish order. */ -const KINDS = Object.freeze(['pwsh']) // --------------------------------------------------------------------------- // Step: fetch and verify-upstream diff --git a/scripts/lib/build/assemble.js b/scripts/lib/build/assemble.js index 27673188..a38fa06a 100644 --- a/scripts/lib/build/assemble.js +++ b/scripts/lib/build/assemble.js @@ -1,9 +1,9 @@ /** * Step: assemble — what one release builds and the packages it stages. * - * The remaining package directory is built from the committed skeletons in - * `packages/--/` plus the verified upstream tree and a - * per-package `provenance.json`. + * Current releases produce only the main plugin. The historical shell + * assembly helpers remain for provenance and fixture checks; describePackages + * no longer schedules shell payloads. * * @module dsh-ops/build/lib/assemble */ @@ -11,9 +11,8 @@ import fs from 'node:fs' import path from 'node:path' import * as shellsModule from '../../../lib/shells.js' -import { resolveShellPin } from './pins.js' import { - REPO_ROOT, SCRIPT_SHA256, copyEntry, gitRevision, log, mainManifest, readJsonIfExists, run, + REPO_ROOT, SCRIPT_SHA256, copyEntry, gitRevision, log, readJsonIfExists, run, sha256File, skeletonDigest, treeFiles, writeJson, } from './util.js' @@ -38,20 +37,9 @@ export function shellPackageName(kind, platform, arch) { * @param {string} dist - the artifact directory. * @returns {object[]} one description per package, in publish order. */ -export function describePackages(platform, arch, dist) { - const version = mainManifest().version - return [ - { - kind: 'pwsh', - name: shellPackageName('pwsh', platform, arch), - version, - platform, - arch, - dist, - executable: `bin/${PLATFORM_PACKAGES.pwsh.executable}`, - payload: { type: 'archive', pin: resolveShellPin('pwsh', platform, arch) }, - }, - ] +export function describePackages(_platform, _arch, _dist) { + // Shells are operator-managed, never released as npm payload packages. + return [] } /** diff --git a/scripts/lib/build/publish-dir.js b/scripts/lib/build/publish-dir.js index 36277030..0532ed19 100644 --- a/scripts/lib/build/publish-dir.js +++ b/scripts/lib/build/publish-dir.js @@ -44,7 +44,7 @@ export function printInjection(context) { console.log(` ${own ? '+' : ' '} ${name}: ${range}`) } console.log(' (+ marks a platform package this release injects; the rest came from the committed manifest)') - console.log(' (the remaining platform package carries ready-to-run Windows binaries)\n') + console.log(' (shell binary payloads are not npm dependencies)\n') } /** diff --git a/src/cli/pwsh-upgrade.ts b/src/cli/pwsh-upgrade.ts new file mode 100644 index 00000000..a1b9ec91 --- /dev/null +++ b/src/cli/pwsh-upgrade.ts @@ -0,0 +1,14 @@ +import { upgradeHostPwsh } from '../shells/pwsh-upgrade.js' + +export async function commandUpgradeHostPwsh({ yes = false, dryRun = false }: { yes?: boolean; dryRun?: boolean } = {}): Promise { + const controller = new AbortController() + const cancel = (): void => controller.abort(new Error('Operator cancelled the PowerShell upgrade')) + process.once('SIGINT', cancel) + try { + const result = await upgradeHostPwsh({ execute: yes, dryRun, signal: controller.signal }) + console.log(JSON.stringify(result, null, 2)) + if (result.status === 'preview') console.log('Pass --yes to run this standard host installation. WinGet retains its own installer and agreement prompts.') + if (result.status === 'cancelled') console.log('Check the standard PowerShell installation before retrying; cancellation may not undo an installer already started.') + return ['preview', 'completed', 'no-update'].includes(result.status) ? 0 : result.status === 'cancelled' ? 130 : 1 + } finally { process.removeListener('SIGINT', cancel) } +} diff --git a/src/cli/report.js b/src/cli/report.js index 83e00dfa..1df994b8 100644 --- a/src/cli/report.js +++ b/src/cli/report.js @@ -197,7 +197,7 @@ export function printLadder(report) { } } - console.log('\nThree layers: repository tools → bash → PowerShell 7') + console.log('\nRouting: repository tools → bash → host PowerShell') console.log(report.policy || '(no file tools resolved)') console.log('\nResolution only: command visibility requires a live host session and sandboxPolicy.') console.log('Resolved candidates only; bash also needs the host subprocess service. Full access runs the executable directly;') diff --git a/src/components/shell/index.js b/src/components/shell/index.js index 3e708b40..bb896784 100644 --- a/src/components/shell/index.js +++ b/src/components/shell/index.js @@ -1,3 +1,3 @@ import { applyComponent } from "../../index.js" -export const name = "dsh-ops-bash & powershell 7" +export const name = "dsh-ops-bash & host PowerShell" export function apply(ctx, config) { return applyComponent(ctx, config, "shell") } diff --git a/src/components/shell/locale/en.json b/src/components/shell/locale/en.json index a2eaf627..f560e1ae 100644 --- a/src/components/shell/locale/en.json +++ b/src/components/shell/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "dsh-ops-bash & powershell 7", - "description": "Prefer bash for general commands and provide PowerShell 7 when Windows-native functionality is needed." + "title": "dsh-ops-bash & host PowerShell", + "description": "Prefer bash for general commands and use host PowerShell for Windows-native functionality." } } diff --git a/src/components/shell/locale/zh.json b/src/components/shell/locale/zh.json index 78403aba..0ac5da62 100644 --- a/src/components/shell/locale/zh.json +++ b/src/components/shell/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { - "title": "dsh-ops-bash & powershell 7", - "description": "将默认终端改为bash,以及bash覆盖不到时提供powershell7" + "title": "dsh-ops-bash & host PowerShell", + "description": "常规命令优先使用 bash,Windows 原生操作使用宿主 PowerShell" } } diff --git a/src/components/shell/package.json b/src/components/shell/package.json index b41c8b25..a6c23ea6 100644 --- a/src/components/shell/package.json +++ b/src/components/shell/package.json @@ -1,6 +1,6 @@ { - "name": "dsh-ops-bash & powershell 7", - "description": "Prefer bash for general commands and provide PowerShell 7 when Windows-native functionality is needed.", + "name": "dsh-ops-bash & host PowerShell", + "description": "Prefer bash for general commands and use host PowerShell for Windows-native functionality.", "icon": "./icon.png", "type": "module" } diff --git a/src/config.js b/src/config.js index 5471b61d..932c8e75 100644 --- a/src/config.js +++ b/src/config.js @@ -183,9 +183,8 @@ export function resolveConfig(rawConfig) { extraGuidance: optionalString(raw, 'extraGuidance') ?? '', bashPath: optionalString(raw, 'bashPath'), publishBashTool: optionalBoolean(raw, 'publishBashTool', true), - // There is deliberately no `pwshPath`: shell mounts a reversible runtime - // overlay from the plugin-owned pwsh resolution, never a stored profile - // edit. Ambient bash discovery requires explicit operator opt-in. + // There is deliberately no `pwshPath`: the host owns its PowerShell + // selection. Ambient bash discovery requires explicit operator opt-in. allowSystemShellFallback: optionalBoolean(raw, 'allowSystemShellFallback', false), // The file tools refuse credential locations by default. An operator who // needs one of them can turn the fence off for this row; the environment diff --git a/src/index.js b/src/index.js index d3c47195..4802a6f5 100644 --- a/src/index.js +++ b/src/index.js @@ -5,7 +5,6 @@ import { SECTION_ORDERS, TOOLING_SECTION, BASH_TOOL, publicToolName, hostShellRe import { NATIVE_FILE_TOOLS as FILE_TOOLS, NATIVE_RESIDENT_FILE_TOOLS } from './capabilities/registry.js' import { resolveShells } from './shells.js' import { mountSessionBash } from './session-shells.js' -import { mountBundledPwsh } from './session-pwsh.js' import { NativeFileTools } from './file/runtime.js' import { resolveWorker } from './ptc/runtime.js' import { mountWorkspacePreparation } from './workspace-preparation.js' @@ -102,20 +101,14 @@ export function applyComponent(root, rawConfig, component) { interpolate: false, text: context => renderToolingPolicy({ published: visibleTools(ctx, context.agent).filter(name => owned.includes(name)), - shellComponent: component === 'shell', pwsh7: shells?.pwsh.available ?? false, + shellComponent: component === 'shell', extraGuidance: config.extraGuidance, }), }), `dsh-ops: ${component} tooling`) }) if (component === 'shell') { - // Two of the three halves describe a Windows host and say so themselves: the ACL workspace - // preparation, and the bundled PowerShell 7 overlay that `pwsh-sandbox` is pointed at. The bash - // surface is the half with a POSIX answer — on Linux it publishes `ops_bash` over the host - // distribution's own bash — so the split is written here rather than left implicit in guards. - if (process.platform === 'win32') { - mountWorkspacePreparation(root) - mountBundledPwsh(root, shells) - } + // Workspace preparation is Windows-specific; the host owns its PowerShell executor. + if (process.platform === 'win32') mountWorkspacePreparation(root) root.inject(['tools'], ctx => { mountSessionBash(ctx, config, shells) if (config.shellPolicy === 'deny-host-shell') installHostShellDenial(ctx, config) diff --git a/src/policy.ts b/src/policy.ts index 17fcd29f..ee469269 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -25,13 +25,12 @@ interface ToolingPolicyOptions { published?: Iterable mounted?: boolean levels?: { files?: boolean, bash?: boolean, pwsh?: boolean } - pwsh7?: boolean shellComponent?: boolean extraGuidance?: string } /** A single compact table; absent tools never get advertised. */ -export function renderToolingPolicy({ published, mounted, levels, pwsh7 = false, shellComponent = true, extraGuidance = '' }: ToolingPolicyOptions) { +export function renderToolingPolicy({ published, mounted, levels, shellComponent = true, extraGuidance = '' }: ToolingPolicyOptions) { const names = new Set(published ?? (mounted || levels?.files ? [...NATIVE_FILE_TOOLS, ...(levels?.bash ? IN_PROCESS_TOOLS : [])].map(publicToolName) : [])) if (levels?.pwsh) names.add('pwsh') @@ -41,7 +40,7 @@ export function renderToolingPolicy({ published, mounted, levels, pwsh7 = false, const hasBash = names.has(BASH_TOOL) const hasPwsh = shellComponent && names.has('pwsh') if (!hasFiles && !hasBash && !hasPwsh && !hasDiscovery) return '' - const heading = hasBash ? '# Shell routing: bash → PowerShell 7' + const heading = hasBash ? '# Shell routing: bash → host PowerShell' : hasFiles || hasDiscovery ? '# Repository file tools' : '# Windows-native shell' const lines = [heading, '', '| Task | Tool |', '| --- | --- |'] const row = (raw: string, task: string) => { @@ -64,7 +63,7 @@ export function renderToolingPolicy({ published, mounted, levels, pwsh7 = false, row('remove', 'Remove one regular file (CAS)') row('result', 'Page / release owned result references') row('bash', 'General commands / builds / git / pipelines / scripts — preferred command executor') - if (names.has('pwsh')) lines.push(`| Windows-native cmdlets / registry / services only | pwsh (${pwsh7 || levels?.pwsh ? 'bundled PowerShell 7 via host' : 'host PowerShell'}) |`) + if (names.has('pwsh')) lines.push(`| Windows-native cmdlets / registry / services only | pwsh (host PowerShell) |`) lines.push('', '- ops_* arguments are strict: pass only declared fields.') if (hasFiles) lines.push('- Use published file tools for file operations.') if (hasDiscovery) lines.push('- Discover a capability when needed; selection makes it available on the next model step and performs no file action.') diff --git a/src/session-pwsh.js b/src/session-pwsh.js deleted file mode 100644 index f6a30f6d..00000000 --- a/src/session-pwsh.js +++ /dev/null @@ -1,63 +0,0 @@ -/** A reversible runtime-only config overlay; never edits profile files or services. */ -export function mountBundledPwsh(ctx, shells) { - // Upstream gated this to win32 as well. The overlay itself is platform - // neutral: it only points the owning `pwsh-sandbox` row at this plugin's copy - // when one is resolvable. Whether that row is mounted at all stays the host's - // decision, so on a host that disables its pwsh rows this is a no-op. - if (!shells.pwsh.available) return - ctx.inject(['loader'], loaderCtx => { - const loader = loaderCtx.get('loader') - const matches = entry => entry?.options?.id === 'pwsh-sandbox' - && entry.options.name === '@deepseek-ai/dsh-pwsh-sandbox' - // The owning executor re-reads `config.pwshPath` (a volatile config - // reference) before every command and re-probes the executable when the - // declared value changed, so the overlay commits the value into that live - // reference — and the unmount restores the owner's latest declared value - // the same way. It must never restart the row: `entry.fiber.update()` on an - // ACTIVE row is a dispose/reload, and restarting the official executor - // swaps the `shell` service provider — every `inject('shell')` consumer is - // rebuilt, which bricks `permission-presets` (its constructor re-pins - // loaded sessions and throws on a session still carrying the `auto` preset) - // and takes the whole tool surface down with it (#29, #104, #129; - // deepseek-harness discussion #9882). - // `Symbol.for('cosmokit.volatile.write')` is the symbol the loader's own - // `updateVolatile()` writes through; the global symbol keeps the commit - // behaviour-identical without a new runtime dependency. - const commit = (entry, value) => { - try { - if (entry.fiber?.state !== 2) return // not active: the config hook covers its next activation - const ref = entry.fiber.config?.pwshPath - const write = Symbol.for('cosmokit.volatile.write') - if (!ref || typeof ref.get !== 'function' || typeof ref[write] !== 'function') return - if (ref.get() !== value) ref[write](value) - } catch (error) { - // One row that refuses the commit must not strand the rows behind it (#30): the loop - // continues, and the refusal is reported rather than swallowed. - loaderCtx.logger?.warn?.(`dsh-ops: pwsh executor row ${entry.options?.id ?? '(unnamed)'} rejected the overlay: ${error?.message ?? error}`) - } - } - const unhook = loaderCtx.on('internal/config', function (_raw, next) { - const config = next() - if (!matches(this.entry) || this.parent?.fiber?.entry === this.entry) return config - return { ...config, pwshPath: shells.pwsh.file } - }, { global: true, prepend: true }) - for (const entry of loader.entries()) { - if (matches(entry)) commit(entry, shells.pwsh.file) - } - let stopped = false - loaderCtx.effect(() => () => { - if (stopped) return - stopped = true - unhook() - // Restore each matched row's own declared value — the latest raw config, - // which is what the owner last chose — again without restarting the row. - // A declared non-string (`!!js` expression node) is left alone: the next - // natural re-resolution evaluates it. - for (const entry of loader.entries()) { - if (!matches(entry)) continue - const declared = entry.options.config?.pwshPath - if (declared === undefined || typeof declared === 'string') commit(entry, declared) - } - }, 'dsh-ops: restore owning PowerShell executor config') - }) -} diff --git a/src/shells/paths.js b/src/shells/paths.js index 393909f4..8d0737c1 100644 --- a/src/shells/paths.js +++ b/src/shells/paths.js @@ -7,13 +7,9 @@ * conventional install locations, and it NEVER mutates ambient state (no PATH * writes, no profile writes, no `process.chdir`). * - * The layout orders here are part of the contract, not an implementation - * detail: the L3 `!!js` override in `cordis.patch.yml` probes the same places - * in the same order (it cannot import this module — a bundle patch is plain - * YAML), so the ladder's `available`/`file` and the host row's executable must - * agree for every layout a deployment can present. - * `test/shells-resolution.test.mjs` asserts that agreement against both - * layouts. + * Legacy PowerShell probes remain available for compatibility and explicit + * provisioning reports. Runtime mounting never activates those copies or + * rewrites the host PowerShell configuration. * * The probes below are exported for their sibling modules under `lib/shells/`; * the barrel `lib/shells.js` re-exports only the public names. @@ -32,9 +28,7 @@ import { } from './pins.js' /** - * Every place this plugin's own PowerShell 7 can come from, most preferred - * first — the order `cordis.patch.yml`'s L3 expression implements, in the same - * order and with the same existence rule. + * Historical PowerShell layout order, retained for compatibility probes only. * @type {readonly ['provisioned', 'package', 'vendor']} */ export const PWSH_LAYOUT_ORDER = Object.freeze(['provisioned', 'package', 'vendor']) @@ -46,7 +40,7 @@ export const PWSH_LAYOUT_ORDER = Object.freeze(['provisioned', 'package', 'vendo * @property {string|undefined} file - the resolved executable, when available. * @property {string} source - where the answer came from, for reports and tests. * One of `disabled`, `config`, `provisioned`, `bundled`, `path`, `well-known`, - * `missing`. + * `missing`, `host`. * @property {string} detail - a one-line English explanation, for reports. */ @@ -317,9 +311,8 @@ export function isWslBashLauncher(file, env, platform) { */ export function wellKnownCandidates({ kind, env, platform }) { if (platform !== 'win32') { - // Only bash has a POSIX rung here: the bundled PowerShell 7 overlay and the - // `pwsh-sandbox` row it configures are Windows facts, and a Linux host's own - // pwsh (when it has one) is the host's business, not this plugin's row. + // Only bash has a POSIX rung here. PowerShell resolution belongs to + // the host on every platform. // `path.posix` is deliberate — see POSIX_BASH_WELL_KNOWN. return kind === 'bash' ? POSIX_BASH_WELL_KNOWN.map(entry => path.posix.normalize(entry)) : [] } diff --git a/src/shells/publish.js b/src/shells/publish.js index 9e6fccc5..08952797 100644 --- a/src/shells/publish.js +++ b/src/shells/publish.js @@ -12,15 +12,8 @@ * is `cwd`/`timeoutMs`/`maxTimeoutMs`/`maxOutputBytes`/`maxSpillBytes`/ * `graceMs` only), so the only zero-internal-dependency way to run the plugin's * own bash is to publish `ops_bash` ourselves and execute through - * `ctx.subprocess`. L3 needs no tool: `pwsh-sandbox` inherits `pwshPath` - * verbatim (`packages/shell/pwsh-sandbox/src/index.ts:40`, - * `type Config = LocalConfig`), so `cordis.patch.yml` points that row at the - * executable `lib/shells/paths.js` resolves — provisioned copy first, bundled - * layouts after it, in the order `lib/shells/paths.js`'s `PWSH_LAYOUT_ORDER` - * names. That is also why L3 has no `available` beyond "the executable is - * there": the rung and the host row are the same fact, and a config key could - * not reach the row anyway (a bundle patch is evaluated before this plugin's own - * row is mounted). + * `ctx.subprocess`. PowerShell stays owned by the host; OPS neither supplies an + * executable nor rewrites the host executor configuration. * * A rung that is unavailable publishes nothing and reports why through the * context logger; it never throws, because a deployment without bash must still @@ -55,8 +48,7 @@ function reportShellProblem(ctx, message) { * Publish the tools that run on the plugin's own shells. * * Today that is exactly one tool — `ops_bash`, bound to the resolved bash — and - * nothing else: pwsh needs no tool, because the host's `pwsh-sandbox` row runs - * the plugin's own executable whenever the bundle override resolved one. A rung + * nothing else: the host owns PowerShell and its executable selection. A rung * that is unavailable publishes nothing and reports why through the context * logger; it never throws, because a deployment without bash must still load * this plugin. diff --git a/src/shells/pwsh-upgrade.ts b/src/shells/pwsh-upgrade.ts new file mode 100644 index 00000000..c19db899 --- /dev/null +++ b/src/shells/pwsh-upgrade.ts @@ -0,0 +1,72 @@ +/** Operator action for the standard host PowerShell install; never an automatic plugin activation. */ +import fs from 'node:fs' +import path from 'node:path' +import { spawn } from 'node:child_process' +import { errorMessage } from './types.js' + +export const HOST_PWSH_UPGRADE_GUIDANCE = 'Installs or upgrades the standard PowerShell 7 installation on Windows through WinGet. Restart DSH afterwards. An explicit host pwshPath is preserved and must be reviewed in host settings if it points elsewhere. Existing Windows PowerShell 5.1 and OPS provisioned files are not removed.' +export interface PwshUpgradePlan { + supported: boolean + file?: string + args: readonly string[] + guidance: string + unavailable?: string +} +export interface UpgradeProcessResult { started: boolean; exitCode: number | null; signal: string | null; error?: string } +export type UpgradeRunner = (plan: PwshUpgradePlan & { file: string }, signal?: AbortSignal) => Promise +export interface PwshUpgradeResult { + status: 'preview' | 'unavailable' | 'completed' | 'no-update' | 'failed' | 'cancelled' + effectState: 'none' | 'applied' | 'unknown' + plan: PwshUpgradePlan + exitCode?: number | null + signal?: string | null + error?: string +} +const usable = (file: string): boolean => { + try { const stat = fs.lstatSync(file); return stat.isFile() || stat.isSymbolicLink() } catch { return false } +} + +/** Use an absolute executable, never a command shell or a relative PATH entry. */ +export function hostPwshUpgradePlan({ platform = process.platform, env = process.env }: { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv } = {}): PwshUpgradePlan { + // Microsoft Learn recommends this exact stable package; install also upgrades an existing install. + const args = ['install', '--id', 'Microsoft.PowerShell', '--exact', '--source', 'winget', '--installer-type', 'wix'] + const base = { supported: false, args, guidance: HOST_PWSH_UPGRADE_GUIDANCE } + if (platform !== 'win32') return { ...base, unavailable: 'This action is Windows-only. Manage host PowerShell through your operating system package manager.' } + const candidates = [ + ...(env.LOCALAPPDATA && path.isAbsolute(env.LOCALAPPDATA) ? [path.join(env.LOCALAPPDATA, 'Microsoft/WindowsApps/winget.exe')] : []), + ...(env.PATH ?? env.Path ?? '').split(';').map(entry => entry.trim().replace(/^"|"$/g, '')) + .filter(entry => path.isAbsolute(entry)).map(entry => path.join(entry, 'winget.exe')), + ] + const file = candidates.find(usable) + return file ? { ...base, supported: true, file: path.resolve(file) } + : { ...base, unavailable: 'WinGet is unavailable. Install or repair Microsoft App Installer, or use the official PowerShell installer; no installation was attempted.' } +} + +/** CLI runner. A host UI may supply its subprocess runner and expose the installer interaction there. */ +export const runUpgradeProcess: UpgradeRunner = (plan, signal) => new Promise(resolve => { + let started = false, failure: string | undefined + const child = spawn(plan.file, [...plan.args], { stdio: 'inherit', windowsHide: true, shell: false, signal }) + child.once('spawn', () => { started = true }) + child.once('error', error => { failure = errorMessage(error) }) + child.once('close', (exitCode, closedSignal) => resolve({ started, exitCode, signal: closedSignal, ...(failure ? { error: failure } : {}) })) +}) + +/** Cancellation stops the runner; an already-started installer may have effects and is never called rolled back. */ +export async function upgradeHostPwsh({ execute = false, dryRun = false, signal, runner = runUpgradeProcess, + platform, env }: { execute?: boolean; dryRun?: boolean; signal?: AbortSignal; runner?: UpgradeRunner; platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv } = {}): Promise { + const plan = hostPwshUpgradePlan({ platform, env }) + if (signal?.aborted) return { status: 'cancelled', effectState: 'none', plan } + if (!plan.supported || !plan.file) return { status: 'unavailable', effectState: 'none', plan } + if (!execute || dryRun) return { status: 'preview', effectState: 'none', plan } + let result: UpgradeProcessResult + try { result = await runner({ ...plan, file: plan.file }, signal) } + catch (error) { return { status: signal?.aborted ? 'cancelled' : 'failed', effectState: 'unknown', error: errorMessage(error), plan } } + const facts = { plan, exitCode: result.exitCode, signal: result.signal, ...(result.error ? { error: result.error } : {}) } + if (signal?.aborted || result.signal) return { ...facts, status: 'cancelled', effectState: result.started ? 'unknown' : 'none' } + if (result.error || !result.started) return { ...facts, status: 'failed', effectState: result.started ? 'unknown' : 'none' } + if (result.exitCode === 0) return { ...facts, status: 'completed', effectState: 'applied' } + // APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE, from Microsoft's WinGet HRESULT definitions. + // This says no applicable update, not that a custom pwshPath or every installed copy is current. + if (result.exitCode !== null && (result.exitCode >>> 0) === 0x8a15002b) return { ...facts, status: 'no-update', effectState: 'none' } + return { ...facts, status: 'failed', effectState: 'unknown' } +} diff --git a/src/shells/resolve.js b/src/shells/resolve.js index fdcc810f..606008ea 100644 --- a/src/shells/resolve.js +++ b/src/shells/resolve.js @@ -1,5 +1,5 @@ /** - * Resolution of the plugin's two shell rungs: its own bash and PowerShell 7. + * Resolution of the plugin's Bash runtime; PowerShell stays owned by the host. * * Together with the probes in `lib/shells/paths.js`, this module is the ONLY * place that answers "can this deployment run the plugin's bash / pwsh right @@ -142,52 +142,10 @@ function resolveBash({ } } -/** - * Resolve the PowerShell 7 rung. - * - * L3 is "the plugin's own pwsh 7", and whether that rung runs is decided by the - * executable it needs actually being there: `cordis.patch.yml` points the - * host's `pwsh-sandbox` row at this same path whenever it exists, so - * `available` here and the host row's behaviour are one fact, not two. There is - * deliberately no configured-path form: the row the plugin would have to - * rewrite is resolved before any dsh-ops config is read. - * @param {object} options - resolution inputs. - * @param {{file: string, version: string}|undefined} options.provisioned - the - * copy `provision-shells` installed, when the store has one. - * @param {string|undefined} options.bundled - the plugin's own pwsh, when it has one. - * @param {string|undefined} options.bundledDetail - why the plugin's own copy is unusable, when it has none. - * @param {boolean} options.allowSystemFallback - whether the host's pwsh may serve as the last rung. - * @returns {import('./paths.js').ShellResolution} the resolution. - */ -function resolvePwsh({ provisioned, bundled, bundledDetail, allowSystemFallback }) { - if (provisioned !== undefined) { - return { - available: true, - file: provisioned.file, - source: 'provisioned', - detail: 'the PowerShell 7 this deployment provisioned from upstream ' - + `(version ${provisioned.version}), which the pwsh-sandbox override points at`, - } - } - if (bundled !== undefined) { - return { - available: true, - file: bundled, - source: 'bundled', - detail: "the plugin's own PowerShell 7, which the pwsh-sandbox override points at", - } - } - return { - available: false, - file: undefined, - source: 'missing', - detail: 'the plugin carries no PowerShell 7 for this platform' - + (bundledDetail === undefined ? '' : ` (${bundledDetail})`) - + '; run `dsh-ops provision-shells --pwsh` to install the pinned upstream copy' - + (allowSystemFallback - ? "; the host's own pwsh tool is the ladder's last rung" - : '; allowSystemShellFallback is false, so the ladder has no PowerShell rung'), - } +/** The host owns PowerShell selection. Legacy OPS copies are never automatically activated. */ +function resolvePwsh() { + return { available: false, file: undefined, source: 'host', + detail: 'PowerShell is managed by the host; OPS does not supply or override its executable. Use dsh-ops upgrade-host-pwsh on Windows, then restart DSH. An explicit host pwshPath remains authoritative.' } } /** @@ -215,15 +173,7 @@ export function resolveShells(config, options = {}) { // and for `dsh-ops ladder`. const posix = platform !== 'win32' const probe = { bundleRoot, env, platform, arch } - const pwshBundled = bundledShell({ kind: 'pwsh', ...probe }) - const pwshProvisioned = provisionedShell({ kind: 'pwsh', env, platform, arch }) - - const pwsh = resolvePwsh({ - provisioned: pwshProvisioned, - bundled: pwshBundled.file, - bundledDetail: pwshBundled.detail, - allowSystemFallback, - }) + const pwsh = resolvePwsh() if (config?.publishBashTool === false) { return { diff --git a/test/bin.test.mjs b/test/bin.test.mjs index 5b56703a..640be70a 100644 --- a/test/bin.test.mjs +++ b/test/bin.test.mjs @@ -654,8 +654,8 @@ await test('provision-shells installs a pinned zip, prunes the old version, and }) }) -await test('ladder reports the provisioned pwsh rung the store feeds', async () => { - if (skipUnpinnedHost('ladder reports the provisioned pwsh rung the store feeds')) return +await test('ladder leaves PowerShell resolution to the host and preserves legacy copies', async () => { + if (skipUnpinnedHost('ladder leaves PowerShell resolution to the host and preserves legacy copies')) return await withTempDir('provision-ladder', (dir) => { const home = path.join(dir, 'home') const user = path.join(dir, 'user') @@ -670,14 +670,12 @@ await test('ladder reports the provisioned pwsh rung the store feeds', async () }) assert.equal(run.status, 0, run.stderr) const parsed = JSON.parse(run.stdout) - assert.deepEqual(parsed.shells.pwsh, { - available: true, - executable, - source: 'provisioned', - detail: 'the PowerShell 7 this deployment provisioned from upstream (version 7.6.6), ' - + 'which the pwsh-sandbox override points at', - }) - assert.equal(parsed.levels.pwsh, true) + assert.equal(parsed.shells.pwsh.available, false) + assert.equal(parsed.shells.pwsh.source, 'host') + assert.equal(parsed.shells.pwsh.executable, null) + assert.match(parsed.shells.pwsh.detail, /managed by the host/) + assert.equal(fs.existsSync(executable), true) + assert.equal(parsed.levels.pwsh, false) }) }) diff --git a/test/hardening.test.mjs b/test/hardening.test.mjs index 194721ce..6f530c2b 100644 --- a/test/hardening.test.mjs +++ b/test/hardening.test.mjs @@ -20,7 +20,6 @@ import fs from 'node:fs' import path from 'node:path' import { resolveWorker, workerName } from '../lib/ptc/runtime.js' -import { mountBundledPwsh } from '../lib/session-pwsh.js' import { assert, report, test, withTempDir } from './lib/harness.mjs' function stageWorker(root) { @@ -65,23 +64,4 @@ await test('a malformed provenance receipt is a parse failure, not a leaked raw }) }) -await test('one pwsh row that refuses the overlay does not strand the rows behind it', () => { - const warnings = [] - const refusingRef = { get: () => 'original', [Symbol.for('cosmokit.volatile.write')]() { throw new Error('row refuses') } } - const following = { current: 'original' } - const followingRef = { get: () => following.current, [Symbol.for('cosmokit.volatile.write')](next) { following.current = next } } - const row = (ref) => ({ options: { id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox', config: { pwshPath: 'original', retained: true } }, - fiber: { state: 2, config: { pwshPath: ref } } }) - const fixture = { inject(_deps, body) { body({ - get: () => ({ entries: () => [row(refusingRef), row(followingRef)] }), - on: () => () => {}, - effect: (callback) => { callback() }, - logger: { warn: (message) => warnings.push(message) }, - }) } } - mountBundledPwsh(fixture, { pwsh: { available: true, file: 'C:/pwsh.exe' } }) - assert.equal(following.current, 'C:/pwsh.exe') - assert.equal(warnings.length, 1, 'the refusal is reported once') - assert.match(warnings[0], /rejected the overlay: row refuses/) -}) - report('hardening') diff --git a/test/policy.test.mjs b/test/policy.test.mjs index bdc46c2e..de8c36c6 100644 --- a/test/policy.test.mjs +++ b/test/policy.test.mjs @@ -52,10 +52,11 @@ await test('resident-only routing omits unavailable operation hints and includes assert.match(discovery, /next model step/) for (const name of files) assert.equal(discovery.includes(name), false) }) -await test('shell routing prefers bash and names bundled pwsh only when available', () => { - const text = renderToolingPolicy({ published: [BASH_TOOL, 'pwsh'], pwsh7: true }) - assert.match(text, /^# Shell routing: bash → PowerShell 7/) - assert.match(text, /bundled PowerShell 7 via host/) +await test('shell routing prefers bash and leaves PowerShell ownership with the host', () => { + const text = renderToolingPolicy({ published: [BASH_TOOL, 'pwsh'] }) + assert.match(text, /^# Shell routing: bash → host PowerShell/) + assert.match(text, /pwsh \(host PowerShell\)/) + assert.equal(text.includes('bundled'), false) assert.match(text, /fix bash errors in bash, do not switch shells/) assert.match(text, /longer than about 1 KB.*script_path/) assert.equal(renderToolingPolicy({ published: ['pwsh'] }).includes('bundled PowerShell'), false) diff --git a/test/pwsh-upgrade.test.mjs b/test/pwsh-upgrade.test.mjs new file mode 100644 index 00000000..db0d47f0 --- /dev/null +++ b/test/pwsh-upgrade.test.mjs @@ -0,0 +1,153 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { hostPwshUpgradePlan, upgradeHostPwsh, runUpgradeProcess } from '../lib/shells/pwsh-upgrade.js' +import { applyComponent } from '../lib/index.js' +import { resolveShells } from '../lib/shells.js' +import { describePackages } from '../scripts/lib/build/assemble.js' +import { writeReleaseRecords } from '../scripts/lib/build/pack.js' +import { stepCheck } from '../scripts/lib/build/check.js' +import { injectedOptionalDependencies } from '../scripts/lib/build/publish-dir.js' +import { assert, report, test, withTempDir, TEST_HOME, PACKAGE_ROOT } from './lib/harness.mjs' +import { bootHost, shutdown } from './lib/host.mjs' + +const ARGS = ['install', '--id', 'Microsoft.PowerShell', '--exact', '--source', 'winget', '--installer-type', 'wix'] +function fixture(root) { + const file = path.join(root, 'Microsoft/WindowsApps/winget.exe') + fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, 'never execute this fixture') + return { file, platform: 'win32', env: { LOCALAPPDATA: root, PATH: '' } } +} +const success = { started: true, exitCode: 0, signal: null } + +await test('the upgrade plan uses the standard exact stable package and an absolute WinGet path', async () => { + await withTempDir('upgrade-plan', root => { + const input = fixture(root), plan = hostPwshUpgradePlan(input) + assert.equal(plan.supported, true); assert.equal(plan.file, input.file); assert.deepEqual(plan.args, ARGS) + assert.match(plan.guidance, /Restart DSH/); assert.match(plan.guidance, /explicit host pwshPath is preserved/) + assert.equal(hostPwshUpgradePlan({ platform: 'linux', env: input.env }).supported, false) + assert.match(hostPwshUpgradePlan({ platform: 'win32', env: {} }).unavailable, /WinGet is unavailable/) + assert.equal(hostPwshUpgradePlan({ platform: 'win32', env: { PATH: 'relative;.', LOCALAPPDATA: '.' } }).supported, false) + assert.equal(hostPwshUpgradePlan({ platform: 'win32', env: { PATH: `"${path.dirname(input.file)}"` } }).file, input.file) + }) +}) +await test('default preview, dry-run and unavailable plans never start an installer', async () => { + await withTempDir('upgrade-preview', async root => { + const input = fixture(root) + const runner = () => { throw new Error('preview must not execute') } + for (const options of [{}, { execute: true, dryRun: true }]) { + const result = await upgradeHostPwsh({ ...input, ...options, runner }) + assert.equal(result.status, 'preview'); assert.equal(result.effectState, 'none') + } + const unavailable = await upgradeHostPwsh({ platform: 'win32', env: {}, execute: true, runner }) + assert.equal(unavailable.status, 'unavailable'); assert.equal(unavailable.effectState, 'none') + }) +}) +await test('explicit execution preserves exact argv and classifies every installer exit honestly', async () => { + await withTempDir('upgrade-exit', async root => { + const input = fixture(root) + const outcomes = [ + [success, 'completed', 'applied'], + [{ ...success, exitCode: 0x8a15002b }, 'no-update', 'none'], + [{ ...success, exitCode: 0x8a15002b | 0 }, 'no-update', 'none'], + [{ ...success, exitCode: 42 }, 'failed', 'unknown'], + [{ ...success, exitCode: null }, 'failed', 'unknown'], + [{ ...success, error: 'installer error' }, 'failed', 'unknown'], + [{ started: false, exitCode: null, signal: null, error: 'missing executable' }, 'failed', 'none'], + [{ ...success, signal: 'SIGTERM' }, 'cancelled', 'unknown'], + ] + for (const [outcome, status, effect] of outcomes) { + const result = await upgradeHostPwsh({ ...input, execute: true, runner: async plan => { + assert.equal(plan.file, input.file); assert.deepEqual(plan.args, ARGS); return outcome + } }) + assert.equal(result.status, status); assert.equal(result.effectState, effect) + assert.equal(result.exitCode, outcome.exitCode) + } + const thrown = await upgradeHostPwsh({ ...input, execute: true, runner: async () => { throw 'runner failed' } }) + assert.equal(thrown.status, 'failed'); assert.equal(thrown.effectState, 'unknown'); assert.equal(thrown.error, 'runner failed') + }) +}) +await test('cancellation before launch has no effects while cancellation after launch stays unknown', async () => { + await withTempDir('upgrade-cancel', async root => { + const input = fixture(root), before = new AbortController(), during = new AbortController() + before.abort() + const cancelled = await upgradeHostPwsh({ ...input, execute: true, signal: before.signal, + runner: () => { throw new Error('pre-cancelled work must not launch') } }) + assert.equal(cancelled.status, 'cancelled'); assert.equal(cancelled.effectState, 'none') + const active = await upgradeHostPwsh({ ...input, execute: true, signal: during.signal, runner: async (_plan, signal) => { + assert.equal(signal, during.signal); during.abort(); return success + } }) + assert.equal(active.status, 'cancelled'); assert.equal(active.effectState, 'unknown') + }) +}) +await test('the CLI subprocess runner reports real nonzero exits and spawn failures without invoking a shell', async () => { + const base = { supported: true, guidance: 'fixture' } + const child = await runUpgradeProcess({ ...base, file: process.execPath, args: ['-e', 'process.exit(7)'] }) + assert.equal(child.started, true); assert.equal(child.exitCode, 7); assert.equal(child.signal, null) + const missing = await runUpgradeProcess({ ...base, file: path.join(TEST_HOME, 'no-such-installer.exe'), args: [] }) + assert.equal(missing.started, false); assert.match(missing.error, /ENOENT/) + const controller = new AbortController(); controller.abort() + const aborted = await runUpgradeProcess({ ...base, file: process.execPath, args: ['-e', 'setInterval(() => {}, 1000)'] }, controller.signal) + assert.match(aborted.error, /abort/i) +}) +await test('CLI preview and --yes --dry-run leave the fake installer untouched', async () => { + await withTempDir('upgrade-cli', root => { + const input = fixture(root) + for (const args of [[], ['--yes', '--dry-run']]) { + const child = spawnSync(process.execPath, [path.join(PACKAGE_ROOT, 'bin/dsh-ops.mjs'), 'upgrade-host-pwsh', ...args], { + cwd: PACKAGE_ROOT, encoding: 'utf8', env: { ...process.env, LOCALAPPDATA: root, PATH: '' }, + }) + assert.equal(child.status, process.platform === 'win32' ? 0 : 1, child.stderr) + assert.match(child.stdout, process.platform === 'win32' ? /"status": "preview"/ : /"status": "unavailable"/) + assert.equal(fs.readFileSync(input.file, 'utf8'), 'never execute this fixture') + } + }) +}) +await test('shell mount preserves live host configuration and legacy provisioned PowerShell files', async () => { + const legacy = path.join(TEST_HOME, 'dsh-ops/shells/pwsh/99.0/bin/pwsh.exe') + fs.mkdirSync(path.dirname(legacy), { recursive: true }); fs.writeFileSync(legacy, 'legacy unchanged') + const declared = path.join(TEST_HOME, 'custom-host/pwsh.exe') + let current = declared, writes = 0 + const entry = { options: { id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox', config: { pwshPath: declared } }, + fiber: { state: 2, config: { pwshPath: { get: () => current, + [Symbol.for('cosmokit.volatile.write')](value) { writes++; current = value } } } } } + const { ctx } = await bootHost() + try { + await ctx.plugin(inner => inner.provide('loader', { entries: () => [entry] })) + const fiber = await ctx.plugin({ name: 'host-pwsh-preservation', apply(inner) { + applyComponent(inner, { publishBashTool: false, configUpdateVersion: 2 }, 'shell') + } }) + assert.equal(resolveShells({}).pwsh.available, false) + assert.equal(current, declared); assert.equal(writes, 0) + await fiber.dispose() + assert.equal(current, declared); assert.equal(writes, 0) + assert.equal(fs.readFileSync(legacy, 'utf8'), 'legacy unchanged') + } finally { await shutdown(ctx) } +}) +await test('new release plans and the install lock carry no Bash or PowerShell payload packages', () => { + assert.deepEqual(describePackages('win32', 'x64', TEST_HOME), []) + assert.deepEqual(injectedOptionalDependencies({ packages: [] }), {}) + for (const name of ['package.json', 'package-lock.json']) { + const contents = fs.readFileSync(path.join(PACKAGE_ROOT, name), 'utf8') + assert.doesNotMatch(contents, /@dsh-ops\/(?:pwsh|bash)-win32-x64/) + } +}) +await test('main-only release records validate without shell payloads and reject stale shell archives', async () => { + await withTempDir('release-main-only', async dist => { + const manifest = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')) + const directory = path.join(dist, 'publish', manifest.name + '-' + manifest.version) + fs.mkdirSync(directory, { recursive: true }) + delete manifest.private + fs.writeFileSync(path.join(directory, 'package.json'), JSON.stringify(manifest)) + const tarball = manifest.name + '-' + manifest.version + '.tgz' + fs.writeFileSync(path.join(dist, tarball), 'fixture archive bytes checked by SHA-256') + const context = { dist, packages: describePackages('win32', 'x64', dist), tarballs: {} } + await writeReleaseRecords(context, []) + const record = JSON.parse(fs.readFileSync(path.join(dist, 'provenance.json'), 'utf8')) + assert.deepEqual(record.packages, []); assert.deepEqual(record.tarballs, [tarball]) + assert.equal(await stepCheck(context), 0) + fs.writeFileSync(path.join(dist, 'old-pwsh.tgz'), 'stale shell archive') + await writeReleaseRecords(context, []) + assert.ok(await stepCheck(context) > 0, 'obsolete shell artifacts must not pass a new release') + }) +}) +report('pwsh-upgrade') diff --git a/test/release-scope.test.mjs b/test/release-scope.test.mjs index e44d6ec2..a4ca226f 100644 --- a/test/release-scope.test.mjs +++ b/test/release-scope.test.mjs @@ -71,4 +71,15 @@ await test('manifest exact targets, npm platform filters and shell pins must agr manifest.contributes['x-components'].find(c=>c.id==='dsh-ops-ptc').targets.push('darwin-x64') fs.writeFileSync(file,JSON.stringify(manifest)); assert.match(run(root).out,/disagree/) })) +await test('release closure refuses either shell payload as a direct or optional dependency', async () => withTempDir('release-shell-dep', root => { + fixture(root) + const file = path.join(root, 'package.json'), pkg = JSON.parse(fs.readFileSync(file, 'utf8')) + for (const group of ['dependencies', 'optionalDependencies']) for (const kind of ['bash', 'pwsh']) { + const changed = { ...pkg, [group]: { ...pkg[group], ['@dsh-ops/' + kind + '-win32-x64']: '0.2.1' } } + fs.writeFileSync(file, JSON.stringify(changed)) + const result = run(root) + assert.notEqual(result.status, 0) + assert.match(result.out, /must not be a package dependency/) + } +})) report('release-scope') diff --git a/test/shells-patch.test.mjs b/test/shells-patch.test.mjs index 2c285d19..92c9a328 100644 --- a/test/shells-patch.test.mjs +++ b/test/shells-patch.test.mjs @@ -1,95 +1,14 @@ -/** - * The L3 patch override in `cordis.patch.yml`: the bundle patch itself, the - * runtime pwsh overlay, and the agreement between the overlay and the plugin's - * own probe. - * - * The patch text is read here through the loader's own `!!js` tag, and the - * overlay is exercised against a linked-profile fixture: the one real parser - * involved is the bundle patch, which no other suite reads through the plugin's - * own eyes. - */ - -import * as urlHelpers from 'node:url' +/** Data-only shell bundle defaults and platform environment contracts. */ import fs from 'node:fs' import path from 'node:path' import { spawnSync } from 'node:child_process' -import { mountBundledPwsh } from '../lib/session-pwsh.js' import { parseDocument } from 'yaml' import { resolveConfig } from '../lib/config.js' -import { - BASH_TOOL_NAME, - ENV_OVERRIDES, - shellEnvOverrides, - PWSH_LAYOUT_ORDER, - bundledShellPath, - provisionedShellVersionDir, - resolveShells, - shellExecutable, -} from '../lib/shells.js' +import { BASH_TOOL_NAME, ENV_OVERRIDES, shellEnvOverrides, resolveShells } from '../lib/shells.js' import { assert, PACKAGE_ROOT, TEST_HOME, report, test, withTempDir } from './lib/harness.mjs' -import { config, makeExecutable, platformPackageName } from './lib/shells-fixtures.mjs' - -/** The bundle patch's raw text, read once. */ +import { config, makeExecutable } from './lib/shells-fixtures.mjs' const PATCH_TEXT = fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'), 'utf8') - -/** - * Teach a general-purpose YAML parser the loader's `!!js` tag, so a tagged - * scalar arrives as its expression text instead of an unknown-tag warning. - * @returns {object} a schema that resolves `!!js` scalars. - */ -function entryListSchema() { - const probe = parseDocument(PATCH_TEXT) - probe.schema.tags.push({ - tag: 'tag:yaml.org,2002:js', - resolve: (data) => data, - construct: (data) => ({ __jsExpr: data }), - identify: (value) => typeof value === 'object' && value !== null && '__jsExpr' in value, - }) - return probe.schema -} - -/** - * The bundle patch as the loader reads it: `!!js` scalars stay expression text - * (the loader wraps them into expression nodes one step later). - * @returns {object[]} the patch entries. - */ -function patchEntries() { - return parseDocument(PATCH_TEXT, { schema: entryListSchema() }).toJS() -} - -/** - * The L3 override expression exactly as written in the patch. - * @returns {string} the expression body. - */ -function pwshOverrideExpression() { - return 'runtime-overlay' -} - -function volatileRef(initial) { - let current = initial - return { get: () => current, [Symbol.for('cosmokit.volatile.write')](next) { current = next } } -} - -/** - * Evaluate one loader expression exactly as the vendored loader does. - * @param {string} expr - the expression body. - * @param {object} ctx - the evaluation scope (`with (ctx)`). - * @returns {unknown} the expression's value. - */ -function evaluateLoaderExpression(expr, ctx) { - // Exercise the real runtime overlay against a loader fixture instead of the - // removed YAML expression. The resolver and the component share this input. - if (expr !== 'runtime-overlay') return undefined - if (!ctx.baseUrl) return undefined - const { fileURLToPath } = urlHelpers - const bundleRoot = fs.realpathSync(path.join(fileURLToPath(ctx.baseUrl), 'node_modules', 'dsh-ops')) - const shells = resolveShells(config(), { bundleRoot }) - const entry = { options: { id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox', config: {} }, - fiber: { state: 2, config: { pwshPath: volatileRef(undefined) } } } - mountBundledPwsh({ inject(_deps, body) { body({ get() { return { entries: () => [entry] } }, - on() { return () => {} }, effect() {} }) } }, shells) - return entry.fiber.config.pwshPath.get() -} +const patchEntries = () => parseDocument(PATCH_TEXT).toJS() await test('the manifest declares the plugin\'s own shell tools as their own namespace', () => { const manifest = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'dsh-plugin.json'), 'utf8')) @@ -131,195 +50,6 @@ await test('the shipped dsh-ops row states the documented shell defaults', () => assert.equal(resolved.allowSystemShellFallback, false) }) -await test('the L3 override is inert while the plugin carries no pwsh', async () => { - await withTempDir('shells-patch-inert', async (dir) => { - // A profile that resolves the plugin perfectly well — the plugin simply - // carries no pwsh yet, which is the documented state until phase C. - const { bundleRoot, profileUrl } = stageLinkedProfile(dir) - fs.writeFileSync(path.join(bundleRoot, 'package.json'), JSON.stringify({ - name: 'dsh-ops', version: '0.1.0', - optionalDependencies: { [platformPackageName('pwsh')]: '0.1.0' }, - })) - const expr = pwshOverrideExpression() - - // No pwsh in the plugin: the expression yields `undefined`, which is the - // row's own default, so the host keeps its own PowerShell resolution. - assert.equal(evaluateLoaderExpression(expr, { baseUrl: profileUrl, ctx: {} }), undefined) - // An unresolvable base URL must degrade to undefined rather than throw. - assert.equal(evaluateLoaderExpression(expr, { baseUrl: undefined, ctx: {} }), undefined) - }) -}) - -await test('the L3 override follows the plugin\'s own pwsh on every platform', async () => { - await withTempDir('shells-patch-platform', async (dir) => { - const { bundleRoot, profileUrl } = stageLinkedProfile(dir) - const bundled = makeExecutable(bundledShellPath({ - kind: 'pwsh', bundleRoot, platform: process.platform, arch: process.arch, - })) - fs.writeFileSync(path.join(bundleRoot, 'package.json'), JSON.stringify({ name: 'dsh-ops', version: '0.1.0' })) - const value = evaluateLoaderExpression(pwshOverrideExpression(), { baseUrl: profileUrl, ctx: {} }) - // The plugin carries the executable and the overlay points the owning row at - // exactly that file. There is no platform gate left: the overlay is gated by - // `shells.pwsh.available` alone. Whether the owning `pwsh-sandbox` row is - // mounted stays the host's decision — `cordis.patch.yml` ships no persistent - // pwsh override — so on a host that disables its pwsh rows this stays a no-op. - assert.equal(value, bundled) - }) -}) - -await test('L3 reports unavailable without a bundled pwsh, and says what the last rung is', async () => { - await withTempDir('shells-pwsh-absent', async (dir) => { - const fallback = resolveShells(config({ allowSystemShellFallback: true }), { - bundleRoot: path.join(dir, 'plugin'), - env: { DSH_HOME: TEST_HOME, PATH: '' }, - platform: 'linux', - arch: 'x64', - }) - assert.equal(fallback.pwsh.available, false) - assert.equal(fallback.pwsh.source, 'missing') - assert.equal(fallback.pwsh.file, undefined) - // The host's own pwsh tool is the ladder's last rung, and the detail says so. - assert.match(fallback.pwsh.detail, /host's own pwsh tool is the ladder's last rung/) - - // Confining the ladder to plugin-provided shells changes the wording, not - // the fact: L3 is still unavailable. - const confined = resolveShells(config({ allowSystemShellFallback: false }), { - bundleRoot: path.join(dir, 'plugin'), - env: { DSH_HOME: TEST_HOME, PATH: '' }, - platform: 'linux', - arch: 'x64', - }) - assert.equal(confined.pwsh.available, false) - assert.match(confined.pwsh.detail, /no PowerShell rung/) - }) -}) - -/** - * Stage one profile whose `dsh-ops` is installed as a junction, exactly as a - * `link:` install looks: `dir/` is the profile directory (it holds - * `cordis.yml`, so `ctx.baseUrl` is `dir/`), `dir/node_modules/dsh-ops` links to - * the bundle at `dir/dsh-ops`, and any platform package sits beside it in - * `dir/node_modules/`. That shared `node_modules` is the point: the patch - * expression reaches it through the profile's `baseUrl`, and `resolveShells` - * reaches the same directory by walking up from its `bundleRoot`. - * @param {string} dir - the temporary root, used as the profile directory. - * @returns {{bundleRoot: string, profileUrl: string}} the tree. - */ -function stageLinkedProfile(dir) { - const bundleRoot = path.join(dir, 'dsh-ops') - fs.mkdirSync(bundleRoot, { recursive: true }) - fs.mkdirSync(path.join(dir, 'node_modules'), { recursive: true }) - fs.symlinkSync(bundleRoot, path.join(dir, 'node_modules', 'dsh-ops'), 'junction') - return { - bundleRoot, - profileUrl: new URL('.', `file:///${dir.replace(/\\/g, '/')}/`).href, - } -} - -await test('the L3 override and the plugin probe agree on all three places, in the same order', async () => { - const expr = pwshOverrideExpression() - const platformPackage = platformPackageName('pwsh') - - /** - * Build a linked profile whose pwsh sits in the named places, then report what - * each side of the contract says. - * - * The platform package is staged beside the linked bundle (in the same - * `node_modules`), so `createRequire` from either side resolves the identical - * directory — the patch expression through the profile's `baseUrl`, the probe - * through its own `bundleRoot`. The provisioned store is the one input the - * expression reads from `process.env` rather than from `baseUrl`, so `DSH_HOME` - * is set for the evaluation and restored afterwards. - * @param {string} dir - the temporary root. - * @param {('provisioned'|'package'|'vendor')[]} places - which places carry the pwsh. - * @returns {{made: Record, expression: unknown, probe: object}} both answers. - */ - const build = (dir, places) => { - const { bundleRoot, profileUrl } = stageLinkedProfile(dir) - fs.writeFileSync(path.join(bundleRoot, 'package.json'), JSON.stringify({ - name: 'dsh-ops', - version: '0.1.0', - optionalDependencies: { [platformPackage]: '0.1.0' }, - })) - const home = path.join(dir, 'home') - /** @type {Record} */ - const made = {} - if (places.includes('provisioned')) { - // What `dsh-ops provision-shells` installs: the pinned upstream release, - // at the layout the pin for this platform names. - made.provisioned = makeExecutable(path.join( - provisionedShellVersionDir({ kind: 'pwsh', version: '7.6.6', env: { DSH_HOME: home } }), - 'bin', shellExecutable('pwsh', process.platform), - )) - } - if (places.includes('package')) { - const packageDir = path.join(dir, 'node_modules', ...platformPackage.split('/')) - fs.mkdirSync(packageDir, { recursive: true }) - fs.writeFileSync(path.join(packageDir, 'package.json'), JSON.stringify({ - name: platformPackage, version: '0.1.0', - })) - made.package = makeExecutable(path.join(packageDir, 'bin', shellExecutable('pwsh', process.platform))) - } - if (places.includes('vendor')) { - made.vendor = makeExecutable(bundledShellPath({ - kind: 'pwsh', bundleRoot, platform: process.platform, arch: process.arch, - })) - } - - const previous = process.env.DSH_HOME - process.env.DSH_HOME = home - try { - return { - made, - expression: evaluateLoaderExpression(expr, { baseUrl: profileUrl, ctx: {} }), - probe: resolveShells(config(), { - bundleRoot, - env: { DSH_HOME: home, PATH: '' }, - platform: process.platform, - }), - } - } finally { - if (previous === undefined) delete process.env.DSH_HOME - else process.env.DSH_HOME = previous - } - } - - // Each place ALONE: the one place present is the one both sides answer with. - for (const place of PWSH_LAYOUT_ORDER) { - await withTempDir(`shells-place-${place}`, async (dir) => { - const { made, expression, probe } = build(dir, [place]) - assert.equal(probe.pwsh.available, true, probe.pwsh.detail) - assert.equal(probe.pwsh.source, place === 'provisioned' ? 'provisioned' : 'bundled', probe.pwsh.detail) - assert.equal(probe.pwsh.file, made[place]) - // Both sides of the L3 contract resolve the same file. The overlay has no - // platform gate, so this holds everywhere. - assert.equal(expression, made[place]) - }) - } - - // Every place at once: the ORDER itself is pinned, on both sides. - await withTempDir('shells-place-all', async (dir) => { - const { made, expression, probe } = build(dir, ['vendor', 'package', 'provisioned']) - assert.notEqual(made.provisioned, made.package) - assert.notEqual(made.package, made.vendor) - assert.equal(probe.pwsh.source, 'provisioned', probe.pwsh.detail) - assert.equal(probe.pwsh.file, made.provisioned) - assert.equal(expression, made.provisioned) - }) - - await withTempDir('shells-place-bundled', async (dir) => { - const { made, expression, probe } = build(dir, ['vendor', 'package']) - assert.equal(probe.pwsh.source, 'bundled', probe.pwsh.detail) - assert.equal(probe.pwsh.file, made.package) - assert.equal(expression, made.package) - }) -}) - -await test('the L3 override declines without a base URL, whatever the plugin carries', () => { - const expr = pwshOverrideExpression() - assert.equal(evaluateLoaderExpression(expr, { baseUrl: undefined, ctx: {} }), undefined) -}) - await test('system discovery is opt-in on Windows and is the rung on a POSIX host', async () => { await withTempDir('shells-default-confined', dir => { // Both spellings exist, so the case asserts the same facts on either host. @@ -362,38 +92,6 @@ await test('system discovery is opt-in on Windows and is the rung on a POSIX hos }) }) -await test('runtime pwsh overlay commits in place, targets only the owning row, and restores the latest raw value', () => { - let hook, cleanup - const ref = volatileRef('original') - const target = { - options: { id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox', config: { pwshPath: 'original', retained: true } }, - fiber: { state: 2, config: { pwshPath: ref }, update() { throw new Error('the overlay must not restart the owning row') } }, - } - const bareRef = volatileRef('original') - const bare = { options: { id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox', config: { retained: true } }, - fiber: { state: 2, config: { pwshPath: bareRef }, update() { throw new Error('the overlay must not restart the owning row') } } } - const foreignRef = volatileRef('foreign') - const foreign = { options: { id: 'pwsh-sandbox', name: 'foreign-executor', config: {} }, fiber: { state: 2, config: { pwshPath: foreignRef } } } - const fixture = { inject(_deps, body) { body({ - get() { return { entries: () => [target, bare, foreign] } }, - on(_name, callback) { hook = callback; return () => { hook = undefined } }, - effect(callback) { cleanup = callback() }, - }) } } - const original = target.options.config - mountBundledPwsh(fixture, { pwsh: { available: true, file: 'approved-pwsh' } }) - assert.equal(ref.get(), 'approved-pwsh') - assert.equal(bareRef.get(), 'approved-pwsh') - assert.equal(target.options.config, original) - assert.equal(foreignRef.get(), 'foreign') - assert.deepEqual(hook.call({ entry: foreign }, {}, () => ({ pwshPath: 'foreign' })), { pwshPath: 'foreign' }) - target.options.config = { pwshPath: 'new-owner-choice', retained: false } - cleanup() - assert.equal(ref.get(), 'new-owner-choice', 'the restore uses the owner\'s latest raw config') - assert.equal(bareRef.get(), undefined, 'no declared value restores to undefined') - cleanup() - assert.equal(ref.get(), 'new-owner-choice', 'cleanup is idempotent') -}) - await test('an inherited LC_ALL cannot un-pin the message locale, and encoding survives it', () => { // Reported by @Haoming9527 in review of #81: POSIX resolves LC_ALL before LC_MESSAGES, so on a host // that sets LC_ALL (`zh_CN.UTF-8` desktops commonly set nothing else) the English pin below was diff --git a/test/shells-resolution.test.mjs b/test/shells-resolution.test.mjs index 5ed099ee..3eb32cc4 100644 --- a/test/shells-resolution.test.mjs +++ b/test/shells-resolution.test.mjs @@ -86,8 +86,9 @@ await test('the bundled copy outranks PATH and the well-known install locations' }) assert.equal(resolved.bash.source, 'bundled') assert.equal(resolved.bash.file, bundledBash) - assert.equal(resolved.pwsh.source, 'bundled') - assert.equal(resolved.pwsh.file, bundledPwsh) + assert.equal(resolved.pwsh.source, 'host') + assert.equal(resolved.pwsh.file, undefined) + assert.equal(fs.existsSync(bundledPwsh), true, 'legacy files are not removed') }) }) @@ -108,7 +109,7 @@ await test('with no bundled copy, PATH is searched before the well-known locatio assert.equal(resolved.bash.source, 'path') assert.equal(resolved.bash.file, onPathBash) assert.equal(resolved.pwsh.available, false) - assert.equal(resolved.pwsh.source, 'missing') + assert.equal(resolved.pwsh.source, 'host') }) }) @@ -129,7 +130,7 @@ await test('a Windows deployment finds the well-known Git for Windows install fo assert.equal(resolved.bash.source, 'well-known') assert.equal(resolved.bash.file, gitBash) assert.equal(resolved.pwsh.available, false) - assert.equal(resolved.pwsh.source, 'missing') + assert.equal(resolved.pwsh.source, 'host') }) }) @@ -198,8 +199,8 @@ await test('publishBashTool:false disables only the bash rung', async () => { assert.equal(resolved.bash.available, false) assert.equal(resolved.bash.source, 'disabled') // The pwsh rung is a separate fact and stays live. - assert.equal(resolved.pwsh.available, true) - assert.equal(resolved.pwsh.source, 'bundled') + assert.equal(resolved.pwsh.available, false) + assert.equal(resolved.pwsh.source, 'host') }) }) @@ -413,7 +414,7 @@ await test('a platform the pins do not cover has no pin, and the store still res // --- the provisioned copies (`dsh-ops provision-shells`) -await test('a provisioned copy outranks the bundled layouts and PATH, for both shells', async () => { +await test('provisioned Bash outranks other copies while legacy PowerShell remains host-owned', async () => { await withTempDir('shells-provisioned', async (dir) => { const home = path.join(dir, 'home') const provisionedBash = stageProvisioned(home, 'bash', '2.56.0.2') @@ -443,9 +444,10 @@ await test('a provisioned copy outranks the bundled layouts and PATH, for both s assert.notEqual(resolved.bash.file, packaged, 'the provisioned copy beats the platform package') assert.notEqual(resolved.bash.file, onPath, 'the provisioned copy beats PATH') assert.match(resolved.bash.detail, /version 2\.56\.0\.2/u) - assert.equal(resolved.pwsh.available, true, resolved.pwsh.detail) - assert.equal(resolved.pwsh.source, 'provisioned', resolved.pwsh.detail) - assert.equal(resolved.pwsh.file, provisionedPwsh) + assert.equal(resolved.pwsh.available, false, resolved.pwsh.detail) + assert.equal(resolved.pwsh.source, 'host', resolved.pwsh.detail) + assert.equal(resolved.pwsh.file, undefined) + assert.equal(fs.existsSync(provisionedPwsh), true) assert.notEqual(resolved.pwsh.file, bundledPwsh, 'the provisioned copy beats the vendored layout') // The package it beat is a live candidate, not an absent one: with the @@ -470,7 +472,7 @@ await test('a provisioned copy outranks the bundled layouts and PATH, for both s }) assert.equal(confined.bash.source, 'provisioned', confined.bash.detail) assert.equal(confined.bash.file, provisionedBash) - assert.equal(confined.pwsh.file, provisionedPwsh) + assert.equal(confined.pwsh.file, undefined) }) }) From f5ac53a19f6ef8c1f425bb44ba6cd84ca1941c3f Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:12:55 +0800 Subject: [PATCH 053/118] feat(documents): validate bounded classic PDF containers --- docs/CHANGELOG.md | 9 + docs/document-dependencies.md | 18 + docs/native-kernel.md | 21 ++ ...37\347\224\237\345\206\205\346\240\270.md" | 17 + ...43\346\236\220\344\276\235\350\265\226.md" | 15 + native/Cargo.lock | 1 + native/crates/ops-documents/Cargo.toml | 1 + native/crates/ops-documents/src/lib.rs | 2 + .../crates/ops-documents/src/pdf_container.rs | 329 ++++++++++++++++ .../ops-documents/src/pdf_container_tests.rs | 313 +++++++++++++++ native/crates/ops-documents/src/pdf_syntax.rs | 357 ++++++++++++++++++ test/package-source.test.mjs | 2 + 12 files changed, 1085 insertions(+) create mode 100644 native/crates/ops-documents/src/pdf_container.rs create mode 100644 native/crates/ops-documents/src/pdf_container_tests.rs create mode 100644 native/crates/ops-documents/src/pdf_syntax.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4d3b3b63..eab1ec1f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,15 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm safe prefixes on unsupported content, stable-scan-only continuation and scoped reference recovery. - DOCX 的 Native/PTC 按需启用,明确正文范围与排除内容;缺口保留安全前缀且不伪造续取。 +### Add bounded PDF container foundation (#261, 15c/16) + +- Validate a restricted classic-xref PDF grammar, exact object boundaries and bounded + values/references before extraction; verify actual Flate output, checksum and terminator. +- Reuse the reviewed exact compression dependency without new packages. Worker text extraction + follows separately; this foundation makes no public PDF-tool or complete-text claim. +- 新增有界传统 xref PDF 容器基础,校验对象边界、引用与实际解压;复用已核验压缩依赖, + 不新增依赖包,工具提取能力由下一切片接入。 + ### Add bounded DOCX main-body worker observations (#261, 15b/16) - Read strict DOCX main-body paragraphs and table coordinates with explicit excluded-content diff --git a/docs/document-dependencies.md b/docs/document-dependencies.md index 114ddff8..65cc2bf5 100644 --- a/docs/document-dependencies.md +++ b/docs/document-dependencies.md @@ -49,3 +49,21 @@ bindings. Unsupported content yields explicit errors or partial observations. PDF parsing has a separate follow-up review. This slice neither adds a PDF dependency nor claims PDF support; the table/DOCX readers are not substitutes for the PDF benchmark. + +## PDF foundation review (15c/16, 2026-10-19) + +The original bounded container grammar adds a direct exact dependency on flate2 1.1.10 +with defaults disabled and zlib-rs selected. Both versions were already locked, licensed +and reviewed above; this adds no resolved package or version. The low-level decoder +checks actual decoded bytes, the zlib checksum/terminator and trailing input. + +A general parser was evaluated and declined for this narrow slice. lopdf 0.42 predates +the configurable stream limits added in 0.44; newer releases also bring a mandatory +crypto dependency tree despite disabled default features. The reviewed crypto buffer +conversion required the [upstream inout correction](https://github.com/RustCrypto/utils/pull/1556), +whose follow-up 0.2.4 release was younger than seven days at review. No affected older +version or vendored patch was selected to evade the age rule. None of the evaluated +lopdf/crypto packages remains in this lockfile. See the publisher's +[lopdf changelog](https://github.com/J-F-Liu/lopdf/blob/main/CHANGELOG.md) and +[inout 0.2.4 release metadata](https://crates.io/api/v1/crates/inout/0.2.4). +This is a targeted source review, not a general PDF compliance or security-audit claim. diff --git a/docs/native-kernel.md b/docs/native-kernel.md index c560e379..aff0723c 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -228,3 +228,24 @@ gaps never get a looping continuation. Stable full scans with remaining paragrap read-only pages; compare revisions across calls. Skipped paragraphs never exceed observed blocks, and FILE_CHANGED always invalidates requested-page completeness. Generated source metadata on reference pages retains the declared main-body scope independently of stored-row completeness. + +## PDF parser foundation (15c/16) + +The internal PDF container parser accepts a restricted PDF 1.0-1.7 subset: one classic +xref table, an exact final startxref/EOF footer, unique dictionary keys and object +identities, and directly declared stream lengths. Every active xref offset must point +exactly at its object header. Objects cannot overlap; gaps must contain only whitespace +or comments. This deliberately rejects incremental updates, encryption, object/xref +streams, recovery scans and ambiguous duplicate data, including decoded forbidden names. + +Bounds are checked before indexed allocation: 16 MiB per input, 10,000 xref entries, +100,000 metadata values, depth 64, 4,096 keys per dictionary, 256 bytes per name/token, +and 1 MiB per string or encoded/decoded stream. Reference chains have cycle detection +and a depth bound. Streams support no filter or one FlateDecode only; external streams, +filter chains and DecodeParms are declined. Actual decompression stops at the decoded +limit plus one byte and requires a valid checksum, zlib terminator and exact consumption +of the input. No size advertised by a PDF author bypasses these limits. + +This slice exposes no tool and makes no text-coverage claim. Page-tree interpretation, +the supported font/encoding subset, partial results and worker authority checks are +introduced together in the following PDF extraction slice. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index e2cd3257..2ee05844 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -181,3 +181,20 @@ Native 显示段落序号、表格坐标以及明确的 scope/excludedContent 安全前缀。PTC 在 Outcome 中返回类型化段落;物理缺口不生成循环续取。稳定完整扫描且尚有后续 段落时可只读前进分页,跨调用须比较 revision。skipped 不超过实际观察的 blocksObserved, FILE_CHANGED 始终使所请求页不完整。引用页生成的 source 类型保留正文范围,独立于存储记录分页。 + +## PDF 解析基础(15c/16) + +内部 PDF 容器解析器仅接受受限的 PDF 1.0–1.7 子集:单个传统 xref 表、 +准确的末尾 startxref/EOF、唯一字典键及对象标识,以及直接整数形式的流长度。 +每个有效 xref 偏移必须准确指向对象头;对象不可重叠,间隙只能包含空白或注释。 +增量更新、加密、对象流/xref 流、恢复扫描及重复歧义数据均被拒绝,名称转义后 +仍会检查不支持的结构名称。 + +在索引分配前实施边界:输入 16 MiB、xref 条目 10,000、元数据值 100,000、 +嵌套深度 64、每字典 4,096 个键、名称/词元 256 字节、字符串及编码/解码流 +各 1 MiB。间接引用检查循环及深度。流仅支持无过滤或单个 FlateDecode; +外部流、过滤链及 DecodeParms 被拒绝。解压按实际输出限制加一字节停止, +并核验校验和、zlib 结束标记及完整输入消费;文档声明长度不能绕过实际边界。 + +本切片不暴露工具,也不宣称提取了完整文本。页面树、支持的字体/编码子集、 +部分结果及 worker 权限检查将在下一 PDF 提取切片中一并接入。 diff --git "a/docs/\346\226\207\346\241\243\350\247\243\346\236\220\344\276\235\350\265\226.md" "b/docs/\346\226\207\346\241\243\350\247\243\346\236\220\344\276\235\350\265\226.md" index e961b593..f0f73fd9 100644 --- "a/docs/\346\226\207\346\241\243\350\247\243\346\236\220\344\276\235\350\265\226.md" +++ "b/docs/\346\226\207\346\241\243\350\247\243\346\236\220\344\276\235\350\265\226.md" @@ -38,3 +38,18 @@ XML 禁用 DTD 与自定义实体、严格 UTF-8 或 BOM 标记 UTF-16 解码、 不支持的内容返回明确错误或部分观察结果。 PDF 解析另有后续审查。本切片不增加 PDF 依赖、不声明 PDF 支持;CSV/DOCX 不能替代 PDF 基准。 + +## PDF 基础依赖核验(15c/16,2026-10-19) + +自主实现的有界容器语法仅直接引用已精确锁定的 flate2 1.1.10,关闭默认功能并 +选择 zlib-rs。两者版本、许可及日期已在上文核验,不新增任何解析依赖包或版本。 +底层解码同时核验实际输出大小、zlib 校验和/结束标记及尾随输入。 + +本切片评估后未采用通用解析库。lopdf 0.42 尚无 0.44 才加入的可配置流限制; +较新版本即使关闭默认功能仍引入必选加密依赖树。核验发现相关缓冲区转换需 +[上游 inout 修复](https://github.com/RustCrypto/utils/pull/1556),其后续 0.2.4 +在核验时发布不足七日;没有为满足日期规则选用受影响旧版,也没有维护私有补丁。 +候选 lopdf/加密依赖均未保留于锁文件。可复核 +[lopdf 更新日志](https://github.com/J-F-Liu/lopdf/blob/main/CHANGELOG.md)及 +[inout 0.2.4 发布信息](https://crates.io/api/v1/crates/inout/0.2.4)。 +这属于针对性源码核验,并非全面 PDF 合规或安全审计结论。 diff --git a/native/Cargo.lock b/native/Cargo.lock index 77ca47e6..e900770f 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -368,6 +368,7 @@ dependencies = [ name = "ops-documents" version = "0.3.1" dependencies = [ + "flate2", "ops-files", "ops-protocol", "quick-xml", diff --git a/native/crates/ops-documents/Cargo.toml b/native/crates/ops-documents/Cargo.toml index f684e78a..e2498636 100644 --- a/native/crates/ops-documents/Cargo.toml +++ b/native/crates/ops-documents/Cargo.toml @@ -14,3 +14,4 @@ serde_json = "1.0" sha2 = "0.10.9" zip = { version = "=9.0.1", default-features = false, features = ["deflate-flate2-zlib-rs"] } quick-xml = { version = "=0.42.0", default-features = false } +flate2 = { version = "=1.1.10", default-features = false, features = ["zlib-rs"] } diff --git a/native/crates/ops-documents/src/lib.rs b/native/crates/ops-documents/src/lib.rs index 0d3fd695..90674bd2 100644 --- a/native/crates/ops-documents/src/lib.rs +++ b/native/crates/ops-documents/src/lib.rs @@ -1,6 +1,8 @@ //! Bounded table observations; cells stay strings, and incomplete traversals never invent totals. #![forbid(unsafe_code)] mod archive; +mod pdf_syntax; +pub mod pdf_container; mod xml; mod docx; mod decode; diff --git a/native/crates/ops-documents/src/pdf_container.rs b/native/crates/ops-documents/src/pdf_container.rs new file mode 100644 index 00000000..3936e2c3 --- /dev/null +++ b/native/crates/ops-documents/src/pdf_container.rs @@ -0,0 +1,329 @@ +//! A deliberately restricted PDF container: one classic xref table, direct stream lengths, no recovery. +use crate::pdf_syntax::{Dict, Lexer, Value, bad, limit, unsupported, white}; +use ops_protocol::Result; +use std::collections::{BTreeMap, BTreeSet}; +pub const MAX_FILE: usize = 16 * 1024 * 1024; +pub const MAX_OBJECTS: usize = 10_000; +pub const MAX_STREAM: usize = 1024 * 1024; +pub type ObjectId = (u32, u16); +#[derive(Debug)] +pub struct Object<'a> { + pub value: Value, + pub stream: Option<&'a [u8]>, +} +#[derive(Debug)] +pub struct Document<'a> { + pub objects: BTreeMap>, + pub root: ObjectId, +} +impl<'a> Document<'a> { + pub fn object(&self, id: ObjectId) -> Result<&Object<'a>> { + self.objects + .get(&id) + .ok_or_else(|| bad("PDF reference does not name an active object")) + } + pub fn resolve<'b>(&'b self, value: &'b Value) -> Result<&'b Value> { + let mut current = value; + let mut seen = BTreeSet::new(); + while let Value::Ref(number, generation) = current { + if seen.len() >= 64 { + return Err(limit("PDF reference chain exceeds 64")); + } + if !seen.insert((*number, *generation)) { + return Err(bad("Cyclic PDF reference")); + } + let object = self.object((*number, *generation))?; + if object.stream.is_some() { + return Err(bad("A PDF stream cannot be used as a plain value")); + } + current = &object.value; + } + Ok(current) + } +} +pub fn get<'a>(dict: &'a Dict, key: &[u8]) -> Result<&'a Value> { + dict.get(key) + .ok_or_else(|| bad(format!("Missing PDF {}", String::from_utf8_lossy(key)))) +} +fn trim_end(mut data: &[u8]) -> &[u8] { + while data.last().is_some_and(|b| white(*b)) { + data = &data[..data.len() - 1]; + } + data +} +fn footer(data: &[u8]) -> Result<(usize, usize)> { + let end = trim_end(data); + let Some(before_eof) = end.strip_suffix(b"%%EOF") else { + return Err(bad("PDF must end with %%EOF")); + }; + if !before_eof.last().is_some_and(|b| white(*b)) { + return Err(bad("Missing PDF EOF separator")); + } + let before_eof = trim_end(before_eof); + let number_start = before_eof + .iter() + .rposition(|b| !b.is_ascii_digit()) + .map_or(0, |n| n + 1); + if number_start == 0 || !white(before_eof[number_start - 1]) { + return Err(bad("Missing PDF xref number separator")); + } + let mut number = Lexer::new(&before_eof[number_start..]); + let xref = number.unsigned()?; + let prefix = trim_end(&before_eof[..number_start]); + let Some(before_start) = prefix.strip_suffix(b"startxref") else { + return Err(bad("Missing final PDF startxref")); + }; + if !before_start.last().is_some_and(|b| white(*b)) { + return Err(bad("Missing PDF startxref separator")); + } + let footer_start = before_start.len(); + if xref >= footer_start { + return Err(bad("PDF xref offset points outside object section")); + } + Ok((xref, footer_start)) +} +fn object<'a>( + data: &'a [u8], + offset: usize, + id: ObjectId, + nodes: usize, +) -> Result<(Object<'a>, usize, usize)> { + if !data.get(offset).is_some_and(u8::is_ascii_digit) { + return Err(bad("PDF xref must point exactly to an object header")); + } + let mut lexer = Lexer { + data, + at: offset, + nodes, + }; + if lexer.unsigned()? != id.0 as usize || lexer.unsigned()? != id.1 as usize { + return Err(bad("PDF xref object identity mismatch")); + } + lexer.keyword(b"obj")?; + let value = lexer.value(0)?; + let mut stream = None; + let token = lexer.word()?; + if token == b"stream" { + let length = get(value.dict()?, b"Length")?.integer()?; + if length > MAX_STREAM { + return Err(limit("PDF encoded stream exceeds 1 MiB")); + } + // Indirect Length is declined before reading stream bytes; no searching for endstream. + if lexer.data.get(lexer.at..lexer.at + 2) == Some(b"\r\n") { + lexer.at += 2; + } else if lexer.data.get(lexer.at) == Some(&b'\n') { + lexer.at += 1; + } else { + return Err(bad("PDF stream keyword must be followed by LF or CRLF")); + } + let end = lexer + .at + .checked_add(length) + .filter(|n| *n <= data.len()) + .ok_or_else(|| bad("PDF stream length exceeds its object"))?; + stream = Some(&data[lexer.at..end]); + lexer.at = end; + lexer.keyword(b"endstream")?; + lexer.keyword(b"endobj")?; + } else if token != b"endobj" { + return Err(bad("Unexpected token after PDF object")); + } + Ok((Object { value, stream }, lexer.at, lexer.nodes)) +} +pub fn parse(data: &[u8]) -> Result> { + if data.len() > MAX_FILE { + return Err(limit("PDF file exceeds 16 MiB")); + } + if data.len() < 9 + || &data[..7] != b"%PDF-1." + || !matches!(data[7], b'0'..=b'7') + || !matches!(data[8], 10 | 13) + { + return Err(unsupported( + "Only PDF 1.0-1.7 with an exact header and classic xref table is supported", + )); + } + let (xref, footer_start) = footer(data)?; + if !data[xref..].starts_with(b"xref") { + return Err(unsupported( + "PDF requires a classic xref table; xref streams are unsupported", + )); + } + let mut lexer = Lexer { + data, + at: xref, + nodes: 0, + }; + lexer.keyword(b"xref")?; + let mut entries = BTreeMap::new(); + loop { + lexer.skip(); + if data[lexer.at..].starts_with(b"trailer") { + lexer.keyword(b"trailer")?; + break; + } + let start = lexer.unsigned()?; + let count = lexer.unsigned()?; + if count == 0 { + return Err(bad("PDF xref subsection cannot be empty")); + } + if start >= MAX_OBJECTS + || count > MAX_OBJECTS - start + || entries.len() + count > MAX_OBJECTS + { + return Err(limit("PDF xref exceeds 10000 entries")); + } + for id in start..start + count { + let offset = lexer.unsigned()?; + let generation = lexer.unsigned()?; + let flag = lexer.word()?; + if generation > u16::MAX as usize + || (flag == b"n" && generation == u16::MAX as usize) + || !matches!(flag, b"n" | b"f") + { + return Err(bad("Invalid PDF xref entry")); + } + if entries + .insert(id, (offset, generation as u16, flag == b"n")) + .is_some() + { + return Err(bad("Duplicate PDF xref object number")); + } + } + } + let trailer = lexer.value(0)?; + let trailer = trailer.dict()?; + let size = get(trailer, b"Size")?.integer()?; + if size == 0 + || size > MAX_OBJECTS + || entries.len() != size + || entries.keys().copied().ne(0..size) + { + return Err(bad( + "PDF xref must fully declare its bounded Size without incremental sections", + )); + } + if entries + .get(&0) + .is_none_or(|(_, generation, active)| *active || *generation != u16::MAX) + { + return Err(bad("PDF object zero must be free with generation 65535")); + } + let root = match get(trailer, b"Root")? { + Value::Ref(a, b) => (*a, *b), + _ => return Err(bad("PDF Root must be an indirect reference")), + }; + lexer.skip(); + if lexer.at != footer_start { + return Err(bad("Unexpected PDF data between trailer and final footer")); + } + let mut ordered = Vec::new(); + for (number, (offset, generation, active)) in entries { + if !active { + continue; + } + if offset < 9 || offset >= xref { + return Err(bad( + "Active PDF object offset is outside the object section", + )); + } + ordered.push((offset, (number as u32, generation))); + } + ordered.sort_unstable(); + let mut previous = 0; + let mut nodes = lexer.nodes; + let mut objects = BTreeMap::new(); + for (offset, id) in ordered { + if offset < previous { + return Err(bad("PDF object ranges overlap")); + } + let mut gap = Lexer { + data, + at: previous, + nodes, + }; + gap.skip(); + if gap.at != offset { + return Err(bad( + "Unindexed data or overlapping comments in PDF object section", + )); + } + let (item, end, count) = object(&data[..xref], offset, id, nodes)?; + previous = end; + nodes = count; + objects.insert(id, item); + } + let mut gap = Lexer { + data, + at: previous, + nodes, + }; + gap.skip(); + if gap.at != xref { + return Err(bad("Unindexed trailing data before PDF xref")); + } + let document = Document { objects, root }; + let root_object = document.object(root)?; + if root_object.stream.is_some() { + return Err(bad("PDF Root cannot be a stream")); + } + root_object.value.dict()?; + Ok(document) +} +// Decode one already bounded stream; verify the actual zlib terminator and complete input consumption. +pub fn decode(object: &Object<'_>) -> Result> { + let raw = object.stream.ok_or_else(|| bad("Expected a PDF stream"))?; + if raw.len() > MAX_STREAM { + return Err(limit("PDF encoded stream exceeds 1 MiB")); + } + let dict = object.value.dict()?; + if dict.contains_key(b"DecodeParms".as_slice()) || dict.contains_key(b"F".as_slice()) { + return Err(unsupported( + "PDF stream predictors, decode parameters and external data are unsupported", + )); + } + let Some(filter) = dict.get(b"Filter".as_slice()) else { + return Ok(raw.to_vec()); + }; + let filter = match filter { + Value::Array(filters) if filters.len() == 1 => &filters[0], + value => value, + }; + if filter.name()? != b"FlateDecode" { + return Err(unsupported( + "Only unfiltered or single FlateDecode PDF streams are supported", + )); + } + let mut decoder = flate2::Decompress::new(true); + let mut output = Vec::new(); + let mut buffer = [0u8; 8192]; + loop { + let before_in = decoder.total_in() as usize; + let before_out = decoder.total_out() as usize; + let room = (MAX_STREAM + 1 - output.len()).min(buffer.len()); + let status = decoder + .decompress( + &raw[before_in..], + &mut buffer[..room], + flate2::FlushDecompress::None, + ) + .map_err(|_| bad("Invalid PDF zlib stream or checksum"))?; + let written = decoder.total_out() as usize - before_out; + output.extend_from_slice(&buffer[..written]); + if output.len() > MAX_STREAM { + return Err(limit("PDF decoded stream exceeds 1 MiB")); + } + if status == flate2::Status::StreamEnd { + if decoder.total_in() as usize != raw.len() { + return Err(bad("Trailing data after PDF zlib stream")); + } + return Ok(output); + } + if decoder.total_in() as usize == before_in && written == 0 { + return Err(bad("Truncated PDF zlib stream")); + } + } +} +#[cfg(test)] +#[path = "pdf_container_tests.rs"] +mod tests; diff --git a/native/crates/ops-documents/src/pdf_container_tests.rs b/native/crates/ops-documents/src/pdf_container_tests.rs new file mode 100644 index 00000000..706d3a13 --- /dev/null +++ b/native/crates/ops-documents/src/pdf_container_tests.rs @@ -0,0 +1,313 @@ +use super::*; +fn fixture(values: &[&[u8]]) -> Vec { + let mut pdf = b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n".to_vec(); + let mut offsets = vec![0]; + for (index, value) in values.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", index + 1).as_bytes()); + pdf.extend_from_slice(value); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len()).as_bytes()); + for offset in offsets.iter().skip(1) { + pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", + offsets.len() + ) + .as_bytes(), + ); + pdf +} +fn replace(pdf: &[u8], old: &[u8], new: &[u8]) -> Vec { + let at = pdf.windows(old.len()).position(|v| v == old).unwrap(); + [&pdf[..at], new, &pdf[at + old.len()..]].concat() +} +fn rejected(pdf: &[u8], code: &str) { + let error = parse(pdf).unwrap_err(); + assert_eq!(error.code, code, "{}", error.message); +} +#[test] +fn real_classic_pdf_preserves_objects_values_and_exact_stream_bytes() { + let pdf = fixture(&[ + b"<< /Type /Catalog /Pages 2 0 R /A true /B false /N null /Escaped#20Name <4f70 7> >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", + b"<< /Length 9 >>\nstream\n(Hi) Tj\n\nendstream", + ]); + let document = parse(&pdf).unwrap(); + assert_eq!(document.objects.len(), 4); + assert_eq!( + document.object((4, 0)).unwrap().stream, + Some(b"(Hi) Tj\n\n".as_slice()) + ); + let root = document + .object(document.root) + .unwrap() + .value + .dict() + .unwrap(); + assert!(matches!(get(root, b"A").unwrap(), Value::Bool(true))); + assert!(matches!(get(root, b"B").unwrap(), Value::Bool(false))); + assert!(matches!(get(root, b"Escaped Name").unwrap(), Value::String(v) if v == b"Opp")); +} +#[test] +fn literal_strings_escapes_line_endings_and_odd_hex_are_exact() { + let mut lexer = Lexer::new(b"(a\\n\\r\\t\\b\\f\\(b\\)\\134\\101\\\r\nc\r\nd)"); + assert!( + matches!(lexer.value(0).unwrap(), Value::String(v) if v == b"a\n\r\t\x08\x0c(b)\\Ac\nd") + ); + let mut lexer = Lexer::new(b"<123>"); + assert!(matches!(lexer.value(0).unwrap(), Value::String(v) if v == [0x12,0x30])); + assert!(Lexer::new(b"1.0").value(0).unwrap().integer().is_err()); + assert!(Lexer::new(b"+1").value(0).unwrap().integer().is_ok()); +} +#[test] +fn duplicate_keys_and_escaped_unsupported_names_are_rejected() { + for value in [ + b"<< /Name 1 /Na#6de 2 >>".as_slice(), + b"<< /Bad#0 1 >>", + b"<< /Bad#00 1 >>", + b"<< /Na#GG 1 >>", + ] { + rejected(&fixture(&[value]), "DOCUMENT_PARSE_ERROR"); + } + for name in ["Encrypt", "Pr#65v", "XRefStm", "ObjStm", "XRef"] { + rejected( + &fixture(&[format!("<< /{name} null >>").as_bytes()]), + "UNSUPPORTED_DOCUMENT", + ); + } +} +#[test] +fn offset_alias_overlap_and_unindexed_data_are_never_recovered() { + let pdf = fixture(&[b"<<>>", b"(value)"]); + let document = parse(&pdf).unwrap(); + assert_eq!(document.objects.len(), 2); + let first = pdf.windows(7).position(|w| w == b"1 0 obj").unwrap(); + let second = pdf.windows(7).position(|w| w == b"2 0 obj").unwrap(); + rejected( + &replace( + &pdf, + format!("{second:010}").as_bytes(), + format!("{first:010}").as_bytes(), + ), + "DOCUMENT_PARSE_ERROR", + ); + rejected( + &replace( + &pdf, + format!("{first:010}").as_bytes(), + format!("{:010}", first - 1).as_bytes(), + ), + "DOCUMENT_PARSE_ERROR", + ); + rejected( + &replace(&pdf, b"2 0 obj", b"2 1 obj"), + "DOCUMENT_PARSE_ERROR", + ); + rejected( + &replace(&pdf, b"%\xE2\xE3\xCF\xD3", b"xxxxx"), + "DOCUMENT_PARSE_ERROR", + ); + // A comment starting before an indexed object must not allow that object to become executable. + rejected( + &replace(&pdf, b"\n2 0 obj", b"%2 0 obj"), + "DOCUMENT_PARSE_ERROR", + ); +} +#[test] +fn xref_counts_generations_size_and_footer_are_bounded_and_exact() { + let pdf = fixture(&[b"<<>>"]); + for (old, new) in [ + (b"xref\n0 2".as_slice(), b"xref\n0 1000000000".as_slice()), + (b"xref\n0 2", b"xref\n10000 2"), + ] { + rejected(&replace(&pdf, old, new), "OUTPUT_LIMIT"); + } + for (old, new) in [ + (b"/Size 2".as_slice(), b"/Size 3".as_slice()), + (b"/Size 2", b"/Size 2.0"), + (b"65535 f", b"65534 f"), + (b"00000 n", b"99999 n"), + (b"00000 n", b"00000 x"), + (b"startxref\n", b"startxref"), + (b"%%EOF\n", b"%%EOF x"), + (b"xref\n0 2", b"xref\n0 1"), + ] { + rejected(&replace(&pdf, old, new), "DOCUMENT_PARSE_ERROR"); + } +} +#[test] +fn streams_require_bounded_direct_integer_lengths_and_matching_end() { + for value in [ + b"<< /Length 1.0 >>\nstream\nx\nendstream".as_slice(), + b"<< /Length 1 0 R >>\nstream\nx\nendstream", + b"<< /Length 3 >>\nstream\nx\nendstream", + b"<< /Length 100000 >>\nstream\nx\nendstream", + b"<< /Length 1 >>\nstream x\nendstream", + b"<< /Length 1 >>\nstream\nx\nendobj", + ] { + rejected(&fixture(&[value]), "DOCUMENT_PARSE_ERROR"); + } + rejected( + &fixture(&[b"<< /Length 1048577 >>\nstream\nx\nendstream"]), + "OUTPUT_LIMIT", + ); + let pdf = fixture(&[b"<<>>", b"<< /Length 0 >>\nstream\nendstream"]); + assert_eq!( + parse(&pdf).unwrap().object((2, 0)).unwrap().stream, + Some(b"".as_slice()) + ); +} +#[test] +fn nesting_strings_names_and_node_totals_are_bounded() { + let deep = format!("<< /X {}0{} >>", "[".repeat(65), "]".repeat(65)); + rejected(&fixture(&[deep.as_bytes()]), "OUTPUT_LIMIT"); + let name = format!("<< /{} 0 >>", "a".repeat(257)); + rejected(&fixture(&[name.as_bytes()]), "OUTPUT_LIMIT"); + let nodes = format!("<< /X [{}] >>", "0 ".repeat(100000)); + rejected(&fixture(&[nodes.as_bytes()]), "OUTPUT_LIMIT"); + let string = format!("<< /X ({}) >>", "x".repeat(1024 * 1024 + 1)); + rejected(&fixture(&[string.as_bytes()]), "OUTPUT_LIMIT"); + let hex = format!("<< /X <{}> >>", "a".repeat(2 * 1024 * 1024 + 1)); + rejected(&fixture(&[hex.as_bytes()]), "OUTPUT_LIMIT"); + rejected(&vec![b' '; MAX_FILE + 1], "OUTPUT_LIMIT"); +} +#[test] +fn reference_resolution_is_bounded_and_cycles_missing_objects_fail() { + let pdf = fixture(&[b"<< /X 2 0 R >>", b"3 0 R", b"2 0 R"]); + let doc = parse(&pdf).unwrap(); + let value = get(doc.object(doc.root).unwrap().value.dict().unwrap(), b"X").unwrap(); + assert_eq!(doc.resolve(value).unwrap_err().code, "DOCUMENT_PARSE_ERROR"); + assert!(doc.resolve(&Value::Ref(999, 0)).is_err()); + let mut values = vec![b"<<>>".to_vec()]; + for n in 2..68 { + values.push(format!("{} 0 R", n + 1).into_bytes()); + } + values.push(b"null".to_vec()); + let pdf = fixture(&values.iter().map(Vec::as_slice).collect::>()); + assert_eq!( + parse(&pdf) + .unwrap() + .resolve(&Value::Ref(2, 0)) + .unwrap_err() + .code, + "OUTPUT_LIMIT" + ); +} +#[test] +fn every_truncated_prefix_and_single_byte_mutation_is_panic_free() { + let pdf = fixture(&[ + b"<< /Type /Catalog /X [(a\\)b) 1 -2 3.5] >>", + b"<< /Length 2 >>\nstream\nxy\nendstream", + ]); + for end in 0..pdf.len() { + assert!(parse(&pdf[..end]).is_err() || trim_end(&pdf[..end]).ends_with(b"%%EOF")); + } + for at in 0..pdf.len() { + for byte in [0, b'%', b'(', b'<', b'9', 255] { + let mut changed = pdf.clone(); + changed[at] = byte; + let _ = parse(&changed); + } + } +} +#[test] +fn flate_streams_validate_actual_decoded_size_checksum_and_consumption() { + use std::io::Write; + fn zipped(bytes: &[u8]) -> Vec { + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() + } + fn decode_zip(bytes: &[u8]) -> ops_protocol::Result> { + let mut dict = Dict::new(); + dict.insert(b"Filter".to_vec(), Value::Name(b"FlateDecode".to_vec())); + decode(&Object { + value: Value::Dict(dict), + stream: Some(bytes), + }) + } + let text = b"BT /F1 12 Tf (A real PDF text stream) Tj ET"; + let compressed = zipped(text); + assert_eq!(decode_zip(&compressed).unwrap(), text); + for length in 0..compressed.len() { + assert_eq!( + decode_zip(&compressed[..length]).unwrap_err().code, + "DOCUMENT_PARSE_ERROR" + ); + } + let mut trailing = compressed.clone(); + trailing.push(0); + assert_eq!( + decode_zip(&trailing).unwrap_err().code, + "DOCUMENT_PARSE_ERROR" + ); + let mut corrupt = compressed.clone(); + let end = corrupt.len() - 1; + corrupt[end] ^= 1; + assert_eq!( + decode_zip(&corrupt).unwrap_err().code, + "DOCUMENT_PARSE_ERROR" + ); + assert_eq!( + decode_zip(&zipped(&vec![b'x'; MAX_STREAM])).unwrap().len(), + MAX_STREAM + ); + assert_eq!( + decode_zip(&zipped(&vec![b'x'; MAX_STREAM + 1])) + .unwrap_err() + .code, + "OUTPUT_LIMIT" + ); + let raw = Object { + value: Value::Dict(Dict::new()), + stream: Some(text), + }; + assert_eq!(decode(&raw).unwrap(), text); +} +#[test] +fn filter_chains_predictors_and_external_streams_are_declined() { + for (key, value) in [ + (b"Filter".as_slice(), Value::Name(b"LZWDecode".to_vec())), + ( + b"Filter", + Value::Array(vec![Value::Name(b"FlateDecode".to_vec()); 2]), + ), + (b"DecodeParms", Value::Null), + (b"F", Value::String(b"file.pdf".to_vec())), + ] { + let mut dict = Dict::new(); + dict.insert(key.to_vec(), value); + assert!( + decode(&Object { + value: Value::Dict(dict), + stream: Some(b"") + }) + .is_err() + ); + } +} +#[test] +fn stream_roots_reserved_generations_and_direct_oversize_decode_are_refused() { + rejected( + &fixture(&[b"<< /Length 0 >>\nstream\nendstream"]), + "DOCUMENT_PARSE_ERROR", + ); + let pdf = fixture(&[b"<<>>"]); + rejected( + &replace(&pdf, b"00000 n", b"65535 n"), + "DOCUMENT_PARSE_ERROR", + ); + let bytes = vec![0; MAX_STREAM + 1]; + let raw = Object { + value: Value::Dict(Dict::new()), + stream: Some(&bytes), + }; + assert_eq!(decode(&raw).unwrap_err().code, "OUTPUT_LIMIT"); +} diff --git a/native/crates/ops-documents/src/pdf_syntax.rs b/native/crates/ops-documents/src/pdf_syntax.rs new file mode 100644 index 00000000..d540cb18 --- /dev/null +++ b/native/crates/ops-documents/src/pdf_syntax.rs @@ -0,0 +1,357 @@ +//! Bounded PDF values, shared by classic-object and content-stream parsing. No recovery heuristics. +use ops_protocol::{Failure, Result}; +use std::collections::BTreeMap; +pub const MAX_NODES: usize = 100_000; +pub const MAX_STRING: usize = 1024 * 1024; +pub type Dict = BTreeMap, Value>; +#[derive(Clone, Debug)] +pub enum Value { + Null, + Bool(bool), + Integer(i64), + Number(f64), + Name(Vec), + String(Vec), + Array(Vec), + Dict(Dict), + Ref(u32, u16), +} +impl Value { + pub fn integer(&self) -> Result { + match self { + Self::Integer(n) if *n >= 0 && *n <= u32::MAX as i64 => Ok(*n as usize), + _ => Err(bad("Expected a bounded nonnegative PDF integer")), + } + } + pub fn dict(&self) -> Result<&Dict> { + match self { + Self::Dict(d) => Ok(d), + _ => Err(bad("Expected a PDF dictionary")), + } + } + pub fn name(&self) -> Result<&[u8]> { + match self { + Self::Name(n) => Ok(n), + _ => Err(bad("Expected a PDF name")), + } + } +} +pub fn bad(message: impl ToString) -> Failure { + Failure::new("DOCUMENT_PARSE_ERROR", message) +} +pub fn unsupported(message: impl ToString) -> Failure { + Failure::new("UNSUPPORTED_DOCUMENT", message) +} +pub fn limit(message: &str) -> Failure { + Failure::new("OUTPUT_LIMIT", message) +} +pub fn white(b: u8) -> bool { + matches!(b, 0 | 9 | 10 | 12 | 13 | 32) +} +fn delimiter(b: u8) -> bool { + white(b) || b"()<>[]{}/%".contains(&b) +} +fn hex(b: u8) -> Result { + match b { + b'0'..=b'9' => Ok(b - b'0'), + b'a'..=b'f' => Ok(b - b'a' + 10), + b'A'..=b'F' => Ok(b - b'A' + 10), + _ => Err(bad("Invalid PDF hexadecimal digit")), + } +} +pub struct Lexer<'a> { + pub data: &'a [u8], + pub at: usize, + pub nodes: usize, +} +impl<'a> Lexer<'a> { + pub fn new(data: &'a [u8]) -> Self { + Self { + data, + at: 0, + nodes: 0, + } + } + pub fn skip(&mut self) { + loop { + while self.at < self.data.len() && white(self.data[self.at]) { + self.at += 1 + } + if self.data.get(self.at) != Some(&b'%') { + break; + } + while self.at < self.data.len() && !matches!(self.data[self.at], 10 | 13) { + self.at += 1 + } + } + } + pub fn word(&mut self) -> Result<&'a [u8]> { + self.skip(); + let start = self.at; + while self.at < self.data.len() && !delimiter(self.data[self.at]) { + self.at += 1; + if self.at - start > 256 { + return Err(limit("PDF token exceeds 256 bytes")); + } + } + if self.at == start { + return Err(bad("Expected a PDF keyword")); + } + Ok(&self.data[start..self.at]) + } + pub fn keyword(&mut self, expected: &[u8]) -> Result<()> { + if self.word()? != expected { + return Err(bad(format!( + "Expected PDF keyword {}", + String::from_utf8_lossy(expected) + ))); + } + Ok(()) + } + pub fn unsigned(&mut self) -> Result { + let token = self.word()?; + if !token.iter().all(u8::is_ascii_digit) || token.len() > 10 { + return Err(bad("Invalid bounded PDF integer")); + } + std::str::from_utf8(token) + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|n| *n <= u32::MAX as usize) + .ok_or_else(|| bad("PDF integer overflow")) + } + fn string(&mut self) -> Result> { + self.at += 1; + let mut output = Vec::new(); + let mut depth = 1; + while let Some(&b) = self.data.get(self.at) { + self.at += 1; + match b { + b'(' => { + depth += 1; + if depth > 64 { + return Err(limit("PDF string nesting exceeds 64")); + } + output.push(b) + } + b')' => { + depth -= 1; + if depth == 0 { + return Ok(output); + } + output.push(b) + } + b'\\' => { + let b = *self + .data + .get(self.at) + .ok_or_else(|| bad("Unclosed PDF string escape"))?; + self.at += 1; + match b { + b'n' => output.push(10), + b'r' => output.push(13), + b't' => output.push(9), + b'b' => output.push(8), + b'f' => output.push(12), + 10 => {} + 13 => { + if self.data.get(self.at) == Some(&10) { + self.at += 1 + } + } + b'0'..=b'7' => { + let mut value = (b - b'0') as u16; + for _ in 0..2 { + if let Some(b'0'..=b'7') = self.data.get(self.at) { + value = value * 8 + (self.data[self.at] - b'0') as u16; + self.at += 1 + } else { + break; + } + } + output.push(value as u8) + } + _ => output.push(b), + } + } + 13 => { + if self.data.get(self.at) == Some(&10) { + self.at += 1 + } + output.push(10) + } + _ => output.push(b), + } + if output.len() > MAX_STRING { + return Err(limit("PDF string exceeds 1 MiB")); + } + } + Err(bad("Unclosed PDF literal string")) + } + fn name(&mut self) -> Result> { + self.at += 1; + let mut name = Vec::new(); + while let Some(&b) = self.data.get(self.at) { + if delimiter(b) { + break; + } + self.at += 1; + let decoded = if b == b'#' { + if self.data.len() - self.at < 2 { + return Err(bad("Incomplete PDF name escape")); + } + let b = hex(self.data[self.at])? * 16 + hex(self.data[self.at + 1])?; + self.at += 2; + b + } else { + b + }; + if decoded == 0 { + return Err(bad("PDF names cannot contain NUL")); + } + name.push(decoded); + if name.len() > 256 { + return Err(limit("PDF name exceeds 256 bytes")); + } + } + if matches!( + name.as_slice(), + b"Encrypt" | b"Prev" | b"XRefStm" | b"ObjStm" | b"XRef" + ) { + return Err(unsupported( + "Encrypted, incremental, object-stream and xref-stream PDFs are unsupported", + )); + } + Ok(name) + } + pub fn value(&mut self, depth: usize) -> Result { + if depth > 64 { + return Err(limit("PDF object nesting exceeds 64")); + } + self.nodes += 1; + if self.nodes > MAX_NODES { + return Err(limit("PDF metadata exceeds 100000 values")); + } + self.skip(); + match self + .data + .get(self.at) + .copied() + .ok_or_else(|| bad("Missing PDF value"))? + { + b'/' => Ok(Value::Name(self.name()?)), + b'(' => Ok(Value::String(self.string()?)), + b'<' => { + self.at += 1; + if self.data.get(self.at) == Some(&b'<') { + self.at += 1; + let mut dict = Dict::new(); + loop { + self.skip(); + if self.data.get(self.at..self.at + 2) == Some(b">>") { + self.at += 2; + return Ok(Value::Dict(dict)); + } + if self.data.get(self.at) != Some(&b'/') { + return Err(bad("PDF dictionary key is not a name")); + } + let key = self.name()?; + if dict.contains_key(&key) { + return Err(bad("Duplicate PDF dictionary keys are ambiguous")); + } + if dict.len() >= 4096 { + return Err(limit("PDF dictionary exceeds 4096 keys")); + } + let value = self.value(depth + 1)?; + dict.insert(key, value); + } + } else { + let mut output = Vec::new(); + let mut high = None; + loop { + let b = *self + .data + .get(self.at) + .ok_or_else(|| bad("Unclosed PDF hex string"))?; + self.at += 1; + if b == b'>' { + if let Some(high) = high { + output.push(high * 16) + } + if output.len() > MAX_STRING { + return Err(limit("PDF hex string exceeds 1 MiB")); + } + return Ok(Value::String(output)); + } + if white(b) { + continue; + } + let digit = hex(b)?; + if let Some(h) = high.take() { + output.push(h * 16 + digit) + } else { + high = Some(digit) + } + if output.len() > MAX_STRING { + return Err(limit("PDF hex string exceeds 1 MiB")); + } + } + } + } + b'[' => { + self.at += 1; + let mut values = Vec::new(); + loop { + self.skip(); + if self.data.get(self.at) == Some(&b']') { + self.at += 1; + return Ok(Value::Array(values)); + } + values.push(self.value(depth + 1)?) + } + } + _ => { + let token = self.word()?; + match token { + b"null" => Ok(Value::Null), + b"true" => Ok(Value::Bool(true)), + b"false" => Ok(Value::Bool(false)), + _ => { + if !token + .iter() + .all(|b| b.is_ascii_digit() || b"+-.".contains(b)) + { + return Err(bad("Invalid PDF value token")); + } + let number = std::str::from_utf8(token) + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|n| n.is_finite()) + .ok_or_else(|| bad("Invalid PDF number"))?; + let saved = self.at; + if token.iter().all(u8::is_ascii_digit) + && number <= 10000.0 + && number.fract() == 0.0 + { + if let Ok(generation) = self.unsigned() + && generation <= 65535 + && self.word().is_ok_and(|w| w == b"R") + { + return Ok(Value::Ref(number as u32, generation as u16)); + } + self.at = saved; + } + if !token.contains(&b'.') { + let integer = std::str::from_utf8(token) + .ok() + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| bad("PDF integer overflow"))?; + Ok(Value::Integer(integer)) + } else { + Ok(Value::Number(number)) + } + } + } + } + } + } +} diff --git a/test/package-source.test.mjs b/test/package-source.test.mjs index 84f5f71e..51b51554 100644 --- a/test/package-source.test.mjs +++ b/test/package-source.test.mjs @@ -19,6 +19,8 @@ await test('npm pack includes compiled host code and native sources, never host 'native/crates/ops-documents/src/decode.rs', 'native/crates/ops-documents/src/parse.rs', 'native/crates/ops-documents/src/tests.rs', 'native/crates/ops-documents/src/archive.rs', 'native/crates/ops-documents/src/xml.rs', 'native/crates/ops-documents/src/docx.rs', + 'native/crates/ops-documents/src/pdf_syntax.rs', + 'native/crates/ops-documents/src/pdf_container.rs', 'native/crates/ops-documents/src/pdf_container_tests.rs', 'licenses/documents/THIRD-PARTY-NOTICES.txt'] for (const name of required) assert.ok(files.includes(name), `missing source-build dependency: ${name}`) assert.equal(files.some(name => name.startsWith('src/')), false, 'host source must not ship beside compiled lib/') From b018e85552a8f0c3d0ff0d804826d50c084e2c5c Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:48:49 +0800 Subject: [PATCH 054/118] fix(shell): describe returned failures in routing guidance --- src/policy.ts | 2 +- test/policy.test.mjs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/policy.ts b/src/policy.ts index 17fcd29f..9a36bfed 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -88,7 +88,7 @@ export function renderToolingPolicy({ published, mounted, levels, pwsh7 = false, if (hasFiles) lines.push('- Follow error.nextAction; authorization denials require the user to change mode, not retries. After applied/unknown effects inspect postconditions; do not switch to a shell workaround.') if (names.has(BASH_TOOL)) lines.push('- Prefer bash for command execution. Use PowerShell only when Windows-native functionality is necessary; fix bash errors in bash, do not switch shells or mix their syntax.', '- Use command only for short commands and pipelines. For scripts longer than about 1 KB or containing heredocs, quotes, backslashes or non-ASCII text, write the script to a file first and pass script_path.', - '- ops_bash requires description and returns kind foreground, background or promoted. Foreground has status (completed/failed), exitCode, stdout/stderr and error; background/promoted has jobId for host job_output/job_kill. Invalid arguments throw; runtime failures are values.') + '- ops_bash requires description and returns kind foreground, background or promoted. Foreground has status (completed/failed), exitCode, stdout/stderr and error; background/promoted has jobId for host job_output/job_kill. Failures, including invalid arguments, return values with failure.code, failure.effectState and failure.retry; inspect unknown effects before repeating.') if (extraGuidance.trim()) lines.push('', extraGuidance.trim()) return lines.join('\n') } diff --git a/test/policy.test.mjs b/test/policy.test.mjs index bdc46c2e..0234e759 100644 --- a/test/policy.test.mjs +++ b/test/policy.test.mjs @@ -36,6 +36,8 @@ await test('the prompt states the real file-tool syntax and result contract (#44 assert.equal(text.includes('ops_bash requires description'), false) const bash = renderToolingPolicy({ published: [BASH_TOOL] }) assert.match(bash, /ops_bash requires description.*foreground, background or promoted.*job_output\/job_kill/) + assert.match(bash, /Failures, including invalid arguments, return values with failure\.code, failure\.effectState and failure\.retry/) + assert.equal(bash.includes('Invalid arguments throw'), false) assert.equal(bash.includes('never throw'), false, 'no file-tool contract without file tools') assert.equal(bash.includes('include'), false) }) From 09ee80e3cd5a4a081ceb6b2e45189308922d6baa Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:27:33 +0800 Subject: [PATCH 055/118] feat(documents): read bounded PDF text pages --- docs/CHANGELOG.md | 9 + docs/native-kernel.md | 42 +++ ...37\347\224\237\345\206\205\346\240\270.md" | 33 ++ native/crates/ops-documents/src/lib.rs | 4 + native/crates/ops-documents/src/pdf.rs | 171 ++++++++++ native/crates/ops-documents/src/pdf_pages.rs | 180 ++++++++++ native/crates/ops-documents/src/pdf_text.rs | 321 ++++++++++++++++++ native/crates/ops-results/src/spill.rs | 2 +- native/src/lib.rs | 2 +- native/src/main.rs | 2 +- test/lib/pdf-fixture.mjs | 36 ++ test/package-source.test.mjs | 2 + test/pdf-worker.test.mjs | 162 +++++++++ 13 files changed, 963 insertions(+), 3 deletions(-) create mode 100644 native/crates/ops-documents/src/pdf.rs create mode 100644 native/crates/ops-documents/src/pdf_pages.rs create mode 100644 native/crates/ops-documents/src/pdf_text.rs create mode 100644 test/lib/pdf-fixture.mjs create mode 100644 test/pdf-worker.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index eab1ec1f..c586453d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,15 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm safe prefixes on unsupported content, stable-scan-only continuation and scoped reference recovery. - DOCX 的 Native/PTC 按需启用,明确正文范围与排除内容;缺口保留安全前缀且不伪造续取。 +### Read bounded PDF text pages with honest coverage (#261, 15d/16) + +- Read a documented classic-xref/standard-font PDF subset into page text and individual runs, + with explicit exclusions, per-stream/page/aggregate limits and no OCR or layout claims. +- Preserve preceding complete pages on unsupported content, keep totals/revision conditional + on stable full scans, and capture full pages before bounded reference spill. +- 新增受限 PDF 文本页及 run 读取,明确排除内容和累计预算;保留此前完整页, + 仅完整稳定扫描提供总数/修订,复用只读权限与无损引用,不声称 OCR 或阅读布局。 + ### Add bounded PDF container foundation (#261, 15c/16) - Validate a restricted classic-xref PDF grammar, exact object boundaries and bounded diff --git a/docs/native-kernel.md b/docs/native-kernel.md index aff0723c..79125560 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -249,3 +249,45 @@ of the input. No size advertised by a PDF author bypasses these limits. This slice exposes no tool and makes no text-coverage claim. Page-tree interpretation, the supported font/encoding subset, partial results and worker authority checks are introduced together in the following PDF extraction slice. + +## PDF text worker (15d/16) + +The one-shot worker accepts strict pdf_read { path, offset?, limit? }. The host resolves +path from its current directory. Offset is a 1-based page number (1–32, default 1); +limit is 1–32 pages (default 10). This remains an internal worker capability until the +separate discovery/Native/PTC adapters are connected; no host tool is registered here. + +Each item is { page, text, runs: string[] }. Runs retain individual PDF text-show +operations; TJ spacing numbers are not characters. Text joins runs with newlines for +display. Scope is page-text-layer-content-order, **not rendered reading order**. +Excluded content is stated in every result: layout, images/OCR, annotations/form fields, +attachments/metadata, and structure-tree alternate text. A page invoking unsupported +forms/images, marked content or external graphics state is a gap, even when it also has +supported text. Blank absent/null/empty/comment-only content yields an empty page; +nonempty graphics without a supported text layer is explicitly unsupported. + +The supported text subset is PDF 1.3–1.7 with the foundation's classic xref container, +standard Helvetica/Courier/Times Type1 families, built-in StandardEncoding printable +characters (including its curly quote glyphs), or named WinAnsiEncoding. The WinAnsi +bullet/space/hyphen aliases are explicit; unknown/control codes are refused without +replacement. Embedded/custom fonts, ToUnicode/CID maps and encoding differences are +unsupported. Earlier PDF encodings are declined rather than reinterpreted. The mapping +and operator semantics follow the [Adobe-hosted ISO 32000-1 reference, sections 9.4/9.6 +and Annex D](https://developer.adobe.com/document-services/docs/assets/35e4369068f86065372c18787171a17e/PDF_ISO_32000-1.pdf). + +Physical scanning stops at 32 pages, 30 seconds, 1 MiB decoded content per page, 64 +content streams per page, 1 MiB text/10,000 runs per page, and 16 MiB aggregate decoded +streams **and** 16 MiB processed stream bytes. Shared streams decode once but every +reuse counts against processed work. The existing 16 MiB file and per-stream limits +also apply. Page offsets do not bypass any physical bound. + +scanComplete concerns the entire bounded text scope; pageComplete concerns the +requested page range and becomes false on file changes. pagesObserved and skipped +count actual observed pages. Only a stable complete scan includes total, omitted +and a raw-file SHA-256 revision. Unsupported later pages retain only preceding complete +pages, return an error/nextAction and omit full totals/revision. Rescan pagination must +compare revisions before combining pages. Inline output is capped; the shared reference +sink captures whole pages before clipping, and stored pages retain original scope and +scan/capture completeness. A reference page being complete does not fill a source gap. +Read-only, credential denial, no-follow opens and no-business-effect failures use the +same worker authority checks as the other document readers. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index 2ee05844..e62fd206 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -198,3 +198,36 @@ FILE_CHANGED 始终使所请求页不完整。引用页生成的 source 类型 本切片不暴露工具,也不宣称提取了完整文本。页面树、支持的字体/编码子集、 部分结果及 worker 权限检查将在下一 PDF 提取切片中一并接入。 + +## PDF 文本 worker(15d/16) + +单次 worker 接受严格的 pdf_read { path, offset?, limit? }。宿主从当前目录 +解析路径。offset 为 1 起始页码(1–32,默认 1),limit 为 1–32 页(默认 10)。 +发现、Native/PTC 适配器由后续切片接入;本切片不注册宿主工具。 + +每项为 { page, text, runs: string[] }。runs 保留各 PDF 显示文本操作; +TJ 的间距数字不当作字符。text 仅以换行连接各 run 供展示。范围明确为 +page-text-layer-content-order,并非渲染阅读顺序。每次结果列出排除内容: +布局、图片/OCR、注释/表单、附件/元数据及结构树替代文本。页面实际调用 +不支持的表单/图片、标记内容或外部图形状态时,整页视为缺口,即使该页也有 +可读文本。缺失/null/空白/仅注释内容可返回空页;有图形但无支持文本层时 +明确拒绝,不执行 OCR。 + +文本子集限于 PDF 1.3–1.7、基础切片支持的传统 xref,以及标准 +Helvetica/Courier/Times Type1 字体。支持内建 StandardEncoding 的可打印字符 +(含弯引号映射)或命名 WinAnsiEncoding,并明确处理后者项目符号/空格/连字符 +别名。未知/控制编码不替换解码;嵌入/自定义字体、ToUnicode/CID 及编码差异表 +均拒绝。更早 PDF 编码不擅自重新解释。映射与操作语义依据 +[Adobe 托管的 ISO 32000-1 第 9.4/9.6 节及附录 D](https://developer.adobe.com/document-services/docs/assets/35e4369068f86065372c18787171a17e/PDF_ISO_32000-1.pdf)。 + +物理扫描限制为 32 页、30 秒、每页解码内容 1 MiB/64 个流、每页文本 +1 MiB/10,000 个 run,以及累计解码 16 MiB 和累计处理流字节 16 MiB。 +共享流只解压一次,但每次复用均计入处理预算。既有 16 MiB 文件和单流边界 +仍然适用,页码偏移无法绕过这些限制。 + +scanComplete 表示整个有界文本范围的扫描完整性,pageComplete 表示请求页范围, +文件变化时必为 false。pagesObserved/skipped 只统计实际观察页。仅稳定完整扫描 +附带 total、omitted 和原文件 SHA-256 revision。后续页不支持时仅保留先前完整页, +返回错误/恢复指引,不生成总数或 revision;组合重扫分页前必须比较 revision。 +共享引用接收器在展示裁剪前捕获完整页并保留范围、扫描/捕获事实;引用分页完成 +不等于源文档缺口已恢复。只读、凭据拒绝、nofollow 及无业务副作用沿用统一权限边界。 diff --git a/native/crates/ops-documents/src/lib.rs b/native/crates/ops-documents/src/lib.rs index 90674bd2..4d406ab0 100644 --- a/native/crates/ops-documents/src/lib.rs +++ b/native/crates/ops-documents/src/lib.rs @@ -2,6 +2,9 @@ #![forbid(unsafe_code)] mod archive; mod pdf_syntax; +mod pdf_text; +mod pdf; +mod pdf_pages; pub mod pdf_container; mod xml; mod docx; @@ -41,6 +44,7 @@ fn recovery(error: &mut Failure) { } struct Source { path: PathBuf, file: File, before: Metadata, started: Instant } pub fn dispatch(request: &Request) -> Result { + if request.tool == "pdf_read" { return pdf::read(request); } if request.tool == "doc_read" { return docx::read(request); } if request.tool != "table_read" { return Err(Failure::new("INVALID_ARGUMENT", "Unknown document capability")); } let args: Args = serde_json::from_value(request.args.clone())?; diff --git a/native/crates/ops-documents/src/pdf.rs b/native/crates/ops-documents/src/pdf.rs new file mode 100644 index 00000000..6a168e4b --- /dev/null +++ b/native/crates/ops-documents/src/pdf.rs @@ -0,0 +1,171 @@ +//! Read-only, stable-scope PDF page observations. Unsupported content never becomes complete text. +use crate::{ + pdf_container::{self, MAX_FILE}, + pdf_pages::{self, MAX_PAGES, Streams}, + pdf_text, +}; +use ops_protocol::{Failure, Request, Result, capture::json_size}; +use serde::Deserialize; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{io::Read, path::Path, time::Instant}; +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Args { + path: String, + #[serde(default = "super::one")] + offset: usize, + #[serde(default = "ten")] + limit: usize, +} +fn page_complete(failure: Option<&Failure>, observed: usize, offset: usize, limit: usize) -> bool { + !failure.is_some_and(|error| error.code == "FILE_CHANGED") + && (failure.is_none() || observed >= offset + limit - 1) +} +fn ten() -> usize { + 10 +} +pub fn read(request: &Request) -> Result { + let args: Args = serde_json::from_value(request.args.clone())?; + if args.offset == 0 || args.offset > MAX_PAGES || args.limit == 0 || args.limit > MAX_PAGES { + return Err(Failure::new( + "INVALID_ARGUMENT", + "pdf_read offset and limit are 1-32 pages (defaults 1 and 10)", + )); + } + request.authorize_path(Path::new(&args.path))?; + let target = ops_files::file_path::checked(&args.path)?; + request.authorize_path(&target.display)?; + let mut file = ops_files::file_path::read_file(&target)?.into_std(); + let before = file.metadata()?; + let started = Instant::now(); + if before.len() > MAX_FILE as u64 { + return Err(Failure::new("OUTPUT_LIMIT", "PDF input exceeds 16 MiB")); + } + let mut bytes = Vec::new(); + file.by_ref() + .take(MAX_FILE as u64 + 1) + .read_to_end(&mut bytes)?; + let document = pdf_container::parse(&bytes)?; + if bytes[7] < b'3' { + return Err(Failure::new( + "UNSUPPORTED_DOCUMENT", + "PDF text extraction supports PDF 1.3-1.7 encoding semantics", + )); + } + let pages = pdf_pages::pages(&document, started)?; + let mut streams = Streams::new(); + let (mut observed, mut selected, mut inline_bytes) = (0usize, 0usize, 0usize); + let (mut items, mut failure, mut cut) = (Vec::new(), None, false); + for (index, page) in pages.iter().enumerate() { + let result = streams + .content(&document, page, started) + .and_then(|content| pdf_text::extract(&document, page.resources, &content, started)); + let runs = match result { + Ok(runs) => runs, + Err(error) => { + failure = Some(error); + break; + } + }; + observed += 1; + let number = index + 1; + if number < args.offset || number >= args.offset + args.limit { + continue; + } + selected += 1; + let item = json!({"page":number,"text":runs.join("\n"),"runs":runs}); + if let Some(sink) = request.output_sink.as_deref() { + sink.push(&item) + } + let cost = json_size(&item) + 1; + if cut || inline_bytes + cost > 80 * 1024 { + cut = true; + if let Some(sink) = request.output_sink.as_deref() { + sink.clipped() + } + } else { + inline_bytes += cost; + items.push(item) + } + } + let after = file.metadata()?; + if before.len() != after.len() || before.modified().ok() != after.modified().ok() { + failure = Some(Failure::new( + "FILE_CHANGED", + "PDF metadata changed while reading; page observations are not a stable snapshot", + )); + } + let scan = failure.is_none(); + let next = args.offset + selected; + let more = !scan || next <= observed; + let cause = if let Some(error) = &failure { + match error.code.as_str() { + "DEADLINE_EXCEEDED" => "time", + "FILE_CHANGED" => "changed", + "OUTPUT_LIMIT" => "scan-budget", + "UNSUPPORTED_DOCUMENT" => "unsupported-content", + _ => "parse-error", + } + } else if more { + "row-limit" + } else { + "none" + }; + let mut output = json!({"status":if scan{"completed"}else{"partial"},"phase":"verify","changed":0,"effectState":"none","path":target.display,"format":"pdf", + "scope":"page-text-layer-content-order","excludedContent":["rendered layout and reading order","images and OCR","annotations and form fields","attachments and document metadata","structure-tree alternate text"], + "bytes":before.len(),"decodedBytes":streams.decoded,"processedBytes":streams.processed,"pagesObserved":observed,"skipped":(args.offset-1).min(observed), + "scanComplete":scan,"pageComplete":page_complete(failure.as_ref(),observed,args.offset,args.limit),"complete":scan&&!more,"truncated":more,"truncatedBy":cause,"nextOffset":next,"items":[]}); + if scan { + output["total"] = json!(observed); + output["omitted"] = json!(observed.saturating_sub(next - 1)); + output["revision"] = json!(format!("{:x}", Sha256::digest(&bytes))) + } + if let Some(mut error) = failure { + error.next_action="Use a dedicated PDF reader for unsupported content or physical scan limits; page offsets do not bypass those gaps. OCR and rendered reading order are not provided.".into(); + output["nextAction"] = json!(error.next_action); + output["error"] = json!(error); + } + if let Some(sink) = request.output_sink.as_deref() { + sink.source(output.clone()) + } + if cut { + output["complete"] = json!(false); + output["pageComplete"] = json!(false); + output["truncated"] = json!(true); + output["nextOffset"] = json!(args.offset + items.len()); + if scan { + output["truncatedBy"] = json!("output-budget"); + output["omitted"] = json!(observed.saturating_sub(args.offset - 1 + items.len())) + } + if output.get("nextAction").is_none() { + output["nextAction"] = json!( + "Read the stored reference when available; otherwise reduce the page limit or use a dedicated reader for a large page." + ) + } + } + output["items"] = json!(items); + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn coverage_invalidates_changed_pages_but_retains_completed_prefix_before_later_gaps() { + let changed = Failure::new("FILE_CHANGED", "changed"); + let unsupported = Failure::new("UNSUPPORTED_DOCUMENT", "later page"); + assert!(!page_complete(Some(&changed), 12, 1, 10)); + assert!(page_complete(Some(&unsupported), 10, 1, 10)); + assert!(!page_complete(Some(&unsupported), 9, 1, 10)); + assert!(page_complete(None, 2, 30, 1)); + } + #[test] + fn expired_pdf_deadline_is_an_explicit_failure() { + let expired = Instant::now() - std::time::Duration::from_secs(30); + assert_eq!( + pdf_text::deadline(expired).unwrap_err().code, + "DEADLINE_EXCEEDED" + ); + } +} diff --git a/native/crates/ops-documents/src/pdf_pages.rs b/native/crates/ops-documents/src/pdf_pages.rs new file mode 100644 index 00000000..4951b275 --- /dev/null +++ b/native/crates/ops-documents/src/pdf_pages.rs @@ -0,0 +1,180 @@ +//! Bounded page-tree traversal and one-time stream decompression; repeated content is charged again as work. +use crate::{ + pdf_container::{Document, MAX_STREAM, ObjectId, decode, get}, + pdf_syntax::{Dict, Value, bad, limit}, + pdf_text::deadline, +}; +use ops_protocol::Result; +use std::{ + collections::{BTreeMap, BTreeSet}, + time::Instant, +}; +pub const MAX_PAGES: usize = 32; +pub const MAX_DECODED: usize = 16 * 1024 * 1024; +pub struct Page<'a> { + pub dict: &'a Dict, + pub resources: Option<&'a Value>, +} +fn reference(value: &Value) -> Result { + match value { + Value::Ref(n, g) => Ok((*n, *g)), + _ => Err(bad("PDF page/content identity must be indirect")), + } +} +pub fn pages<'a>(doc: &'a Document<'_>, started: Instant) -> Result>> { + let catalog = doc.object(doc.root)?.value.dict()?; + if get(catalog, b"Type")?.name()? != b"Catalog" { + return Err(bad("PDF Root must be a Catalog")); + } + let mut seen = BTreeSet::new(); + let mut output = Vec::new(); + walk( + doc, + reference(get(catalog, b"Pages")?)?, + None, + None, + &mut seen, + &mut output, + started, + 0, + )?; + Ok(output) +} +#[allow(clippy::too_many_arguments)] +fn walk<'a>( + doc: &'a Document<'_>, + id: ObjectId, + parent: Option, + inherited: Option<&'a Value>, + seen: &mut BTreeSet, + output: &mut Vec>, + started: Instant, + depth: usize, +) -> Result { + deadline(started)?; + if depth > 64 || seen.len() >= 10_000 { + return Err(limit("PDF page tree exceeds bounded depth or nodes")); + } + if !seen.insert(id) { + return Err(bad("Cyclic or aliased PDF page tree")); + } + let object = doc.object(id)?; + if object.stream.is_some() { + return Err(bad("PDF page tree node cannot be a stream")); + } + let dict = object.value.dict()?; + if let Some(parent) = parent { + if reference(get(dict, b"Parent")?)? != parent { + return Err(bad("PDF page Parent disagrees with page tree")); + } + } else if dict.contains_key(b"Parent".as_slice()) { + return Err(bad("PDF page-tree root cannot have a Parent")); + } + let resources = dict.get(b"Resources".as_slice()).or(inherited); + match get(dict, b"Type")?.name()? { + b"Page" => { + if output.len() >= MAX_PAGES { + return Err(limit( + "PDF physical scan exceeds 32 pages; paging does not bypass this bound", + )); + } + output.push(Page { dict, resources }); + Ok(1) + } + b"Pages" => { + let expected = get(dict, b"Count")?.integer()?; + if expected > MAX_PAGES { + return Err(limit( + "PDF physical scan exceeds 32 pages; paging does not bypass this bound", + )); + } + let Value::Array(kids) = get(dict, b"Kids")? else { + return Err(bad("PDF Pages Kids must be an array")); + }; + let mut count = 0; + for kid in kids { + count += walk( + doc, + reference(kid)?, + Some(id), + resources, + seen, + output, + started, + depth + 1, + )?; + } + if count != expected { + return Err(bad("PDF page Count disagrees with observed tree")); + } + Ok(count) + } + _ => Err(bad("PDF page tree node has an invalid Type")), + } +} +pub struct Streams { + cache: BTreeMap>, + pub decoded: usize, + pub processed: usize, +} +impl Streams { + pub fn new() -> Self { + Self { + cache: BTreeMap::new(), + decoded: 0, + processed: 0, + } + } + fn append( + &mut self, + doc: &Document<'_>, + value: &Value, + output: &mut Vec, + started: Instant, + ) -> Result<()> { + deadline(started)?; + let id = reference(value)?; + if !self.cache.contains_key(&id) { + let bytes = decode(doc.object(id)?)?; + deadline(started)?; + if self.decoded + bytes.len() > MAX_DECODED { + return Err(limit("PDF aggregate decoded streams exceed 16 MiB")); + } + self.decoded += bytes.len(); + self.cache.insert(id, bytes); + } + let bytes = &self.cache[&id]; + if self.processed + bytes.len() > MAX_DECODED || output.len() + bytes.len() > MAX_STREAM { + return Err(limit( + "PDF processed content exceeds 16 MiB total or 1 MiB per page", + )); + } + self.processed += bytes.len(); + output.extend_from_slice(bytes); + Ok(()) + } + pub fn content( + &mut self, + doc: &Document<'_>, + page: &Page<'_>, + started: Instant, + ) -> Result> { + let mut output = Vec::new(); + let Some(value) = page.dict.get(b"Contents".as_slice()) else { + return Ok(output); + }; + match value { + Value::Array(values) => { + if values.len() > 64 { + return Err(limit("PDF page exceeds 64 content streams")); + } + for value in values { + self.append(doc, value, &mut output, started)? + } + } + Value::Null => {} + value => self.append(doc, value, &mut output, started)?, + } + Ok(output) + } +} diff --git a/native/crates/ops-documents/src/pdf_text.rs b/native/crates/ops-documents/src/pdf_text.rs new file mode 100644 index 00000000..ed2db95a --- /dev/null +++ b/native/crates/ops-documents/src/pdf_text.rs @@ -0,0 +1,321 @@ +//! PDF text operators for standard Latin Type1 fonts. No glyph substitution or layout inference. +use crate::{ + pdf_container::{Document, get}, + pdf_syntax::{Dict, Lexer, Value, bad, limit, unsupported}, +}; +use ops_protocol::{Failure, Result}; +use std::time::{Duration, Instant}; +pub const MAX_TEXT: usize = 1024 * 1024; +pub fn deadline(started: Instant) -> Result<()> { + if started.elapsed() >= Duration::from_secs(30) { + return Err(Failure::new( + "DEADLINE_EXCEEDED", + "PDF reading reached its 30-second deadline", + )); + } + Ok(()) +} +#[derive(Clone, Copy)] +enum Encoding { + Standard, + WinAnsi, +} +fn font(doc: &Document<'_>, resources: &Dict, name: &[u8]) -> Result { + let fonts = doc.resolve(get(resources, b"Font")?)?.dict()?; + let font = doc.resolve(get(fonts, name)?)?.dict()?; + if get(font, b"Type")?.name()? != b"Font" || get(font, b"Subtype")?.name()? != b"Type1" { + return Err(unsupported("PDF text requires standard Latin Type1 fonts")); + } + if font.contains_key(b"ToUnicode".as_slice()) || font.contains_key(b"FontDescriptor".as_slice()) + { + return Err(unsupported( + "PDF embedded fonts and ToUnicode mappings are unsupported", + )); + } + if !matches!( + get(font, b"BaseFont")?.name()?, + b"Helvetica" + | b"Helvetica-Bold" + | b"Helvetica-Oblique" + | b"Helvetica-BoldOblique" + | b"Courier" + | b"Courier-Bold" + | b"Courier-Oblique" + | b"Courier-BoldOblique" + | b"Times-Roman" + | b"Times-Bold" + | b"Times-Italic" + | b"Times-BoldItalic" + ) { + return Err(unsupported( + "PDF font is outside the standard Helvetica/Courier/Times families", + )); + } + match font.get(b"Encoding".as_slice()) { + None => Ok(Encoding::Standard), + Some(Value::Name(name)) if name == b"WinAnsiEncoding" => Ok(Encoding::WinAnsi), + _ => Err(unsupported( + "PDF font requires built-in StandardEncoding or named WinAnsiEncoding; differences and other encodings are unsupported", + )), + } +} +fn text(bytes: &[u8], encoding: Encoding) -> Result { + // ISO 32000-1 Annex D: WinAnsi differs from a generic CP1252 decoder at bullet/space/hyphen slots. + const C1: [char; 32] = [ + '€', '•', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '•', 'Ž', '•', '•', '‘', + '’', '“', '”', '•', '–', '—', '˜', '™', 'š', '›', 'œ', '•', 'ž', 'Ÿ', + ]; + let mut output = String::new(); + for &byte in bytes { + let c = match encoding { + Encoding::Standard => match byte { + 39 => '’', + 96 => '‘', + 32..=126 => byte as char, + _ => { + return Err(unsupported( + "PDF StandardEncoding character is outside the supported printable Latin subset", + )); + } + }, + Encoding::WinAnsi => match byte { + 32..=126 => byte as char, + 127 => '•', + 128..=159 => C1[(byte - 128) as usize], + 160 => ' ', + 173 => '-', + 161..=255 => byte as char, + _ => { + return Err(unsupported( + "PDF WinAnsi character has no supported glyph mapping", + )); + } + }, + }; + if output.len() + c.len_utf8() > MAX_TEXT { + return Err(limit("PDF text run exceeds 1 MiB")); + } + output.push(c); + } + Ok(output) +} +fn numeric(value: &Value) -> bool { + matches!(value, Value::Integer(_) | Value::Number(_)) +} +fn numbers(args: &[Value], count: usize) -> Result<()> { + if args.len() != count || !args.iter().all(numeric) { + return Err(bad("Invalid PDF numeric operator arguments")); + } + Ok(()) +} +fn no_args(args: &[Value]) -> Result<()> { + numbers(args, 0) +} +fn string(value: &Value, encoding: Encoding) -> Result { + match value { + Value::String(bytes) => text(bytes, encoding), + _ => Err(bad("PDF text-showing operand is not a string")), + } +} +pub fn extract( + doc: &Document<'_>, + resources: Option<&Value>, + bytes: &[u8], + started: Instant, +) -> Result> { + if bytes.iter().all(|b| crate::pdf_syntax::white(*b)) { + return Ok(Vec::new()); + } + let mut lexer = Lexer::new(bytes); + lexer.skip(); + if lexer.at == bytes.len() { + return Ok(Vec::new()); + } + let mut args = Vec::new(); + let (mut inside, mut selected, mut stack) = (false, None, Vec::new()); + let (mut runs, mut text_bytes) = (Vec::new(), 0usize); + loop { + deadline(started)?; + lexer.skip(); + if lexer.at == bytes.len() { + break; + } + let b = bytes[lexer.at]; + if b.is_ascii_digit() || b"+-./(<[".contains(&b) { + if args.len() >= 64 { + return Err(limit("PDF operator exceeds 64 operands")); + } + args.push(lexer.value(0)?); + continue; + } + let op = lexer.word()?; + if inside + && !matches!( + op, + b"ET" + | b"Tf" + | b"Tj" + | b"TJ" + | b"'" + | b"\"" + | b"T*" + | b"Td" + | b"TD" + | b"Tm" + | b"Tc" + | b"Tw" + | b"Tz" + | b"TL" + | b"Ts" + | b"Tr" + | b"BT" + ) + { + return Err(bad("Non-text PDF operator inside a text object")); + } + let mut shown = None; + match op { + b"BT" => { + no_args(&args)?; + if inside { + return Err(bad("Nested PDF text objects")); + } + inside = true + } + b"ET" => { + no_args(&args)?; + if !inside { + return Err(bad("PDF ET outside text object")); + } + inside = false + } + b"Tf" => { + if !inside || args.len() != 2 || !numeric(&args[1]) { + return Err(bad("Invalid PDF Tf text state")); + } + let resources = doc + .resolve(resources.ok_or_else(|| bad("Missing PDF page Resources"))?)? + .dict()?; + selected = Some(font(doc, resources, args[0].name()?)?); + } + b"Tj" | b"TJ" | b"'" | b"\"" => { + if !inside { + return Err(bad("PDF text show outside text object")); + } + let encoding = selected + .ok_or_else(|| bad("PDF text show before a supported font is selected"))?; + shown = Some(match op { + b"TJ" => { + if args.len() != 1 { + return Err(bad("Invalid PDF TJ arguments")); + } + let Value::Array(parts) = &args[0] else { + return Err(bad("PDF TJ operand is not an array")); + }; + let mut run = String::new(); + for part in parts { + if numeric(part) { + continue; + } + let fragment = string(part, encoding)?; + if run.len() + fragment.len() > MAX_TEXT { + return Err(limit("PDF text run exceeds 1 MiB")); + } + run.push_str(&fragment); + } + run + } + b"\"" => { + if args.len() != 3 { + return Err(bad("Invalid PDF quote arguments")); + } + numbers(&args[..2], 2)?; + string(&args[2], encoding)? + } + _ => { + if args.len() != 1 { + return Err(bad("Invalid PDF text-show arguments")); + } + string(&args[0], encoding)? + } + }); + } + b"T*" | b"Td" | b"TD" | b"Tm" | b"Tc" | b"Tw" | b"Tz" | b"TL" | b"Ts" | b"Tr" => { + if !inside { + return Err(bad("PDF text positioning outside text object")); + } + let count = match op { + b"T*" => 0, + b"Td" | b"TD" => 2, + b"Tm" => 6, + _ => 1, + }; + numbers(&args, count)?; + if op == b"Tr" && !matches!(args[0], Value::Integer(0..=7)) { + return Err(bad("Invalid PDF text rendering mode")); + } + } + b"q" => { + no_args(&args)?; + if inside { + return Err(unsupported("PDF q inside a text object is unsupported")); + } + if stack.len() >= 64 { + return Err(limit("PDF graphics state exceeds depth 64")); + } + stack.push(selected) + } + b"Q" => { + no_args(&args)?; + if inside { + return Err(unsupported("PDF Q inside a text object is unsupported")); + } + selected = stack.pop().ok_or_else(|| bad("Unbalanced PDF Q"))? + } + b"cm" | b"c" => numbers(&args, 6)?, + b"v" | b"y" | b"re" | b"k" | b"K" => numbers(&args, 4)?, + b"rg" | b"RG" => numbers(&args, 3)?, + b"m" | b"l" => numbers(&args, 2)?, + b"w" | b"J" | b"j" | b"M" | b"i" | b"g" | b"G" => numbers(&args, 1)?, + b"h" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n" + | b"W" | b"W*" => no_args(&args)?, + b"d" => { + if args.len() != 2 + || !numeric(&args[1]) + || !matches!(&args[0],Value::Array(v) if v.iter().all(numeric)) + { + return Err(bad("Invalid PDF dash arguments")); + } + } + b"ri" => { + if args.len() != 1 { + return Err(bad("Invalid PDF rendering intent arguments")); + } + args[0].name()?; + } + _ => { + return Err(unsupported(format!( + "Unsupported PDF content operator {}; forms, images, marked content and external graphics state are not traversed", + String::from_utf8_lossy(op) + ))); + } + } + if let Some(run) = shown { + if runs.len() >= 10_000 || text_bytes + run.len() + 1 > MAX_TEXT { + return Err(limit("PDF page text exceeds 1 MiB or 10000 runs")); + } + text_bytes += run.len() + 1; + runs.push(run); + } + args.clear(); + } + if inside || !stack.is_empty() || !args.is_empty() { + return Err(bad("Unclosed PDF text/graphics state or dangling operands")); + } + if runs.iter().all(String::is_empty) { + return Err(unsupported( + "PDF content has no supported text layer; OCR is not provided", + )); + } + Ok(runs) +} diff --git a/native/crates/ops-results/src/spill.rs b/native/crates/ops-results/src/spill.rs index a3b25fe0..6f40a039 100644 --- a/native/crates/ops-results/src/spill.rs +++ b/native/crates/ops-results/src/spill.rs @@ -19,7 +19,7 @@ fn mark_head(request: &Request, value: &mut Value) { let skipped = if request.tool == "replace" { 0 } else { value["skipped"].as_u64().unwrap_or(0) }; if let Some(total) = value["total"].as_u64() { value["omitted"] = json!(total.saturating_sub(skipped)); } if value.get("nextOffset").is_some() { - value["nextOffset"] = json!(if matches!(request.tool.as_str(), "table_read" | "doc_read") { skipped + 1 } else if request.tool == "inspect_local_file" { + value["nextOffset"] = json!(if matches!(request.tool.as_str(), "table_read" | "doc_read" | "pdf_read") { skipped + 1 } else if request.tool == "inspect_local_file" { if request.args["view"] == "hex" { skipped / 16 + 1 } else { skipped + 1 } } else { skipped }); } diff --git a/native/src/lib.rs b/native/src/lib.rs index e4a49714..7cf1e3a2 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -33,7 +33,7 @@ pub fn dispatch(request: Request) -> Result { Ok(ops_results::spill::finish(&request, value)) } fn execute(dir: &Dir, request: &Request) -> Result { - if matches!(request.tool.as_str(), "table_read" | "doc_read") { return ops_documents::dispatch(request); } + if matches!(request.tool.as_str(), "table_read" | "doc_read" | "pdf_read") { return ops_documents::dispatch(request); } // File reads/searches do not create or consult the durable audit store. // This is important for read-only sessions and keeps observation separate // from mutation audit records. diff --git a/native/src/main.rs b/native/src/main.rs index 6465cefd..20c0ffbd 100644 --- a/native/src/main.rs +++ b/native/src/main.rs @@ -4,7 +4,7 @@ use serde_json::json; use std::io::{Read, Write}; fn may_mutate(request: &Request) -> bool { - !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "table_read" | "doc_read" | "glob" | "grep" | "diff" | "stat") + !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "table_read" | "doc_read" | "pdf_read" | "glob" | "grep" | "diff" | "stat") && !(request.tool == "result" && request.args.get("action").and_then(serde_json::Value::as_str) == Some("page")) } fn main() { diff --git a/test/lib/pdf-fixture.mjs b/test/lib/pdf-fixture.mjs new file mode 100644 index 00000000..8835bbe5 --- /dev/null +++ b/test/lib/pdf-fixture.mjs @@ -0,0 +1,36 @@ +/** Small real classic-xref PDFs. Offsets/stream lengths count bytes; no external PDF package is needed. */ +import { deflateSync } from 'node:zlib' +const bytes = value => Buffer.isBuffer(value) ? value : Buffer.from(value, 'latin1') +export function pdf(objects, { version = '1.4', trailer = '' } = {}) { + const chunks = [Buffer.from('%PDF-' + version + '\n%\xe2\xe3\xcf\xd3\n', 'latin1')], offsets = [0] + let offset = chunks[0].length + for (const [index, value] of objects.entries()) { + offsets.push(offset) + const item = Buffer.concat([Buffer.from(String(index + 1) + ' 0 obj\n'), bytes(value), Buffer.from('\nendobj\n')]) + chunks.push(item); offset += item.length + } + const xref = 'xref\n0 ' + offsets.length + '\n0000000000 65535 f \n' + offsets.slice(1).map(at => String(at).padStart(10, '0') + ' 00000 n \n').join('') + chunks.push(Buffer.from(xref + 'trailer\n<< /Size ' + offsets.length + ' /Root 1 0 R ' + trailer + ' >>\nstartxref\n' + offset + '\n%%EOF\n')) + return Buffer.concat(chunks) +} +export function stream(content, { deflate = false, extra = '', length, packed } = {}) { + const encoded = packed ?? (deflate ? deflateSync(bytes(content)) : bytes(content)) + return Buffer.concat([Buffer.from('<< /Length ' + (length ?? encoded.length) + (deflate ? ' /Filter /FlateDecode' : '') + ' ' + extra + ' >>\nstream\n'), encoded, Buffer.from('\nendstream')]) +} +export function show(value) { + let text = '' + for (const byte of bytes(value)) text += byte < 32 || byte > 126 ? '\\' + byte.toString(8).padStart(3, '0') : [40, 41, 92].includes(byte) ? '\\' + String.fromCharCode(byte) : String.fromCharCode(byte) + return 'BT /F1 12 Tf 72 720 Td (' + text + ') Tj ET\n' +} +export function textPdf(pages, options = {}) { + const objects = [ + '<< /Type /Catalog /Pages 2 0 R ' + (options.catalogExtra ?? '') + ' >>', + '<< /Type /Pages /Kids [' + pages.map((_, i) => String(4 + i * 2) + ' 0 R').join(' ') + '] /Count ' + pages.length + ' >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /' + (options.font ?? 'Helvetica') + (options.encoding === null ? '' : ' /Encoding /' + (options.encoding ?? 'WinAnsiEncoding')) + ' ' + (options.fontExtra ?? '') + ' >>', + ] + for (const [i, content] of pages.entries()) { + objects.push('<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents ' + (5 + i * 2) + ' 0 R ' + (options.pageExtra ?? '') + ' >>') + objects.push(stream(content, options)) + } + return pdf(objects, options) +} diff --git a/test/package-source.test.mjs b/test/package-source.test.mjs index 51b51554..034fa4d0 100644 --- a/test/package-source.test.mjs +++ b/test/package-source.test.mjs @@ -20,6 +20,8 @@ await test('npm pack includes compiled host code and native sources, never host 'native/crates/ops-documents/src/tests.rs', 'native/crates/ops-documents/src/archive.rs', 'native/crates/ops-documents/src/xml.rs', 'native/crates/ops-documents/src/docx.rs', 'native/crates/ops-documents/src/pdf_syntax.rs', + 'native/crates/ops-documents/src/pdf.rs', 'native/crates/ops-documents/src/pdf_text.rs', + 'native/crates/ops-documents/src/pdf_pages.rs', 'native/crates/ops-documents/src/pdf_container.rs', 'native/crates/ops-documents/src/pdf_container_tests.rs', 'licenses/documents/THIRD-PARTY-NOTICES.txt'] for (const name of required) assert.ok(files.includes(name), `missing source-build dependency: ${name}`) diff --git a/test/pdf-worker.test.mjs b/test/pdf-worker.test.mjs new file mode 100644 index 00000000..0ebe0db5 --- /dev/null +++ b/test/pdf-worker.test.mjs @@ -0,0 +1,162 @@ +/** Real PDF worker observations: supported text scope, honest gaps, bounded work and authority. */ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { callWorker, request, envelopeOf, requireWorker } from './lib/boundary.mjs' +import { pdf, stream, show, textPdf } from './lib/pdf-fixture.mjs' +requireWorker('pdf-worker') +const fileAt = (root, value, name = 'document.pdf') => { const file = path.join(root, name); fs.writeFileSync(file, value); return file } +async function run(root, args, options = {}) { + const payload = { version: 1, requestId: 'pdf-test', component: 'file', budget: { timeoutMs: 30000, maxBytes: options.maxBytes ?? 131072 }, + ...(options.cache ? { storage: { root: options.cache, authorityId: 'a'.repeat(64), allowedTools: ['pdf_read', 'result'] } } : {}), + request: { ...request(options.tool ?? 'pdf_read', args, { root, readOnly: options.readOnly, writeRoots: [] }), ...options.authority } } + const raw = await callWorker(JSON.stringify(payload)); assert.ok(Buffer.byteLength(raw.stdout) <= payload.budget.maxBytes) + return envelopeOf(raw, assert) +} +function failure(body, code) { assert.equal(body.error?.code, code, JSON.stringify(body)); assert.equal(body.error.effectState, 'none'); assert.notEqual(body.complete, true); assert.equal(body.total, undefined); assert.equal(body.revision, undefined) } +const catalog = '<< /Type /Catalog /Pages 2 0 R >>' +const font = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>' +const page = content => '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents ' + content + ' >>' +function padded(size, label = 'ok') { const prefix = show(label); return prefix + '%' + 'x'.repeat(size - Buffer.byteLength(prefix) - 2) + '\n' } + +await test('raw and Flate PDF pages preserve literal/hex/TJ runs and exact WinAnsi Unicode', async () => { + await withTempDir('pdf-text', async root => { + const content = 'BT /F1 12 Tf (A\\(B\\)\\\\C) Tj [<436166e9> -120 ( ) 5 <80>] TJ T* (next) \' 0 0 (last) " ET\n' + for (const deflate of [false, true]) { + const bytes = textPdf([content], { deflate }), body = await run(root, { path: fileAt(root, bytes) }) + assert.equal(body.error, undefined); assert.equal(body.status, 'completed'); assert.equal(body.complete, true) + assert.equal(body.scope, 'page-text-layer-content-order'); assert.ok(body.excludedContent.includes('images and OCR')) + assert.deepEqual(body.items, [{ page: 1, text: 'A(B)\\C\nCafé €\nnext\nlast', runs: ['A(B)\\C', 'Café €', 'next', 'last'] }]) + assert.equal(body.total, 1); assert.equal(body.revision, createHash('sha256').update(bytes).digest('hex')); assert.equal(body.nextOffset, 2) + } + const aliases = await run(root, { path: fileAt(root, textPdf([show(Buffer.from([127,129,141,143,144,157,160,173,159]))])) }) + assert.equal(aliases.items[0].text, '•••••• -Ÿ') + const standard = await run(root, { path: fileAt(root, textPdf([show("'quote" + String.fromCharCode(96))], { encoding: null })) }) + assert.equal(standard.items[0].text, '’quote‘') + }) +}) +await test('page offsets have complete-scan totals, ten-page default and honest blank/empty documents', async () => { + await withTempDir('pdf-pages', async root => { + const file = fileAt(root, textPdf(Array.from({ length: 12 }, (_, i) => show('Page ' + (i + 1))))) + const first = await run(root, { path: file }); assert.equal(first.items.length, 10); assert.equal(first.nextOffset, 11); assert.equal(first.total, 12); assert.equal(first.scanComplete, true); assert.equal(first.pageComplete, true); assert.equal(first.complete, false) + const last = await run(root, { path: file, offset: 11, limit: 2 }); assert.deepEqual(last.items.map(i => i.page), [11, 12]); assert.equal(last.complete, true); assert.equal(last.revision, first.revision) + const blank = await run(root, { path: fileAt(root, textPdf([' \n\t'])) }); assert.deepEqual(blank.items, [{ page: 1, text: '', runs: [] }]); assert.equal(blank.complete, true) + const empty = await run(root, { path: fileAt(root, textPdf([])) }); assert.equal(empty.total, 0); assert.deepEqual(empty.items, []); assert.equal(empty.complete, true) + for (const args of [{ offset: 0 }, { offset: 33 }, { limit: 0 }, { limit: 33 }, { unknown: true }]) failure(await run(root, { path: file, ...args }), 'INVALID_ARGUMENT') + }) +}) +await test('unsupported later operators keep preceding complete pages without inventing a total or cursor', async () => { + await withTempDir('pdf-gaps', async root => { + for (const content of ['/X1 Do', 'BI /W 1 /H 1 ID x EI', '/Tag BMC EMC', '/GS1 gs', '0 0 m 1 1 l S', 'BX weird EX']) { + const body = await run(root, { path: fileAt(root, textPdf([show('safe'), content, show('unobserved')])), limit: 1 }) + failure(body, 'UNSUPPORTED_DOCUMENT'); assert.equal(body.status, 'partial'); assert.equal(body.scanComplete, false); assert.equal(body.pageComplete, true) + assert.equal(body.pagesObserved, 1); assert.deepEqual(body.items.map(i => i.text), ['safe']); assert.match(body.nextAction, /paging|offsets|gaps/i); assert.equal(body.continuation, undefined) + } + }) +}) +await test('unsupported fonts mappings controls and older encoding semantics fail explicitly', async () => { + await withTempDir('pdf-fonts', async root => { + for (const options of [{ font: 'Symbol' }, { font: 'Arial' }, { encoding: 'MacRomanEncoding' }, { fontExtra: '/ToUnicode 3 0 R' }, { fontExtra: '/FontDescriptor 3 0 R' }, { version: '1.2' }]) { + failure(await run(root, { path: fileAt(root, textPdf([show('text')], options)) }), 'UNSUPPORTED_DOCUMENT') + } + failure(await run(root, { path: fileAt(root, textPdf([show(Buffer.from([0xe9]))], { encoding: null })) }), 'UNSUPPORTED_DOCUMENT') + failure(await run(root, { path: fileAt(root, textPdf([show(Buffer.from([0,10]))])) }), 'UNSUPPORTED_DOCUMENT') + }) +}) +await test('malformed content cannot be accepted as complete text', async () => { + await withTempDir('pdf-grammar', async root => { + for (const content of ['BT BT ET ET', '(x) Tj', 'ET', 'BT /F1 Tf ET', 'BT /F1 12 Tf [(x) /bad] TJ ET', 'BT /F1 12 Tf (x) Tj', 'q', 'Q', '1 2', 'BT /F1 12 Tf 8 Tr (x) Tj ET', 'BT /F1 12 Tf Tj ET']) { + failure(await run(root, { path: fileAt(root, textPdf([content])) }), 'DOCUMENT_PARSE_ERROR') + } + }) +}) +await test('page counts aliases cycles parents and physical page limits are enforced before text traversal', async () => { + await withTempDir('pdf-tree', async root => { + for (const tree of ['<< /Type /Pages /Kids [4 0 R] /Count 2 >>', '<< /Type /Pages /Kids [4 0 R 4 0 R] /Count 2 >>', '<< /Type /Pages /Kids [2 0 R] /Count 1 >>']) { + failure(await run(root, { path: fileAt(root, pdf([catalog, tree, font, page('5 0 R'), stream(show('safe'))])) }), 'DOCUMENT_PARSE_ERROR') + } + failure(await run(root, { path: fileAt(root, pdf([catalog, '<< /Type /Pages /Kids [4 0 R] /Count 1 >>', font, page('5 0 R').replace('/Parent 2', '/Parent 1'), stream(show('safe'))])) }), 'DOCUMENT_PARSE_ERROR') + failure(await run(root, { path: fileAt(root, textPdf(Array.from({ length: 33 }, () => show('x')))), limit: 1 }), 'OUTPUT_LIMIT') + }) +}) +await test('raw file and actual decoded stream limits are independent of advertised compressed sizes', async () => { + await withTempDir('pdf-bounds', async root => { + failure(await run(root, { path: fileAt(root, Buffer.alloc(16 * 1024 * 1024 + 1)) }), 'OUTPUT_LIMIT') + for (const deflate of [false, true]) failure(await run(root, { path: fileAt(root, textPdf([padded(1024 * 1024 + 1)], { deflate })) }), 'OUTPUT_LIMIT') + failure(await run(root, { path: fileAt(root, textPdf([show('x')], { deflate: true, extra: '/DecodeParms << /Predictor 12 >>' })) }), 'UNSUPPORTED_DOCUMENT') + }) +}) +await test('unique decoded streams and repeated shared work have separate aggregate caps', async () => { + await withTempDir('pdf-aggregate', async root => { + const content = padded(1024 * 1024) + const unique = await run(root, { path: fileAt(root, textPdf(Array(17).fill(content), { deflate: true })), limit: 1 }) + failure(unique, 'OUTPUT_LIMIT'); assert.equal(unique.pagesObserved, 16); assert.equal(unique.decodedBytes, 16 * 1024 * 1024); assert.equal(unique.items[0].text, 'ok') + const objects = [catalog, '<< /Type /Pages /Kids [' + Array.from({ length: 17 }, (_, i) => (5 + i) + ' 0 R').join(' ') + '] /Count 17 >>', font, stream(content, { deflate: true }), ...Array(17).fill(page('4 0 R'))] + const shared = await run(root, { path: fileAt(root, pdf(objects)), limit: 1 }) + failure(shared, 'OUTPUT_LIMIT'); assert.equal(shared.pagesObserved, 16); assert.equal(shared.decodedBytes, 1024 * 1024); assert.equal(shared.processedBytes, 16 * 1024 * 1024) + const array = [catalog, '<< /Type /Pages /Kids [4 0 R] /Count 1 >>', font, page('[5 0 R 6 0 R]'), stream(padded(600000), { deflate: true }), stream(padded(600000), { deflate: true })] + failure(await run(root, { path: fileAt(root, pdf(array)) }), 'OUTPUT_LIMIT') + }) +}) +await test('content arrays concatenate in order and inherited resources resolve without a second decoding', async () => { + await withTempDir('pdf-inherit', async root => { + const objects = [catalog, '<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /Resources << /Font << /F1 3 0 R >> >> >>', font, + '<< /Type /Page /Parent 2 0 R /Contents [6 0 R 7 0 R] >>', '<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>', stream(show('first'), { deflate: true }), stream(show('second'), { deflate: true })] + const body = await run(root, { path: fileAt(root, pdf(objects)) }) + assert.equal(body.complete, true); assert.deepEqual(body.items.map(i => i.runs), [['first', 'second'], ['first']]) + assert.equal(body.decodedBytes, Buffer.byteLength(show('first') + show('second'))); assert.equal(body.processedBytes, body.decodedBytes + Buffer.byteLength(show('first'))) + }) +}) +await test('read-only PDF observations create nothing and repeat credential/no-follow checks', async () => { + await withTempDir('pdf-authority', async root => { + const file = fileAt(root, textPdf([show('safe')])), before = fs.readdirSync(root) + assert.equal((await run(root, { path: file }, { readOnly: true })).complete, true); assert.deepEqual(fs.readdirSync(root), before) + failure(await run(root, { path: file }, { authority: { deny_roots: [root] } }), 'CREDENTIAL_DENIED') + const target = path.join(root, 'target'); fs.mkdirSync(target); const hidden = fileAt(target, textPdf([show('secret')])) + const alias = path.join(root, 'alias'); fs.symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + const denied = await run(root, { path: path.join(alias, path.basename(hidden)) }); assert.ok(denied.error); assert.equal(denied.error.effectState, 'none'); assert.equal(denied.items, undefined) + }) +}) +await test('reference spill reconstructs complete PDF pages and retains source coverage and root', async () => { + await withTempDir('pdf-spill', async root => { + const cache = path.join(root, 'cache'); fs.mkdirSync(cache, { mode: 0o700 }) + const value = Buffer.alloc(30000, 0xe9), expected = 'é'.repeat(30000) + const file = fileAt(root, textPdf([show(value), show('last')], { deflate: true })) + const head = await run(root, { path: file }, { cache, maxBytes: 4096 }) + assert.ok(head.reference, JSON.stringify(head)); assert.equal(head.complete, false); assert.equal(head.nextOffset, 1) + assert.equal(head.referenceRoot, process.platform === 'win32' ? path.toNamespacedPath(fs.realpathSync.native(root)) : fs.realpathSync(root)) + const records = []; let offset = 0, source + for (;;) { const next = await run(head.referenceRoot, { action: 'page', id: head.reference.refId, offset, limit: 512, maxBytes: 65536 }, { cache, tool: 'result', readOnly: true }); assert.equal(next.error, undefined); records.push(...next.items); source = next.source; assert.ok(next.nextOffset > offset); offset = next.nextOffset; if (next.complete) break } + assert.equal(source.scope, 'page-text-layer-content-order'); assert.equal(source.scanComplete, true); assert.equal(source.captureComplete, true); assert.equal(source.total, 2) + const rows = []; let chunks = [], byteOffset = 0 + for (const item of records) { assert.equal(item.index, rows.length); if (item.kind === 'item') { rows.push(item.value); continue } assert.equal(item.chunk, chunks.length); assert.equal(item.byteOffset, byteOffset); chunks.push(item.text); byteOffset += Buffer.byteLength(item.text); if (item.complete) { rows.push(JSON.parse(chunks.join(''))); chunks = []; byteOffset = 0 } } + assert.deepEqual(rows.map(row => row.text), [expected, 'last']); assert.equal(chunks.length, 0); assert.deepEqual(rows[0].runs, [expected]) + const readonly = await run(root, { path: file }, { cache, readOnly: true, maxBytes: 4096 }); assert.equal(readonly.reference, undefined); assert.equal(readonly.storageError.code, 'POLICY_DENIED'); assert.equal(readonly.complete, false) + }) +}) +await test('transport output limit still reports no business effects in writable sessions', async () => { + await withTempDir('pdf-transport', async root => { failure(await run(root, { path: fileAt(root, textPdf([show('x'.repeat(30000))])) }, { maxBytes: 1024 }), 'OUTPUT_LIMIT') }) +}) +await test('out-of-range offsets count actual skips and sparse blank pages remain empty', async () => { + await withTempDir('pdf-sparse', async root => { + const file=fileAt(root,textPdf([show('one'),show('two')])) + const beyond=await run(root,{path:file,offset:32,limit:1}) + assert.equal(beyond.skipped,2);assert.equal(beyond.total,2);assert.deepEqual(beyond.items,[]);assert.equal(beyond.complete,true) + const blanks=[catalog,'<< /Type /Pages /Kids [4 0 R 5 0 R 6 0 R] /Count 3 >>',font, + '<< /Type /Page /Parent 2 0 R >>',page('null'),page('7 0 R'),stream('% comment only\n')] + const body=await run(root,{path:fileAt(root,pdf(blanks))}) + assert.equal(body.complete,true);assert.deepEqual(body.items.map(item=>item.runs),[[],[],[]]) + failure(await run(root,{path:fileAt(root,textPdf(['BT /F1 12 Tf (x) Tj 0 0 m ET']))}),'DOCUMENT_PARSE_ERROR') + }) +}) +await test('stream-valued font and resource dictionaries cannot be interpreted as plain values', async () => { + await withTempDir('pdf-stream-dicts',async root=>{ + const tree='<< /Type /Pages /Kids [4 0 R] /Count 1 >>' + const fontStream=stream('',{extra:'/Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding'}) + failure(await run(root,{path:fileAt(root,pdf([catalog,tree,fontStream,page('5 0 R'),stream(show('text'))]))}),'DOCUMENT_PARSE_ERROR') + const resourcePage='<< /Type /Page /Parent 2 0 R /Resources 6 0 R /Contents 5 0 R >>' + failure(await run(root,{path:fileAt(root,pdf([catalog,tree,font,resourcePage,stream(show('text')),stream('',{extra:'/Font << /F1 3 0 R >>'})]))}),'DOCUMENT_PARSE_ERROR') + }) +}) +report('pdf-worker') From 26b861d1b32775976587a22e4fc560cced1057d8 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:53:51 +0800 Subject: [PATCH 056/118] feat(documents): expose scoped PDF text readers --- docs/CHANGELOG.md | 4 +- docs/native-kernel.md | 11 ++- ...37\347\224\237\345\206\205\346\240\270.md" | 8 +- dsh-plugin.json | 3 +- src/capabilities/discovery.ts | 1 + src/capabilities/documents.ts | 13 ++- src/capabilities/registry.ts | 2 +- src/modes/native/documents.ts | 5 +- src/modes/ptc/continuation.ts | 4 +- test/capabilities.test.mjs | 6 +- test/discovery.test.mjs | 4 +- test/pdf-host.test.mjs | 95 +++++++++++++++++++ test/types/ptc-sdk.ts | 16 ++++ 13 files changed, 155 insertions(+), 17 deletions(-) create mode 100644 test/pdf-host.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c586453d..c0e61f85 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,8 +19,10 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm with explicit exclusions, per-stream/page/aggregate limits and no OCR or layout claims. - Preserve preceding complete pages on unsupported content, keep totals/revision conditional on stable full scans, and capture full pages before bounded reference spill. +- Publish discoverable-only Native PDF text and typed PTC page/run outcomes, retaining source scope, + safe failed prefixes, forward stable-scan continuations and reference recovery metadata. - 新增受限 PDF 文本页及 run 读取,明确排除内容和累计预算;保留此前完整页, - 仅完整稳定扫描提供总数/修订,复用只读权限与无损引用,不声称 OCR 或阅读布局。 + 仅完整稳定扫描提供总数/修订,复用只读权限与无损引用,不声称 OCR 或阅读布局。Native/PTC 按代理按需启用,保留正文缺口与类型化页数据。 ### Add bounded PDF container foundation (#261, 15c/16) diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 79125560..fbcf31f8 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -254,8 +254,8 @@ introduced together in the following PDF extraction slice. The one-shot worker accepts strict pdf_read { path, offset?, limit? }. The host resolves path from its current directory. Offset is a 1-based page number (1–32, default 1); -limit is 1–32 pages (default 10). This remains an internal worker capability until the -separate discovery/Native/PTC adapters are connected; no host tool is registered here. +limit is 1–32 pages (default 10). The capability stays outside the default schema. Native selects it +through `ops_discover`, PTC through `ops_sdk`; it activates at the owning agent's next step. Each item is { page, text, runs: string[] }. Runs retain individual PDF text-show operations; TJ spacing numbers are not characters. Text joins runs with newlines for @@ -291,3 +291,10 @@ sink captures whole pages before clipping, and stored pages retain original scop scan/capture completeness. A reference page being complete does not fill a source gap. Read-only, credential denial, no-follow opens and no-business-effect failures use the same worker authority checks as the other document readers. + +Native shows page numbers and escaped text with the explicit text-layer scope and exclusions. +PTC generated declarations retain page, text and string[] runs; unsupported or malformed observations +keep canonical prefix data inside failure Outcomes without a looping page continuation. Stable full +scans alone permit forward rescan pages, with revisions compared before combining results. References +retain typed original page coverage, decoded/processed work, scope and capture facts. All reads use +the same live authority, credentials, no-follow, shared budgets and cancellation path as file tools. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index e62fd206..de36bc3e 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -203,7 +203,7 @@ FILE_CHANGED 始终使所请求页不完整。引用页生成的 source 类型 单次 worker 接受严格的 pdf_read { path, offset?, limit? }。宿主从当前目录 解析路径。offset 为 1 起始页码(1–32,默认 1),limit 为 1–32 页(默认 10)。 -发现、Native/PTC 适配器由后续切片接入;本切片不注册宿主工具。 +该能力默认不出现在 schema;经发现或 SDK 选择后,在所属代理下一步骤启用。 每项为 { page, text, runs: string[] }。runs 保留各 PDF 显示文本操作; TJ 的间距数字不当作字符。text 仅以换行连接各 run 供展示。范围明确为 @@ -231,3 +231,9 @@ scanComplete 表示整个有界文本范围的扫描完整性,pageComplete 表 返回错误/恢复指引,不生成总数或 revision;组合重扫分页前必须比较 revision。 共享引用接收器在展示裁剪前捕获完整页并保留范围、扫描/捕获事实;引用分页完成 不等于源文档缺口已恢复。只读、凭据拒绝、nofollow 及无业务副作用沿用统一权限边界。 + +PDF 能力不进入默认 schema:Native 经 `ops_discover`、PTC 经 `ops_sdk` 选择,在所属代理下一 +步骤启用。Native 显示页码、转义文本与明确的文本层范围/排除内容;PTC 生成类型保留 page、text +和 string[] runs。不支持或非法页面在失败 Outcome 内保留此前规范数据,不生成循环页续取; +仅稳定完整扫描可前进分页,组合前须比较 revision。引用保留原页面覆盖率、解码/处理工作量、 +范围与捕获事实;每次读取复用文件工具的实时权限、凭据、nofollow、共享预算和取消路径。 diff --git a/dsh-plugin.json b/dsh-plugin.json index c27edbdf..88834fb0 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -87,6 +87,7 @@ "edit_many", "table_read", "doc_read", + "pdf_read", "result" ], "shellPermission": "Per-call DSH sandbox policy and native subprocess" @@ -112,7 +113,7 @@ "targets": ["win32-x64", "linux-x64", "darwin-arm64"], "presentation": "PTC-only; reader/result/SDK loader resident, other APIs become visible on the next agent step", "runtime": "independent native Rust file worker", - "tools": ["ops_inspect_local_file","ops_grep","ops_glob","ops_replace","ops_write_file","ops_edit_file","ops_diff","ops_stat","ops_copy","ops_move","ops_remove","ops_read_many","ops_edit_many","ops_table_read","ops_doc_read","ops_batch","ops_result","ops_sdk"], + "tools": ["ops_inspect_local_file","ops_grep","ops_glob","ops_replace","ops_write_file","ops_edit_file","ops_diff","ops_stat","ops_copy","ops_move","ops_remove","ops_read_many","ops_edit_many","ops_table_read","ops_doc_read","ops_pdf_read","ops_batch","ops_result","ops_sdk"], "permissions": "Per-call original DSH sandboxPolicy and sandbox.confine (host full/partial enforcement reported); full access bypasses confinement and permits explicit operation roots; subprocess owns cancellation and credential scrub" }, { diff --git a/src/capabilities/discovery.ts b/src/capabilities/discovery.ts index 91b1cd28..ed4b8b24 100644 --- a/src/capabilities/discovery.ts +++ b/src/capabilities/discovery.ts @@ -3,6 +3,7 @@ import type { ParameterSchema } from './types.js' /** Reviewed, currently implemented advanced operations. Future formats are absent. */ export const DISCOVERY_TERMS = Object.freeze({ + pdf_read: 'pdf portable document pages text extraction font', doc_read: 'docx word document paragraphs body text tables zip xml unicode', table_read: 'table csv tsv delimiter quoted records columns rows encoding utf16', inspect_local_file: 'read inspect single file text hex binary bounded line windows', diff --git a/src/capabilities/documents.ts b/src/capabilities/documents.ts index 659e13c7..0fe3706f 100644 --- a/src/capabilities/documents.ts +++ b/src/capabilities/documents.ts @@ -8,6 +8,11 @@ const boolean: JsonSchemaNode = { type: 'boolean' } const strings: JsonSchemaNode = { type: 'array', items: string } const object = (properties: Record, required: string[] = []): ParameterSchema => ({ type: 'object', properties, required, additionalProperties: false }) export const DOCUMENT_PARAMETERS = Object.freeze({ + pdf_read: object({ + path: { ...string, description: 'Regular PDF file path relative to the current session cwd.' }, + offset: { type: 'integer', default: 1, description: '1-32 pages, 1-based.' }, + limit: { type: 'integer', default: 10, description: '1-32 pages.' }, + }, ['path']), doc_read: object({ path: { ...string, description: 'Regular DOCX file path relative to the current session cwd. Binary DOC is unsupported.' }, offset: { type: 'integer', default: 1, description: '1-100000 main-body paragraphs, 1-based.' }, @@ -23,11 +28,12 @@ export const DOCUMENT_PARAMETERS = Object.freeze({ }, ['path']), }) export const DOCUMENT_DESCRIPTIONS = Object.freeze({ + pdf_read: 'Read bounded PDF 1.3-1.7 page text and individual runs relative to session cwd. Restricted classic-xref PDFs with standard Helvetica/Courier/Times Type1 fonts and Standard/WinAnsi encoding only. Scope is text-layer content order; excludes rendered reading order, images/OCR, annotations/forms and metadata. Unsupported or malformed later pages retain preceding complete pages with error/nextAction. Check scanComplete versus pageComplete; total/revision require a stable full scan. 16 MiB input and aggregate decoded/processed streams, 1 MiB/page, 32 pages, 30s. Page offsets cannot bypass physical gaps; compare revisions across calls. Failures return status:error|rejected and error.code; never throw.', table_read: 'Read bounded CSV/TSV lexical string cells relative to session cwd. Preserves quoted multiline records and physical LF lineStart/lineEnd. Check scanComplete versus pageComplete; total/revision exist only after a stable full scan. Up to 32 MiB, 100000 data records, 1 MiB/record, 1024 columns and 30s; row paging cannot bypass scan gaps. Errors retain safe preceding rows with nextAction. Compare revisions before combining rescanned pages. No spreadsheet formulas or type coercion. Failures return status:error|rejected and error.code; never throw.', doc_read: 'Read bounded DOCX main-body paragraphs relative to session cwd, preserving Unicode text and table/row/cell coordinates. Scope excludes layout, headers/footers, notes, comments and metadata; complete applies only to that scope. Standard stored/deflated ZIP, strict UTF-8/BOM UTF-16; unsupported content and parse errors retain safe preceding paragraphs with nextAction. Check scanComplete versus pageComplete; total/revision require a stable full scan. 32 MiB archive, 8 MiB main XML, 1 MiB/paragraph, 100000 paragraphs, 30s. Paging cannot bypass physical gaps; compare revisions across calls. No binary DOC, macros, external fetch or extraction to disk. Failures return status:error|rejected and error.code; never throw.', }) export const DOCUMENT_FACTS: Record = { - scope: string, excludedContent: strings, blocksObserved: integer, + scope: string, excludedContent: strings, blocksObserved: integer, pagesObserved: integer, decodedBytes: integer, processedBytes: integer, format: string, encoding: string, header: boolean, columns: strings, columnCount: integer, bytes: integer, bytesRead: integer, rowsObserved: integer, skipped: integer, scanComplete: boolean, pageComplete: boolean, complete: boolean, truncated: boolean, @@ -47,5 +53,8 @@ const DOCX_OUTPUT: JsonSchemaNode = { ...TABLE_OUTPUT, properties: { ...TABLE_OU items: { type: 'array', items: object({ block: integer, type: { type: 'string', enum: ['paragraph'] }, text: string, table: integer, row: integer, cell: integer }, ['block', 'type', 'text']) }, } } +const PDF_OUTPUT: JsonSchemaNode = { ...TABLE_OUTPUT, properties: { ...TABLE_OUTPUT.properties, + items: { type: 'array', items: object({ page: integer, text: string, runs: strings }, ['page', 'text', 'runs']) }, +} } export function isDocumentTool(tool: string): tool is keyof typeof DOCUMENT_PARAMETERS { return Object.hasOwn(DOCUMENT_PARAMETERS, tool) } -export function documentOutput(tool: string): JsonSchemaNode | undefined { return tool === 'doc_read' ? DOCX_OUTPUT : tool === 'table_read' ? TABLE_OUTPUT : undefined } +export function documentOutput(tool: string): JsonSchemaNode | undefined { return tool === 'pdf_read' ? PDF_OUTPUT : tool === 'doc_read' ? DOCX_OUTPUT : tool === 'table_read' ? TABLE_OUTPUT : undefined } diff --git a/src/capabilities/registry.ts b/src/capabilities/registry.ts index c03e6a53..38bb06d8 100644 --- a/src/capabilities/registry.ts +++ b/src/capabilities/registry.ts @@ -10,7 +10,7 @@ import type { CapabilityMetadata, Component, SchemaCapability } from './types.js export type FileToolName = keyof typeof fileParameters export type PtcToolName = keyof typeof ptcParameters // Preserve the published registration order independently of schema object key order. -const fileOrder = ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read', 'doc_read'] as const satisfies readonly FileToolName[] +const fileOrder = ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read', 'doc_read', 'pdf_read'] as const satisfies readonly FileToolName[] const fileMutations = new Set(['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) const allPresentations = Object.freeze(['native', 'ptc', 'both'] as const) diff --git a/src/modes/native/documents.ts b/src/modes/native/documents.ts index 1a14b18a..aadf49be 100644 --- a/src/modes/native/documents.ts +++ b/src/modes/native/documents.ts @@ -6,14 +6,15 @@ const facts = (value: Row, keys: string[]): string => keys.filter(key => value[k export function renderDocument(tool: string, args: Row, value: Row): string[] { const items = Array.isArray(value.items) ? value.items.filter(object) : [] const lines = ['=== DOCUMENT: ' + show(value.path ?? args.path ?? '(path unavailable)') + ' ===', - '[Observation: ' + facts(value, ['status', 'format', 'encoding', 'header', 'bytes', 'bytesRead', 'revision', 'columnCount']) + '.]', - '[Coverage: ' + facts(value, ['scanComplete', 'pageComplete', 'complete', 'rowsObserved', 'blocksObserved', 'skipped', 'total', 'omitted', 'nextOffset', 'truncatedBy']) + '.]'] + '[Observation: ' + facts(value, ['status', 'format', 'encoding', 'header', 'bytes', 'bytesRead', 'decodedBytes', 'processedBytes', 'revision', 'columnCount']) + '.]', + '[Coverage: ' + facts(value, ['scanComplete', 'pageComplete', 'complete', 'rowsObserved', 'blocksObserved', 'pagesObserved', 'skipped', 'total', 'omitted', 'nextOffset', 'truncatedBy']) + '.]'] if (typeof value.scope === 'string') lines.push('[Scope: ' + show(value.scope) + '; excludedContent=' + show(value.excludedContent) + '. Completeness applies only to this scope.]') if (Array.isArray(value.columns)) lines.push('Columns: ' + show(value.columns)) if (object(value.error)) lines.push('Error: ' + facts(value.error, ['code', 'message', 'effectState', 'nextAction'])) if (value.scanComplete === false || object(value.error)) lines.push('[Notice: This observation is not exhaustive. Only preceding complete records are shown; page offsets do not recover a physical scan gap.]') for (const item of items) lines.push(tool === 'table_read' ? 'Row ' + show(item.row) + ' (LF lines ' + show(item.lineStart) + '-' + show(item.lineEnd) + ') | ' + show(item.cells) + : tool === 'pdf_read' ? 'Page ' + show(item.page) + ' [runs=' + (Array.isArray(item.runs) ? item.runs.length : 'unknown') + '] | ' + show(item.text) : 'Paragraph ' + show(item.block) + ' [' + facts(item, ['table', 'row', 'cell']) + '] | ' + show(item.text)) if (!items.length) lines.push('(no records in this displayed range)') lines.push('[Notice: displayedRecords=' + items.length + '. Scan, requested range, and displayed content have separate completeness.]') diff --git a/src/modes/ptc/continuation.ts b/src/modes/ptc/continuation.ts index a199c03b..de337159 100644 --- a/src/modes/ptc/continuation.ts +++ b/src/modes/ptc/continuation.ts @@ -2,7 +2,7 @@ import type { CanonicalData, JsonValue } from './outcome.js' const object = (value: unknown): CanonicalData | undefined => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as CanonicalData : undefined const integer = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0 -const readers = ['inspect_local_file', 'read_many', 'glob', 'grep', 'diff', 'result', 'table_read', 'doc_read'] as const +const readers = ['inspect_local_file', 'read_many', 'glob', 'grep', 'diff', 'result', 'table_read', 'doc_read', 'pdf_read'] as const export type ContinuationTool = typeof readers[number] export interface RetrievalContinuation { version: 1, tool: ContinuationTool, args: CanonicalData } const PREFIX = 'ops-next-v1:' @@ -53,7 +53,7 @@ export function continuationFor(tool: string, rawArgs: unknown, data: CanonicalD if ((tool === 'grep' || tool === 'glob') && data.scanComplete === true && typeof data.continuation === 'string') { return encode(tool, { ...args, continuation: data.continuation, ...(integer(data.nextOffset) ? { offset: data.nextOffset } : {}) }) } - if (tool === 'table_read' || tool === 'doc_read') { + if (tool === 'table_read' || tool === 'doc_read' || tool === 'pdf_read') { const offset = args.offset ?? 1 return data.scanComplete === true && typeof data.revision === 'string' && /^[a-f0-9]{64}$/u.test(data.revision) && integer(offset) && offset > 0 && integer(data.nextOffset) && data.nextOffset > offset diff --git a/test/capabilities.test.mjs b/test/capabilities.test.mjs index 267d4c3c..0495aa3f 100644 --- a/test/capabilities.test.mjs +++ b/test/capabilities.test.mjs @@ -10,9 +10,9 @@ import { BASH_TOOL_NAME } from '../lib/shells/constants.js' import { assert, test, report } from './lib/harness.mjs' await test('the catalogue covers every owned file, PTC and shell tool exactly once', () => { - assert.equal(CAPABILITIES.length, 20) + assert.equal(CAPABILITIES.length, 21) assert.equal(new Set(CAPABILITIES.map(item => item.rawName)).size, CAPABILITIES.length) - assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read', 'doc_read']) + assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read', 'doc_read', 'pdf_read']) assert.deepEqual(FILE_MUTATIONS, ['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) assert.deepEqual(PTC_TOOLS, ['batch', 'result']) assert.deepEqual(IN_PROCESS_TOOLS, ['bash']) @@ -55,7 +55,7 @@ const snapshot = (names, definition) => names.map(name => { await test('business file and batch definitions retain their original serialized contracts', () => { // Captured before this migration, covering every argument/output contract and published description. const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') - assert.equal(hash(snapshot(FILE_TOOLS.filter(name => !['glob', 'grep', 'read_many', 'edit_many', 'table_read', 'doc_read'].includes(name)), fileDefinition)), 'f32e33306b4052fdb3d19a11e0fe9d65c255a87d2d485de08a9d7b1275e958fc') + assert.equal(hash(snapshot(FILE_TOOLS.filter(name => !['glob', 'grep', 'read_many', 'edit_many', 'table_read', 'doc_read', 'pdf_read'].includes(name)), fileDefinition)), 'f32e33306b4052fdb3d19a11e0fe9d65c255a87d2d485de08a9d7b1275e958fc') const batch = ptcDefinition('batch', async () => null) assert.equal(batch.parameters, ptcParameters.batch); assert.ok(batch.description.startsWith(ptcDescriptions.batch)); assert.match(batch.description, /PTC returns Outcome/) assert.ok(batch.output.schema.oneOf.every(branch => branch.properties.status)) diff --git a/test/discovery.test.mjs b/test/discovery.test.mjs index aa5bf8ba..19c860b0 100644 --- a/test/discovery.test.mjs +++ b/test/discovery.test.mjs @@ -18,7 +18,7 @@ await test('discovery config validates its own keys and copies bounded operator const selected = resolveDiscoveryConfig(input) input.allowedTools.push('diff') assert.deepEqual(selected.allowedTools, ['stat']) - for (const raw of [null, [], false, { enabled: 1 }, { maxResults: 0 }, { maxResults: 6 }, { maxResults: 1.5 }, { maxResults: null }, { allowedTools: ['pdf_read'] }, { alwaysLoaded: ['stat'], allowedTools: [] }]) assert.throws(() => resolveDiscoveryConfig(raw)) + for (const raw of [null, [], false, { enabled: 1 }, { maxResults: 0 }, { maxResults: 6 }, { maxResults: 1.5 }, { maxResults: null }, { allowedTools: ['unknown_document_reader'] }, { alwaysLoaded: ['stat'], allowedTools: [] }]) assert.throws(() => resolveDiscoveryConfig(raw)) assert.throws(() => resolveDiscoveryConfig({ allowedTool: [] }), /unknown discovery key: allowedTool/) }) await test('discovery arguments enforce max_results 1-5 and reject hidden action payloads', () => { @@ -26,7 +26,7 @@ await test('discovery arguments enforce max_results 1-5 and reject hidden action for (const raw of [null, {}, { query: '' }, { query: 'a'.repeat(513) }, { query: 'stat', path: 'file' }, ...[0, 6, 1.5, null, '3'].map(max_results => ({ query: 'stat', max_results }))]) assert.equal(typeof parseRequest(raw), 'string') assert.deepEqual(search('select:ops_stat', DISCOVERABLE_TOOLS, 5), ['stat']) assert.deepEqual(search('metadata revision hash', DISCOVERABLE_TOOLS, 1), ['stat']) - assert.deepEqual(search('select:ops_pdf_read', DISCOVERABLE_TOOLS, 5), []) + assert.deepEqual(search('select:ops_pdf_read', DISCOVERABLE_TOOLS, 5), ['pdf_read']) }) await test('discovery-entry schema has a reproducible pinned token measurement, separate from the full BM-03 gate', () => { const wire = JSON.stringify([{ name: publicToolName('discover'), description: DISCOVERY_DESCRIPTION, parameters: DISCOVERY_PARAMETERS }]) diff --git a/test/pdf-host.test.mjs b/test/pdf-host.test.mjs new file mode 100644 index 00000000..9834a3c0 --- /dev/null +++ b/test/pdf-host.test.mjs @@ -0,0 +1,95 @@ +/** Real Native/PTC PDF observations preserve restricted text scope and bounded page recovery. */ +import fs from 'node:fs' +import path from 'node:path' +import { assertSupportedJsonSchema, jsonSchemaToTs } from '@deepseek-ai/dsh-tools' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { shutdown } from './lib/host.mjs' +import { requireWorker } from './lib/boundary.mjs' +import { textPdf, show } from './lib/pdf-fixture.mjs' +import { documentHost, documentNames, documentCall, documentCode, activateDocument } from './lib/document-host.mjs' +import { capability } from '../lib/capabilities/registry.js' +import { publicToolName } from '../lib/policy.js' +import { canonicalOutput } from '../lib/modes/ptc/canonical-schema.js' +import { decodeContinuation } from '../lib/modes/ptc/continuation.js' +import { definition } from '../lib/file/contract.js' +requireWorker('pdf-host') +const TOOL = publicToolName('pdf_read') +const render = value => definition('pdf_read', async () => value).output.render({ path: 'text.pdf' }, value)[0].text +await test('PDF capability stays discoverable and generates strict page/text/run contracts', () => { + const entry = capability('pdf_read'); assert.equal(entry.availability, 'discoverable'); assert.equal(entry.mutates, false) + assert.equal(entry.parameters.additionalProperties, false); assert.deepEqual(entry.parameters.required, ['path']) + const schema = canonicalOutput('pdf_read'); assertSupportedJsonSchema(schema) + assert.equal(schema.properties.items.items.properties.runs.items.type, 'string') + assert.equal(schema.properties.items.items.properties.block, undefined); assert.equal(schema.properties.source.properties.pagesObserved.type, 'integer') + assert.match(jsonSchemaToTs(schema), /runs: string\[\]/); assert.match(entry.description, /images\/OCR/) +}) +await test('real PDF discovery activates next step for its owner and PTC pages preserve exact text runs', () => withTempDir('pdf-host-pages', async root => { + const cwd = path.join(root, 'nested'); fs.mkdirSync(cwd) + fs.writeFileSync(path.join(cwd, 'text.pdf'), textPdf([show('BM04 PDF text'), show(Buffer.from([0x80, 0x95, 0xa0]))], { deflate: true })) + const { ctx, agent } = await documentHost(root, ['pdf_read'], { real: true }) + try { + const native = await agent('native', { cwd }), sibling = await agent(), child = await agent('native', { parent: native }), code = await agent('ptc', { cwd }) + const initial = await ctx.get('systemPrompt').assemble({ scope: native }) + assert.equal(initial.tools.some(tool => tool.name === TOOL), false) + await activateDocument(ctx, native, 'pdf_read'); await activateDocument(ctx, code, 'pdf_read', 'ptc') + for (const other of [sibling, child]) assert.equal(documentNames(ctx, other).includes(TOOL), false) + const raw = await documentCall(ctx, native, 'pdf_read', { path: 'text.pdf', limit: 1 }) + assert.equal(raw.scope, 'page-text-layer-content-order'); assert.equal(raw.total, 2); assert.equal(raw.scanComplete, true) + assert.deepEqual(raw.items, [{ page: 1, text: 'BM04 PDF text', runs: ['BM04 PDF text'] }]) + const text = render(raw); assert.match(text, /Page 1/); assert.match(text, /BM04 PDF text/); assert.match(text, /images and OCR/) + assert.match(text, /Completeness applies only to this scope/) + const result = await documentCode(ctx, code, `return await tools.${TOOL}({path:'text.pdf',limit:1});`) + assert.equal(result.status, 'partial'); assert.equal(result.data.items[0].runs[0], 'BM04 PDF text') + const next = decodeContinuation(result.continuation); assert.equal(next.tool, 'pdf_read'); assert.equal(next.args.offset, 2) + const tail = await documentCall(ctx, code, next.tool, next.args) + assert.equal(tail.status, 'completed'); assert.equal(tail.data.revision, result.data.revision) + assert.deepEqual(tail.data.items[0], { page: 2, text: '€• ', runs: ['€• '] }) + } finally { await shutdown(ctx) } +})) +await test('unsupported later PDF pages retain only preceding complete pages and never resume the same gap', () => withTempDir('pdf-host-gap', async root => { + fs.writeFileSync(path.join(root, 'text.pdf'), textPdf([show('safe prefix'), show('not complete') + '/Im1 Do\n'])) + const { ctx, agent } = await documentHost(root, ['pdf_read']) + try { + const native = await agent(), code = await agent('ptc') + await activateDocument(ctx, native, 'pdf_read'); await activateDocument(ctx, code, 'pdf_read', 'ptc') + const raw = await documentCall(ctx, native, 'pdf_read', { path: 'text.pdf', limit: 1 }) + assert.equal(raw.scanComplete, false); assert.equal(raw.pageComplete, true); assert.equal(raw.pagesObserved, 1) + assert.equal(raw.total, undefined); assert.match(render(raw), /UNSUPPORTED_DOCUMENT/); assert.match(render(raw), /Page 1.*safe prefix/) + assert.match(render(raw), /not exhaustive/); assert.doesNotMatch(render(raw), /not complete/) + const result = await documentCall(ctx, code, 'pdf_read', { path: 'text.pdf', limit: 1 }) + assert.equal(result.status, 'error'); assert.equal(result.error.code, 'UNSUPPORTED_DOCUMENT'); assert.equal(result.error.retry, 'never') + assert.equal(result.continuation, undefined); assert.equal(result.data.items[0].text, 'safe prefix'); assert.equal(result.data.revision, undefined) + assert.match(result.error.nextAction, /page offsets do not bypass/) + } finally { await shutdown(ctx) } +})) +await test('PDF current credential policy and read-only authority survive decoding; malformed files stay errors', () => withTempDir('pdf-host-authority', async root => { + fs.writeFileSync(path.join(root, 'text.pdf'), textPdf([show('readonly')])) + fs.writeFileSync(path.join(root, 'id_ed25519.pdf'), textPdf([show('SECRET')])) + fs.writeFileSync(path.join(root, 'broken.pdf'), '%PDF-1.4\n') + const before = fs.readdirSync(root).sort(), { ctx, agent } = await documentHost(root, ['pdf_read']) + try { + const owner = await agent('ptc', { policy: 'read-only' }); await activateDocument(ctx, owner, 'pdf_read', 'ptc') + assert.equal((await documentCall(ctx, owner, 'pdf_read', { path: 'text.pdf' })).status, 'completed') + const denied = await documentCall(ctx, owner, 'pdf_read', { path: 'id_ed25519.pdf' }) + assert.equal(denied.error.code, 'CREDENTIAL_DENIED'); assert.equal(denied.error.effectState, 'none'); assert.doesNotMatch(JSON.stringify(denied), /SECRET/) + const broken = await documentCall(ctx, owner, 'pdf_read', { path: 'broken.pdf' }) + assert.equal(broken.status, 'error'); assert.equal(broken.error.code, 'DOCUMENT_PARSE_ERROR'); assert.equal(broken.continuation, undefined) + const invalid = await documentCall(ctx, owner, 'pdf_read', { path: 'text.pdf', limit: 33 }) + assert.equal(invalid.error.code, 'INVALID_ARGUMENT'); assert.deepEqual(fs.readdirSync(root).sort(), before) + } finally { await shutdown(ctx) } +})) +await test('large PDF text and run data spill with original page scope and bounded Native output', () => withTempDir('pdf-host-spill', async root => { + fs.writeFileSync(path.join(root, 'text.pdf'), textPdf([show('ab'.repeat(40000))], { deflate: true })) + const { ctx, agent } = await documentHost(root, ['pdf_read', 'result']) + try { + const owner = await agent(); await activateDocument(ctx, owner, 'pdf_read'); await activateDocument(ctx, owner, 'result') + const raw = await documentCall(ctx, owner, 'pdf_read', { path: 'text.pdf' }) + assert.equal(raw.reference.producerTool, 'pdf_read'); assert.equal(raw.scanComplete, true); assert.equal(raw.complete, false) + const content = render(raw); assert.ok(Buffer.byteLength(JSON.stringify([{ type: 'text', text: content }])) <= 65536); assert.match(content, /ref:\/\//) + const page = await documentCall(ctx, owner, 'result', { action: 'page', id: raw.reference.refId, root: raw.referenceRoot, limit: 1 }) + assert.equal(page.source.scope, 'page-text-layer-content-order'); assert.equal(page.source.pagesObserved, 1) + assert.equal(page.source.captureComplete, true); assert.ok(page.source.decodedBytes > 80000); assert.ok(page.source.processedBytes > 80000) + assert.equal(page.items[0].kind, 'item-json-chunk'); assert.equal(page.items[0].byteOffset, 0) + } finally { await shutdown(ctx) } +})) +report('pdf-host') diff --git a/test/types/ptc-sdk.ts b/test/types/ptc-sdk.ts index 3d8bdb05..2251249b 100644 --- a/test/types/ptc-sdk.ts +++ b/test/types/ptc-sdk.ts @@ -70,3 +70,19 @@ async function paragraphs() { // @ts-expect-error DOCX decoding comes from the package, not a caller-supplied encoding. void tools.ops_doc_read({ path: 'body.docx', encoding: 'utf-8' }) void paragraphs + +async function pdfPages() { + const value = await tools.ops_pdf_read({ path: 'text.pdf', offset: 1, limit: 10 }) + if (value.status === 'completed' || value.status === 'partial') { + const runs: string[] | undefined = value.data.items?.[0]?.runs + const page: number | undefined = value.data.items?.[0]?.page + const processed: number | undefined = value.data.source?.processedBytes + // @ts-expect-error PDF pages have no DOCX paragraph block index. + void value.data.items?.[0]?.block + return { runs, page, processed } + } + return value.data?.items?.[0]?.text +} +// @ts-expect-error PDF encoding derives from supported fonts, not caller selection. +void tools.ops_pdf_read({ path: 'text.pdf', encoding: 'utf-8' }) +void pdfPages From c9f1320df2da094416e66b4d7ec68270ef407b4d Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:55:06 +0800 Subject: [PATCH 057/118] refactor(cli): migrate provisioning and reports to TypeScript --- ...rovision-shells.js => provision-shells.ts} | 64 +++++++++++-------- src/cli/{report.js => report.ts} | 40 +++++++----- src/cli/{uninstall.js => uninstall.ts} | 15 +++-- test/bin.test.mjs | 2 +- 4 files changed, 70 insertions(+), 51 deletions(-) rename src/cli/{provision-shells.js => provision-shells.ts} (89%) rename src/cli/{report.js => report.ts} (85%) rename src/cli/{uninstall.js => uninstall.ts} (93%) diff --git a/src/cli/provision-shells.js b/src/cli/provision-shells.ts similarity index 89% rename from src/cli/provision-shells.js rename to src/cli/provision-shells.ts index 28179556..6bce4244 100644 --- a/src/cli/provision-shells.js +++ b/src/cli/provision-shells.ts @@ -12,6 +12,7 @@ import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import fs from 'node:fs' +import { errorMessage } from '../shells/types.js' import os from 'node:os' import path from 'node:path' import { dshHome } from '../paths.js' @@ -24,6 +25,13 @@ import { } from '../shells.js' import { formatBytes } from './uninstall.js' +type ShellKind = 'bash' | 'pwsh' +export interface ShellPin extends Record { + name: ShellKind; platform: string; arch: string; version: string; assetFile: string; url: string + sha256: string; bytes: number; extractor: string; extractTo: string; executableRelativePath: string +} +type HelperResult = { ok: true } | { error: string; stderr: string[] } + // --------------------------------------------------------------------------- // provision-shells: the pinned upstream shells become local copies // --------------------------------------------------------------------------- @@ -58,7 +66,7 @@ export const PIN_REQUIRED_FIELDS = Object.freeze([ * @param {string} file - the file to hash. * @returns {string} the lowercase hex digest. */ -export function sha256File(file) { +export function sha256File(file: string) { const hash = createHash('sha256') const chunk = Buffer.allocUnsafe(1024 * 1024) const handle = fs.openSync(file, 'r') @@ -80,7 +88,7 @@ export function sha256File(file) { * @param {number} [count] - how many lines to keep. * @returns {string[]} the lines. */ -export function tailLines(text, count = 8) { +export function tailLines(text: string | undefined | null, count = 8) { return String(text ?? '') .split('\n') .map((line) => line.trimEnd()) @@ -96,7 +104,7 @@ export function tailLines(text, count = 8) { * @param {string} [options.cwd] - working directory for the child. * @returns {{ok: true}|{error: string, stderr: string[]}} the outcome. */ -export function runTool(file, args, { cwd } = {}) { +export function runTool(file: string, args: string[], { cwd }: { cwd?: string } = {}): HelperResult { const result = spawnSync(file, args, { cwd, encoding: 'utf8', windowsHide: true }) if (result.error !== undefined && result.error !== null) { return { error: `could not run ${file}: ${String(result.error.message ?? result.error)}`, stderr: [] } @@ -112,7 +120,7 @@ export function runTool(file, args, { cwd } = {}) { * @param {NodeJS.ProcessEnv} env - the environment to read. * @returns {string|undefined} the proxy URL, when one is named. */ -export function proxyFor(env) { +export function proxyFor(env: NodeJS.ProcessEnv) { const value = env.HTTPS_PROXY ?? env.https_proxy ?? env.HTTP_PROXY ?? env.http_proxy return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined } @@ -130,7 +138,7 @@ export function proxyFor(env) { * @param {NodeJS.ProcessEnv} options.env - the environment to read. * @returns {{file: string}|{error: string, detail: string, stderr: string[]}} the asset, or why not. */ -export function curlAsset({ pin, into, env }) { +export function curlAsset({ pin, into, env }: { pin: Pick; into: string; env: NodeJS.ProcessEnv }): { file: string } | { error: string; detail: string; stderr: string[] } { const file = path.join(into, pin.assetFile) const proxy = proxyFor(env) const args = ['--location', '--fail', '--silent', '--show-error', '--output', file] @@ -171,7 +179,7 @@ export function curlAsset({ pin, into, env }) { * @param {NodeJS.ProcessEnv} env - the environment to locate it with. * @returns {string|undefined} the program, or undefined when there is none. */ -export function findZipCapableTar(env) { +export function findZipCapableTar(env: NodeJS.ProcessEnv) { const candidates = process.platform === 'win32' ? [path.join(env.SystemRoot ?? env.SYSTEMROOT ?? 'C:\\Windows', 'System32', 'tar.exe')] : [] @@ -193,7 +201,7 @@ export function findZipCapableTar(env) { * @param {string} options.target - the version directory. * @returns {{ok: true}|{error: string, stderr: string[]}} the outcome. */ -export function unpackAsset({ pin, asset, target }) { +export function unpackAsset({ pin, asset, target }: { pin: Pick; asset: string; target: string }): HelperResult { const into = path.resolve(target, pin.extractTo) if (pin.extractor === 'zip') { const tar = findZipCapableTar(process.env) @@ -235,12 +243,12 @@ export function unpackAsset({ pin, asset, target }) { * @returns {object} the validated pin. * @throws {Error} when a field is missing or malformed. */ -export function validatePin(value, index) { +export function validatePin(value: unknown, index: number): ShellPin { const at = `pins[${index}]` if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`${at} must be an object`) } - const pin = /** @type {Record} */ (value) + const pin = value as Record for (const field of PIN_REQUIRED_FIELDS) { if (pin[field] === undefined) throw new Error(`${at}.${field} is required`) } @@ -250,7 +258,7 @@ export function validatePin(value, index) { * @param {string} key - the field. * @returns {string} its value. */ - const text = (key) => { + const text = (key: string) => { const entry = pin[key] if (typeof entry !== 'string' || entry.trim() === '') { throw new Error(`${at}.${key} must be a non-empty string, got ${JSON.stringify(entry)}`) @@ -263,7 +271,7 @@ export function validatePin(value, index) { * @param {string} key - the field. * @returns {string} its value. */ - const segment = (key) => { + const segment = (key: string) => { const entry = text(key) if (entry.includes('/') || entry.includes('\\') || entry.includes('..') || path.isAbsolute(entry)) { throw new Error(`${at}.${key} must be a plain name, got ${JSON.stringify(entry)}`) @@ -276,7 +284,7 @@ export function validatePin(value, index) { * @param {string} key - the field. * @returns {string} its value. */ - const relative = (key) => { + const relative = (key: string) => { const entry = text(key) if (path.isAbsolute(entry) || entry.split(/[\\/]/u).includes('..')) { throw new Error(`${at}.${key} must be a relative path inside the version directory, got ${JSON.stringify(entry)}`) @@ -294,14 +302,14 @@ export function validatePin(value, index) { if (!/^[0-9a-f]{64}$/u.test(text('sha256'))) { throw new Error(`${at}.sha256 must be 64 lowercase hex characters, got ${JSON.stringify(pin.sha256)}`) } - if (!Number.isSafeInteger(pin.bytes) || /** @type {number} */ (pin.bytes) <= 0) { + if (typeof pin.bytes !== 'number' || !Number.isSafeInteger(pin.bytes) || pin.bytes <= 0) { throw new Error(`${at}.bytes must be a positive whole number, got ${JSON.stringify(pin.bytes)}`) } const executableRelativePath = relative('executableRelativePath') // The expected file name follows the pin's own platform, not the host's: a pin // table is validated on whatever machine runs the provisioner, and a `darwin` // pin's payload has no `.exe`. - const expectedExecutable = shellExecutable(name, text('platform')) + const expectedExecutable = shellExecutable(name as ShellKind, text('platform')) if (path.basename(executableRelativePath) !== expectedExecutable) { throw new Error(`${at}.executableRelativePath must end in ${expectedExecutable}, ` + `got ${JSON.stringify(executableRelativePath)}`) @@ -312,7 +320,9 @@ export function validatePin(value, index) { return { ...pin, - name, + name: name as ShellKind, + sha256: text('sha256'), + bytes: pin.bytes, version: segment('version'), platform: text('platform'), arch: text('arch'), @@ -334,22 +344,22 @@ export function validatePin(value, index) { * @returns {object[]} the pins. * @throws {Error} when the file is unreadable or a pin is malformed. */ -export function pinTable(file) { +export function pinTable(file: string | undefined): ShellPin[] { if (file === undefined) return SHELL_UPSTREAM_PINS.map((pin, index) => validatePin(pin, index)) const resolved = path.resolve(file) let text try { text = fs.readFileSync(resolved, 'utf8') } catch (error) { - throw new Error(`--pin does not name a readable file: ${resolved} (${error.message})`) + throw new Error(`--pin does not name a readable file: ${resolved} (${errorMessage(error)})`) } - let value + let value: unknown try { value = JSON.parse(text) } catch (error) { - throw new Error(`--pin must hold one JSON document of pins: ${error.message}`) + throw new Error(`--pin must hold one JSON document of pins: ${errorMessage(error)}`) } - const list = Array.isArray(value) ? value : value?.pins + const list = Array.isArray(value) ? value : value !== null && typeof value === 'object' && 'pins' in value ? value.pins : undefined if (!Array.isArray(list)) { throw new Error('--pin must hold one JSON array of pins (or an object with a "pins" array)') } @@ -361,10 +371,10 @@ export function pinTable(file) { * @param {string} file - the marker file. * @returns {object|undefined} the parsed marker. */ -export function readShellReceipt(file) { +export function readShellReceipt(file: string): Record | undefined { try { - const value = JSON.parse(fs.readFileSync(file, 'utf8')) - return value !== null && typeof value === 'object' ? value : undefined + const value: unknown = JSON.parse(fs.readFileSync(file, 'utf8')) + return value !== null && typeof value === 'object' ? value as Record : undefined } catch { return undefined } @@ -384,7 +394,7 @@ export function readShellReceipt(file) { * @param {boolean} options.dryRun - report what would happen, and download nothing. * @returns {boolean} whether this shell is provisioned (or already was). */ -export function provisionShell(pin, { force, dryRun }) { +export function provisionShell(pin: ShellPin, { force, dryRun }: { force: boolean; dryRun: boolean }) { const target = provisionedShellVersionDir({ kind: pin.name, version: pin.version }) const executable = path.join(target, pin.executableRelativePath) const receiptFile = path.join(target, SHELL_RECEIPT_FILE) @@ -484,7 +494,7 @@ export function provisionShell(pin, { force, dryRun }) { fs.rmSync(stale, { recursive: true, force: true }) console.log(` removed: ${stale} (a version this pin no longer names)`) } catch (error) { - console.error(` warning: could not remove the previous version ${stale}: ${error.message}`) + console.error(` warning: could not remove the previous version ${stale}: ${errorMessage(error)}`) } } @@ -510,7 +520,7 @@ export function provisionShell(pin, { force, dryRun }) { * @param {string|undefined} options.pinFile - read the pins from this JSON file instead. * @returns {number} the exit code: 0 when every selected shell is in place, 1 otherwise. */ -export function commandProvisionShells({ kinds, force, dryRun, pinFile }) { +export function commandProvisionShells({ kinds, force, dryRun, pinFile }: { kinds: ShellKind[]; force: boolean; dryRun: boolean; pinFile?: string }) { // The pinned payload is the Windows one: a Windows host carries no POSIX shell, so this // distribution provisions and digest-checks one there. A POSIX host already has its // distribution's own bash, so there is nothing to install — say that instead of failing on an @@ -524,7 +534,7 @@ export function commandProvisionShells({ kinds, force, dryRun, pinFile }) { try { pins = pinTable(pinFile) } catch (error) { - console.error(error.message) + console.error(errorMessage(error)) return 1 } diff --git a/src/cli/report.js b/src/cli/report.ts similarity index 85% rename from src/cli/report.js rename to src/cli/report.ts index 1df994b8..170a169a 100644 --- a/src/cli/report.js +++ b/src/cli/report.ts @@ -11,10 +11,13 @@ */ import fs from 'node:fs' +import { errorMessage } from '../shells/types.js' import os from 'node:os' import path from 'node:path' import { PACKAGE_ROOT, dshHome } from '../paths.js' import { resolveConfig } from '../config.js' +import type { ResolvedConfig } from '../config.js' +import type { ShellResolution } from '../shells.js' import { BASH_TOOL, FILE_TOOLS, @@ -26,6 +29,11 @@ import { resolveWorker } from '../ptc/runtime.js' import { restrictedCompatibility } from '../bash-ipc.js' import { resolveShells } from '../shells.js' +type WorkerReport = { resolved: true; executable: string; source: string; error: null } + | { resolved: false; executable: null; source: null; error: string } +interface ShellReport { available: boolean; executable: string | null; source: string; detail: string; restricted?: { compatible: boolean; reason: string } | null } +interface LadderReport { worker: WorkerReport; shells: Record<'bash' | 'pwsh', ShellReport>; policy: string } + /** * The unit `uninstall` removes: `/dsh-ops`, holding what older * versions installed outside the profile (the former FastCtx runtime under @@ -61,25 +69,25 @@ export function fastctxDataDir() { * @returns {unknown} the raw row config. * @throws {Error} when the file cannot be read, does not parse, or is not an object. */ -export function readRowConfig(file) { +export function readRowConfig(file: string | undefined): Record { if (file === undefined) return {} const resolved = path.resolve(file) let text try { text = fs.readFileSync(resolved, 'utf8') } catch (error) { - throw new Error(`--config does not name a readable file: ${resolved} (${error.message})`) + throw new Error(`--config does not name a readable file: ${resolved} (${errorMessage(error)})`) } - let value + let value: unknown try { value = JSON.parse(text) } catch (error) { - throw new Error(`--config must hold one JSON object (the \`dsh-ops\` row's config): ${error.message}`) + throw new Error(`--config must hold one JSON object (the \`dsh-ops\` row's config): ${errorMessage(error)}`) } if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`--config must hold one JSON object (the \`dsh-ops\` row's config), got ${typeof value}`) } - return value + return value as Record } /** @@ -89,7 +97,7 @@ export function readRowConfig(file) { * @param {import('../config.js').ResolvedConfig} config - the plugin configuration. * @returns {{resolved: boolean, executable: string|null, source: string|null, error: string|null}} the report value. */ -export function workerReport(config) { +export function workerReport(config: ResolvedConfig): WorkerReport { try { const file = resolveWorker(config) const source = config.binaryPath !== undefined ? 'config.binaryPath' @@ -97,7 +105,7 @@ export function workerReport(config) { : path.relative(PACKAGE_ROOT, file).split(path.sep)[0] === 'runtime' ? 'packaged runtime' : 'source-tree build' return { resolved: true, executable: file, source, error: null } } catch (error) { - return { resolved: false, executable: null, source: null, error: String(error?.message ?? error) } + return { resolved: false, executable: null, source: null, error: errorMessage(error) } } } @@ -107,7 +115,7 @@ export function workerReport(config) { * @param {import('../shells.js').ShellResolution} resolution - the rung's resolution. * @returns {{available: boolean, executable: string|null, source: string, detail: string}} the report value. */ -export function shellReport(resolution) { +export function shellReport(resolution: ShellResolution): ShellReport { return { available: resolution.available === true, executable: resolution.file ?? null, @@ -122,13 +130,13 @@ export function shellReport(resolution) { * @param {import('../shells.js').ShellResolution} resolution - the bash rung. * @returns {{compatible: boolean, reason: string}|null} null when there is no bash to check. */ -export function restrictedReport(resolution) { +export function restrictedReport(resolution: ShellResolution) { if (resolution.available !== true || typeof resolution.file !== 'string') return null try { const { compatible, reason } = restrictedCompatibility(resolution.file) return { compatible, reason } } catch (error) { - return { compatible: false, reason: String(error?.message ?? error) } + return { compatible: false, reason: errorMessage(error) } } } @@ -148,7 +156,7 @@ export function restrictedReport(resolution) { * @param {import('../config.js').ResolvedConfig} options.config - the plugin configuration. * @returns {import('../policy.js').LadderLevels} the rungs. */ -export function ladderFor({ worker, shells, config }) { +export function ladderFor({ worker, shells, config }: { worker: { resolved: boolean }; shells: { bash: { available: boolean }; pwsh: { available: boolean } }; config: ResolvedConfig }) { const published = worker.resolved ? FILE_TOOLS.map(publicToolName) : [] if (config.publishBashTool && shells.bash.available) published.push(BASH_TOOL) if (shells.pwsh.available) published.push('pwsh') @@ -160,7 +168,7 @@ export function ladderFor({ worker, shells, config }) { * @param {string} text - the rendered tooling section. * @returns {{number: number, title: string}[]} one entry per rendered rung, in render order. */ -export function renderedRungs(text) { +export function renderedRungs(text: string) { const rungs = [] for (const line of text.split('\n')) { const match = /^## Rung (\d+) — (.*)$/u.exec(line) @@ -174,7 +182,7 @@ export function renderedRungs(text) { * @param {object} report - the report value `commandLadder` assembled. * @returns {void} */ -export function printLadder(report) { +export function printLadder(report: LadderReport) { console.log('Native worker (file tools)') if (report.worker.resolved) { console.log(` executable: ${report.worker.executable}`) @@ -185,7 +193,7 @@ export function printLadder(report) { } console.log('\nShell rungs (lib/shells.js resolveShells)') - for (const kind of ['bash', 'pwsh']) { + for (const kind of ['bash', 'pwsh'] as const) { const rung = report.shells[kind] console.log(` ${kind}`) console.log(` available: ${rung.available ? 'yes' : 'no'}`) @@ -217,12 +225,12 @@ export function printLadder(report) { * @param {string|undefined} options.configFile - the `--config` path, when one was given. * @returns {number} the exit code. */ -export function commandLadder({ json, configFile }) { +export function commandLadder({ json, configFile }: { json: boolean; configFile?: string }) { let config try { config = resolveConfig(readRowConfig(configFile)) } catch (error) { - console.error(error.message) + console.error(errorMessage(error)) return 1 } diff --git a/src/cli/uninstall.js b/src/cli/uninstall.ts similarity index 93% rename from src/cli/uninstall.js rename to src/cli/uninstall.ts index 70b40a50..7b1f26c6 100644 --- a/src/cli/uninstall.js +++ b/src/cli/uninstall.ts @@ -10,6 +10,7 @@ */ import fs from 'node:fs' +import { errorMessage } from '../shells/types.js' import path from 'node:path' import { fastctxDataDir, managedDir } from './report.js' @@ -18,7 +19,7 @@ import { fastctxDataDir, managedDir } from './report.js' * @param {number} bytes - the size. * @returns {string} the size in the largest unit that keeps it readable. */ -export function formatBytes(bytes) { +export function formatBytes(bytes: number) { if (bytes < 1024) return `${bytes} B` const units = ['kB', 'MB', 'GB', 'TB'] let value = bytes / 1024 @@ -36,7 +37,7 @@ export function formatBytes(bytes) { * @param {string} entry - the path to measure. * @returns {number} the byte count. */ -export function measure(entry) { +export function measure(entry: string): number { let stat try { stat = fs.lstatSync(entry) @@ -57,7 +58,7 @@ export function measure(entry) { * @param {string} dir - the directory. * @returns {{name: string, bytes: number}[]} one row per entry, directories first. */ -export function contentsOf(dir) { +export function contentsOf(dir: string) { const rows = fs.readdirSync(dir, { withFileTypes: true }).map((entry) => ({ name: entry.isDirectory() ? `${entry.name}${path.sep}` : entry.name, bytes: measure(path.join(dir, entry.name)), @@ -75,7 +76,7 @@ export function contentsOf(dir) { * @param {string} dir - the directory to describe. * @returns {{missing: boolean, rows: {name: string, bytes: number}[], bytes: number}} the report. */ -export function reportDirectory(label, dir) { +export function reportDirectory(label: string, dir: string) { console.log(`\n${label}`) console.log(` directory: ${dir}`) if (!fs.existsSync(dir)) { @@ -109,7 +110,7 @@ export function reportDirectory(label, dir) { * @param {boolean} options.dryRun - report only, whatever `yes` says. * @returns {number} the exit code: 0 when the requested state is gone or absent, 1 when a removal failed. */ -export function commandUninstall({ yes, purgeFastctx, dryRun }) { +export function commandUninstall({ yes, purgeFastctx, dryRun }: { yes: boolean; purgeFastctx: boolean; dryRun: boolean }) { const removing = yes && !dryRun console.log(`dsh-ops uninstall — ${removing ? 'removing' : 'report only (dry run; pass --yes to delete)'}`) @@ -124,7 +125,7 @@ export function commandUninstall({ yes, purgeFastctx, dryRun }) { fs.rmSync(managed, { recursive: true, force: true }) console.log(' action: removed') } catch (error) { - console.error(` action: FAILED to remove ${managed}: ${error.message}`) + console.error(` action: FAILED to remove ${managed}: ${errorMessage(error)}`) return 1 } } @@ -142,7 +143,7 @@ export function commandUninstall({ yes, purgeFastctx, dryRun }) { fs.rmSync(fastctx, { recursive: true, force: true }) console.log(' action: removed') } catch (error) { - console.error(` action: FAILED to remove ${fastctx}: ${error.message}`) + console.error(` action: FAILED to remove ${fastctx}: ${errorMessage(error)}`) return 1 } } diff --git a/test/bin.test.mjs b/test/bin.test.mjs index 640be70a..0aeff157 100644 --- a/test/bin.test.mjs +++ b/test/bin.test.mjs @@ -555,7 +555,7 @@ await test('provision-shells --dry-run downloads nothing and writes nothing', as assert.ok(run.stdout.includes(pin.url), 'the report names the URL it would fetch') assert.ok(run.stdout.includes(pin.sha256), 'the report names the digest it would check') assert.match(run.stdout, /\(1\.2 kB\)/u, 'the report names the size it would download') - assert.match(run.stdout, new RegExp(home.replace(/\\/g, '\\\\'), 'u'), 'the report names the store it would write') + assert.ok(run.stdout.includes(home), 'the report names the literal store it would write') // Neither the store nor a scratch download exists: a dry run is a report. assert.equal(fs.existsSync(path.join(home, 'dsh-ops')), false) assert.deepEqual(fs.readdirSync(home), []) From 72e07e8be996e64204286b707be0e0c342fb2f4e Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:56:25 +0800 Subject: [PATCH 058/118] test(shell): align host trial and startup fixtures --- scripts/tools-trial.mjs | 5 +++-- test/bundle-startup.test.mjs | 8 ++++---- test/linux-shell.test.mjs | 14 ++++++++------ 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/scripts/tools-trial.mjs b/scripts/tools-trial.mjs index f502f0bc..688ed7cd 100644 --- a/scripts/tools-trial.mjs +++ b/scripts/tools-trial.mjs @@ -66,7 +66,8 @@ try { // can provision a shell, and record the reason when it cannot. try { const shell = await import('../lib/components/shell/index.js') - await ctx.plugin(shell.default ?? shell, { publishBashTool: true, enableShellTools: false, required: false, toolCallTimeoutMs: 120_000 }) + await ctx.plugin(shell.default ?? shell, { publishBashTool: true, enableShellTools: false, required: false, toolCallTimeoutMs: 120_000, + ...(process.env.DSH_OPS_TEST_BASH ? { bashPath: process.env.DSH_OPS_TEST_BASH } : {}) }) } catch (error) { record('ops_bash', 'skipped', `shell component did not mount: ${error.message.split('\n')[0]}`) } @@ -121,7 +122,7 @@ try { record('ops_replace', 'failed', 'the file on disk did not change') exited = 1 } - await attempt('bash', { command: 'node -e "console.log(\'trial-ok\')"' }, 'trial-ok', 'ran a real command', + await attempt('bash', { description: 'Print the trial success marker', command: 'node -e "console.log(\'trial-ok\')"' }, 'trial-ok', 'ran a real command', 'the shell row publishes nothing on a bare host; test/shells-bash-tool.test.mjs covers that contract') // Anything published but not yet in the table above is reported honestly rather than ignored. diff --git a/test/bundle-startup.test.mjs b/test/bundle-startup.test.mjs index 5cf37087..3d2d02a1 100644 --- a/test/bundle-startup.test.mjs +++ b/test/bundle-startup.test.mjs @@ -81,11 +81,11 @@ await test('a missing optional Bash leaves the file bundle available', async () agent.ctx = createScope(ctx, agent).ctx await ctx.serial('agent/created', { agent, source: 'startup' }) const names = () => ctx.get('tools').schemas(agent).map(tool => tool.name) - await waitFor(() => names().includes('ops_stat'), { what: 'file publication without optional Bash' }) + await waitFor(() => names().includes('ops_read_many'), { what: 'file publication without optional Bash' }) assert.equal(names().includes('ops_bash'), false) - const result = await ctx.get('tools').get('ops_stat', agent).execute({ path: path.join(root, 'absent') }, { agent, signal: new AbortController().signal }) - assert.equal(result.status, 'completed', JSON.stringify(result)) - assert.equal(result.exists, false) + const result = await ctx.get('tools').get('ops_read_many', agent).execute({ files: [{ path: path.join(root, 'absent') }] }, { agent, signal: new AbortController().signal }) + assert.equal(result.items[0].status, 'error', JSON.stringify(result)) + assert.equal(result.items[0].error.code, 'NOT_FOUND') } finally { await shutdown(ctx) } }) }) diff --git a/test/linux-shell.test.mjs b/test/linux-shell.test.mjs index ef11be7d..5a661346 100644 --- a/test/linux-shell.test.mjs +++ b/test/linux-shell.test.mjs @@ -238,12 +238,14 @@ await test('session bash resolves workdir before validating or spawning it (#180 const file = path.join(workspace, 'not-a-directory') fs.writeFileSync(file, 'file') for (const workdir of ['missing', 'not-a-directory', path.join(workspace, 'absent')]) { - await assert.rejects(() => execute({ command: 'touch must-not-run', workdir }), error => { - assert.match(error.message, /invalid workdir: must be an existing directory/) - assert.ok(error.message.includes(path.resolve(workspace, workdir)), error.message) - assert.doesNotMatch(error.message, /bash executable|provision-shells/) - return true - }) + const value = await execute({ command: 'touch must-not-run', workdir }) + assert.equal(value.status, 'failed') + assert.equal(value.failure.code, 'INVALID_BASH_ARGUMENT') + assert.equal(value.failure.effectState, 'none') + assert.equal(value.failure.retry, 'after-input-change') + assert.match(value.error, /invalid workdir: must be an existing directory/) + assert.ok(value.error.includes(path.resolve(workspace, workdir)), value.error) + assert.doesNotMatch(value.error, /bash executable|provision-shells/) } assert.equal(fs.existsSync(marker), false) assert.equal(fs.existsSync(path.join(workspace, 'must-not-run')), false) From 2d3c9e632fec905150f2da74128d84cf40a9779d Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:58:53 +0800 Subject: [PATCH 059/118] refactor(runtime): type worker selection and Native lifecycle --- docs/CHANGELOG.md | 7 ++++ docs/native-kernel.md | 7 ++++ ...37\347\224\237\345\206\205\346\240\270.md" | 6 ++++ src/file/{runtime.js => runtime.ts} | 33 ++++++++++++++----- src/ptc/{runtime.js => runtime.ts} | 25 +++++++------- test/capabilities.test.mjs | 7 ++++ test/types/tsconfig.json | 3 +- test/types/worker-runtime.ts | 26 +++++++++++++++ 8 files changed, 94 insertions(+), 20 deletions(-) rename src/file/{runtime.js => runtime.ts} (62%) rename src/ptc/{runtime.js => runtime.ts} (81%) create mode 100644 test/types/worker-runtime.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c0e61f85..62e2689e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,13 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Type worker resolution and Native runtime ownership (#261, 16/16) + +- Compile worker discovery and Native lifecycle as strict TypeScript; preserve runtime .js paths, + provenance checks and scoped teardown, and reject calls before runtime initialization. +- worker 解析与 Native 生命周期改为严格 TypeScript,保留发布路径和权限边界,未初始化调用返回明确失败值。 + + ### Expose scoped DOCX paragraphs (#261, 15b/16) - Add discoverable Native main-body paragraphs and typed PTC outcomes with explicit excluded content, diff --git a/docs/native-kernel.md b/docs/native-kernel.md index fbcf31f8..8dfd9849 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -298,3 +298,10 @@ keep canonical prefix data inside failure Outcomes without a looping page contin scans alone permit forward rescan pages, with revisions compared before combining results. References retain typed original page coverage, decoded/processed work, scope and capture facts. All reads use the same live authority, credentials, no-follow, shared budgets and cancellation path as file tools. + +## Typed runtime ownership (16/16) + +Worker selection and Native runtime lifecycle are strict TypeScript. Resolution is a discriminated +success/path or failure/reason value; configuration and Native tool names are checked at compile +time. Missing runtime initialization returns an unavailable value before creating call state. +The shipped runtime paths remain .js, with the same provenance, scope and cancellation behavior. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index de36bc3e..53d56091 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -237,3 +237,9 @@ PDF 能力不进入默认 schema:Native 经 `ops_discover`、PTC 经 `ops_sdk` 和 string[] runs。不支持或非法页面在失败 Outcome 内保留此前规范数据,不生成循环页续取; 仅稳定完整扫描可前进分页,组合前须比较 revision。引用保留原页面覆盖率、解码/处理工作量、 范围与捕获事实;每次读取复用文件工具的实时权限、凭据、nofollow、共享预算和取消路径。 + +## 类型化运行时归属(16/16) + +worker 选择与 Native 运行时生命周期改为严格 TypeScript。解析结果区分成功路径与失败原因, +配置及 Native 工具名在编译时检查。未初始化运行时在创建调用状态前返回不可用值。发布路径仍为 +.js,来源校验、作用域与取消行为保持一致。 diff --git a/src/file/runtime.js b/src/file/runtime.ts similarity index 62% rename from src/file/runtime.js rename to src/file/runtime.ts index 7cf8e1ec..18581534 100644 --- a/src/file/runtime.js +++ b/src/file/runtime.ts @@ -1,3 +1,6 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import type { FileToolName } from '../capabilities/registry.js' import { WORKER_TIMEOUT_MS } from '../worker-protocol.js' import { publicToolName } from '../policy.js' import { definition } from './contract.js' @@ -8,8 +11,21 @@ import { safeDisposeAll } from '../teardown.js' import { invoke, failure } from './worker.js' import { createNativeReadDedup } from '../modes/native/read-dedup.js' +type Execution = Parameters[1] +type NativeToolName = FileToolName | 'result' +export interface NativeRuntimeConfig { workerPath?: string; required?: boolean; denyCredentialReads?: boolean; toolCallTimeoutMs?: number } +type Reporter = (message: string, level?: string) => void export class NativeFileTools { - constructor({ ctx, config, report }) { + readonly ctx: Context + readonly config: NativeRuntimeConfig + readonly report: Reporter + file: string | undefined + readonly active: Map> + readonly disposers: (() => void)[] + private stopped: boolean + private readonly readDedup: ReturnType + private unregisterProvider: (() => void) | undefined + constructor({ ctx, config, report }: { ctx: Context; config: NativeRuntimeConfig; report?: Reporter }) { this.ctx = ctx this.config = config this.report = report ?? (() => {}) @@ -20,7 +36,7 @@ export class NativeFileTools { this.readDedup = createNativeReadDedup(ctx) } - async start(names = NATIVE_FILE_TOOLS) { + async start(names: readonly NativeToolName[] = NATIVE_FILE_TOOLS as readonly NativeToolName[]): Promise { this.file = this.config.workerPath if (!this.file) { if (this.config.required) throw new Error('Native file worker path is unavailable.') @@ -31,13 +47,14 @@ export class NativeFileTools { return names.map(publicToolName) } - definition(rawName) { - const execute = (args, exec) => this.call(rawName, args, exec) + definition(rawName: NativeToolName): ToolDefinition { + const execute: ToolDefinition['execute'] = (args, exec) => this.call(rawName, args, exec) return this.readDedup.wrap(rawName === 'result' ? resultDefinition(execute) : definition(rawName, execute)) } - async call(tool, args, exec) { + async call(tool: NativeToolName, args: unknown, exec: Execution): Promise { if (this.stopped) return failure('POLICY_DENIED', 'Native file component is disposed.') + if (!this.file) return failure('EXECUTOR_UNAVAILABLE', 'Native file worker path is unavailable.') const controller = new AbortController() const abort = () => controller.abort(exec?.signal?.reason) if (exec?.signal?.aborted) abort() @@ -56,14 +73,14 @@ export class NativeFileTools { } } - cancel(reason) { + cancel(reason: string): void { for (const controller of this.active.keys()) controller.abort(new Error(reason)) } - async stop() { + async stop(): Promise { this.stopped = true this.readDedup.dispose() - safeDisposeAll([this.unregisterProvider].filter(Boolean), error => this.report('dsh-ops: Native pager teardown failed: ' + error, 'warn'), 'result provider') + safeDisposeAll([this.unregisterProvider].filter((dispose): dispose is () => void => typeof dispose === 'function'), error => this.report('dsh-ops: Native pager teardown failed: ' + error, 'warn'), 'result provider') for (const controller of this.active.keys()) controller.abort(new Error('Native file component unloaded')) await Promise.allSettled(this.active.values()) // Isolate each teardown: one throwing worker registration must not strand the rest. diff --git a/src/ptc/runtime.js b/src/ptc/runtime.ts similarity index 81% rename from src/ptc/runtime.js rename to src/ptc/runtime.ts index c7009b00..6bb1cb90 100644 --- a/src/ptc/runtime.js +++ b/src/ptc/runtime.ts @@ -3,9 +3,12 @@ import { createHash } from 'node:crypto' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +export interface WorkerSelection { binaryPath?: string } +export interface RuntimeOptions { packageRoot?: string; env?: NodeJS.ProcessEnv; platform?: string; arch?: string } +export type WorkerResolution = { ok: true; file: string } | { ok: false; reason: string } export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') export const PLATFORMS = Object.freeze(['win32-x64', 'linux-x64', 'darwin-arm64']) -export function workerName(platform = process.platform) { return platform === 'win32' ? 'dsh-ops-worker.exe' : 'dsh-ops-worker' } +export function workerName(platform: string = process.platform) { return platform === 'win32' ? 'dsh-ops-worker.exe' : 'dsh-ops-worker' } /** * Read a JSON file, or name it. * @@ -19,13 +22,13 @@ export function workerName(platform = process.platform) { return platform === 'w * (#30's point). The package-relative name keeps it actionable — you still know which artifact is * wrong. */ -function readJson(file, what, shown) { +function readJson(file: string, what: string, shown: string): Record { let text try { text = fs.readFileSync(file, 'utf8') } catch (error) { - throw new Error(`Native worker ${what} at ${shown} could not be read (${error?.code ?? error?.name ?? 'unknown'})`) + throw new Error(`Native worker ${what} at ${shown} could not be read (${(error as NodeJS.ErrnoException)?.code ?? (error as Error)?.name ?? 'unknown'})`) } - try { return JSON.parse(text) } catch (error) { - throw new Error(`Native worker ${what} at ${shown} is not valid JSON (${error?.name ?? 'unknown'})`) + try { return JSON.parse(text) as Record } catch (error) { + throw new Error(`Native worker ${what} at ${shown} is not valid JSON (${(error as Error)?.name ?? 'unknown'})`) } } /** @@ -43,7 +46,7 @@ function readJson(file, what, shown) { * @param {object} [options] - same as {@link resolveWorker}. * @returns {{ok: true, file: string}|{ok: false, reason: string}} the attempt. */ -export function tryResolveWorker(config = {}, options = {}) { +export function tryResolveWorker(config: WorkerSelection = {}, options: RuntimeOptions = {}): WorkerResolution { const platform = options.platform ?? process.platform const arch = options.arch ?? process.arch try { @@ -52,11 +55,11 @@ export function tryResolveWorker(config = {}, options = {}) { } return { ok: true, file: resolveWorker(config, options) } } catch (error) { - return { ok: false, reason: String(error?.message ?? error) } + return { ok: false, reason: String((error as Error)?.message ?? error) } } } -export function resolveWorker(config = {}, { packageRoot = PACKAGE_ROOT, env = process.env, platform = process.platform, arch = process.arch } = {}) { +export function resolveWorker(config: WorkerSelection = {}, { packageRoot = PACKAGE_ROOT, env = process.env, platform = process.platform, arch = process.arch }: RuntimeOptions = {}): string { if (!PLATFORMS.includes(`${platform}-${arch}`)) throw new Error(`dsh-ops 0.3.1 supports Windows x64, Linux x64 and macOS Apple Silicon (darwin-arm64) only; unsupported runtime target ${platform}-${arch}.`) const explicit = config.binaryPath ?? env.DSH_OPS_WORKER_BIN const fromPackage = path.resolve(packageRoot, 'runtime', `${platform}-${arch}`, workerName(platform)) @@ -70,8 +73,8 @@ export function resolveWorker(config = {}, { packageRoot = PACKAGE_ROOT, env = p // Compare **resolved, case-folded** paths, never raw strings: Windows is // case-insensitive, so `…\RUNTIME\…` names the same file as the candidate we // derived — a substring/spelling test would let it skip verification entirely. - const same = (a, b) => (platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b) - const skipped = [] + const same = (a: string, b: string) => (platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b) + const skipped: string[] = [] for (const { file, note } of candidates) { if (!path.isAbsolute(file)) throw new Error('Native worker selection must be an absolute path.') let stat @@ -79,7 +82,7 @@ export function resolveWorker(config = {}, { packageRoot = PACKAGE_ROOT, env = p // Only "no such file" is an ordinary miss for a candidate list. A directory the // process may not traverse, or a symlink that dangles, is a real condition that // an "unavailable" message would hide behind the same words. - if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw new Error(`Native worker candidate ${file} could not be inspected: ${error.message}`) + if ((error as NodeJS.ErrnoException).code !== 'ENOENT' && (error as NodeJS.ErrnoException).code !== 'ENOTDIR') throw new Error(`Native worker candidate ${file} could not be inspected: ${(error as Error).message}`) skipped.push(`${file} (absent)`); continue } if (!stat.isFile()) { skipped.push(`${file} (not a regular file)`); continue } diff --git a/test/capabilities.test.mjs b/test/capabilities.test.mjs index 0495aa3f..7d7996bf 100644 --- a/test/capabilities.test.mjs +++ b/test/capabilities.test.mjs @@ -83,4 +83,11 @@ await test('native registration consumes the catalogue while execute values stay await runtime.stop() assert.deepEqual(disposed.sort(), registered.map(item => item.name).sort()) }) +await test('unstarted Native runtimes return an unavailable value before creating call state', async () => { + const runtime = new NativeFileTools({ ctx: {}, config: { workerPath: 'fixture-worker' } }) + const value = await runtime.call('stat', { path: '.' }, {}) + assert.equal(value.error.code, 'EXECUTOR_UNAVAILABLE') + assert.match(value.error.message, /worker path is unavailable/) + assert.equal(runtime.active.size, 0) +}) report('capabilities') diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json index c99b6ddc..9f3a6cec 100644 --- a/test/types/tsconfig.json +++ b/test/types/tsconfig.json @@ -6,6 +6,7 @@ }, "include": [ "kernel.ts", - "ptc-sdk.ts" + "ptc-sdk.ts", + "worker-runtime.ts" ] } diff --git a/test/types/worker-runtime.ts b/test/types/worker-runtime.ts new file mode 100644 index 00000000..b5dee341 --- /dev/null +++ b/test/types/worker-runtime.ts @@ -0,0 +1,26 @@ +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type { Context } from '@deepseek-ai/cordis' +import { NativeFileTools } from '../../src/file/runtime.js' +import { resolveWorker, tryResolveWorker } from '../../src/ptc/runtime.js' +declare const ctx: Context +const runtime = new NativeFileTools({ ctx, config: { workerPath: '/worker', toolCallTimeoutMs: 30000 } }) +void runtime.definition('table_read') +void runtime.definition('result') +// @ts-expect-error Native definitions cannot expose batch-only APIs. +void runtime.definition('batch') +// @ts-expect-error Timeout configuration is numeric, not an unvalidated string. +void new NativeFileTools({ ctx, config: { toolCallTimeoutMs: '30000' } }) +// @ts-expect-error An explicit worker path must be a string. +void resolveWorker({ binaryPath: 3 }) +const selected = tryResolveWorker({}, { platform: 'linux', arch: 'x64' }) +if (selected.ok) { + const file: string = selected.file + // @ts-expect-error Failure detail is unavailable on a successful resolution. + void selected.reason + void file +} else { + const reason: string = selected.reason + // @ts-expect-error A failed resolution cannot provide a worker path. + void selected.file + void reason +} From f1d11e11fe64d59344528bbb86b49488b2682499 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:01:54 +0800 Subject: [PATCH 060/118] refactor(shell): type runtime pins and execution helpers --- src/{bash-pe.js => bash-pe.ts} | 12 ++++++------ src/{paths.js => paths.ts} | 2 +- .../{argument-error.js => argument-error.ts} | 12 +++++++----- src/shells/{constants.js => constants.ts} | 4 ++-- src/shells/{pins.js => pins.ts} | 10 ++++++---- src/shells/{spawn-error.js => spawn-error.ts} | 14 ++++++++------ src/{teardown.js => teardown.ts} | 4 ++-- 7 files changed, 32 insertions(+), 26 deletions(-) rename src/{bash-pe.js => bash-pe.ts} (76%) rename src/{paths.js => paths.ts} (90%) rename src/shells/{argument-error.js => argument-error.ts} (80%) rename src/shells/{constants.js => constants.ts} (96%) rename src/shells/{pins.js => pins.ts} (95%) rename src/shells/{spawn-error.js => spawn-error.ts} (89%) rename src/{teardown.js => teardown.ts} (89%) diff --git a/src/bash-pe.js b/src/bash-pe.ts similarity index 76% rename from src/bash-pe.js rename to src/bash-pe.ts index 66d34b37..4bbc99a9 100644 --- a/src/bash-pe.js +++ b/src/bash-pe.ts @@ -1,14 +1,14 @@ /** Strict PE import redirection, never patch executable instructions or original package bytes. */ -export function patchMsys(bytes) { +export function patchMsys(bytes: Uint8Array) { const b = Buffer.from(bytes) if (b.toString('ascii',0,2) !== 'MZ') throw Error('Expected PE') const pe=b.readUInt32LE(60), optional=pe+24 if(b.readUInt32LE(pe)!==0x4550 || b.readUInt16LE(pe+4)!==0x8664 || b.readUInt16LE(optional)!==0x20b) throw Error('Expected x64 PE32+') - const section=optional+b.readUInt16LE(pe+20), sections=[] + const section=optional+b.readUInt16LE(pe+20), sections: { rva: number; size: number; raw: number }[]=[] for(let i=0;i{const s=sections.find(s=>r>=s.rva&&r{const at=off(r),end=b.indexOf(0,at);if(end<0||end-at>256)throw Error('Invalid PE import name');return b.toString('ascii',at,end)} - const exports={} + const off=(r: number)=>{const s=sections.find(s=>r>=s.rva&&r{const at=off(r),end=b.indexOf(0,at);if(end<0||end-at>256)throw Error('Invalid PE import name');return b.toString('ascii',at,end)} + const exports: Record={} let table=off(b.readUInt32LE(optional+120)) for(let count=0;b.readUInt32LE(table+12);count++,table+=20) { if(count>64)throw Error('PE import limit') @@ -22,7 +22,7 @@ export function patchMsys(bytes) { if(!exports['kernel32.dll']?.includes('CreateNamedPipeA')||!exports['ntdll.dll']?.includes('NtCreateNamedPipeFile'))throw Error('Unexpected MSYS imports') return {bytes:b,exports} } -export function proxyDefinition(names, dll) { +export function proxyDefinition(names: readonly string[], dll: string) { const overrides=dll==='kernel32.dll'?['CreateNamedPipeA','CreatePipe','IdnToAscii','IdnToUnicode']:['NtCreateNamedPipeFile','NtCreateSection'] return `LIBRARY ${dll==='kernel32.dll'?'opsipc':'opsnt'}\nEXPORTS\n`+names.map(n=>` ${n}=${overrides.includes(n)?'Ops'+n:(dll==='kernel32.dll'?'KERNEL32.':'NTDLL.')+n}`).join('\n')+'\n' } diff --git a/src/paths.js b/src/paths.ts similarity index 90% rename from src/paths.js rename to src/paths.ts index b9dea814..0d1731fc 100644 --- a/src/paths.js +++ b/src/paths.ts @@ -16,7 +16,7 @@ export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta. * @param {NodeJS.ProcessEnv} [env] - the environment to read. * @returns {string} the absolute DSH home. */ -export function dshHome(env = process.env) { +export function dshHome(env: NodeJS.ProcessEnv = process.env) { return env.DSH_HOME && env.DSH_HOME.trim() !== '' ? path.resolve(env.DSH_HOME) : path.join(os.homedir(), '.dsh') diff --git a/src/shells/argument-error.js b/src/shells/argument-error.ts similarity index 80% rename from src/shells/argument-error.js rename to src/shells/argument-error.ts index e9045144..d93bfeb3 100644 --- a/src/shells/argument-error.js +++ b/src/shells/argument-error.ts @@ -13,8 +13,8 @@ * actually wrong never reached the caller (#204 P1, #235). * * A distinct type is what lets the session layer keep the two apart: the - * executor's runtime failures are values, while a caller's bad argument is a - * thrown refusal whose message names the field and what it expected. + * internal validator throws a refusal naming the field; the public host adapter + * converts it to a failed foreground value with no effects and input-change guidance. * * @module dsh-ops/shells/argument-error */ @@ -29,11 +29,13 @@ export const BASH_ARGUMENT_ERROR_CODE = 'INVALID_BASH_ARGUMENT' * command ran and which field to fix. */ export class BashArgumentError extends Error { + declare readonly code: string + declare readonly argument?: string /** * @param {string} message - the refusal, naming the argument and what was expected. * @param {string} [argument] - the declared argument to fix, when the refusal is one field. */ - constructor(message, argument) { + constructor(message: string, argument?: string) { super(message) this.name = 'BashArgumentError' this.code = BASH_ARGUMENT_ERROR_CODE @@ -51,6 +53,6 @@ export class BashArgumentError extends Error { * @param {unknown} error - the thrown value. * @returns {boolean} whether it is an argument refusal. */ -export function isBashArgumentError(error) { - return error instanceof BashArgumentError || error?.code === BASH_ARGUMENT_ERROR_CODE +export function isBashArgumentError(error: unknown) { + return error instanceof BashArgumentError || ((typeof error === 'object' && error !== null || typeof error === 'function') && 'code' in error && error.code === BASH_ARGUMENT_ERROR_CODE) } diff --git a/src/shells/constants.js b/src/shells/constants.ts similarity index 96% rename from src/shells/constants.js rename to src/shells/constants.ts index ea843ed9..e98fbe04 100644 --- a/src/shells/constants.js +++ b/src/shells/constants.ts @@ -107,8 +107,8 @@ export const ENV_OVERRIDES = Object.freeze({ * @param {NodeJS.ProcessEnv} [explicit] - the caller's own entries. * @returns {NodeJS.ProcessEnv} the environment to pass to the spawn. */ -export function shellEnvOverrides(ambient = process.env, explicit = {}) { - const env = { ...ENV_OVERRIDES, ...explicit } +export function shellEnvOverrides(ambient: NodeJS.ProcessEnv = process.env, explicit: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...ENV_OVERRIDES, ...explicit } const all = explicit.LC_ALL ?? ambient.LC_ALL if (typeof all === 'string' && all !== '') { env.LC_ALL = '' diff --git a/src/shells/pins.js b/src/shells/pins.ts similarity index 95% rename from src/shells/pins.js rename to src/shells/pins.ts index 9edc5259..76a3d906 100644 --- a/src/shells/pins.js +++ b/src/shells/pins.ts @@ -28,6 +28,8 @@ import path from 'node:path' import { dshHome } from '../paths.js' +export type ShellKind = 'bash' | 'pwsh' + /** * The platform packages a deployment MAY carry a shell in, and the subdirectory * each keeps its shell in. Nothing requires them: the pinned upstream download @@ -52,7 +54,7 @@ export const PLATFORM_PACKAGES = Object.freeze({ * @param {string} [platform] - `process.platform`. * @returns {string} the executable's file name. */ -export function shellExecutable(kind, platform = process.platform) { +export function shellExecutable(kind: ShellKind, platform: string = process.platform) { return platform === 'win32' ? PLATFORM_PACKAGES[kind].executable : kind } @@ -146,7 +148,7 @@ export const SHELL_UPSTREAM_PINS = Object.freeze([ * @param {string} [options.arch] - `process.arch`. * @returns {object|undefined} the pin, or undefined when this platform has none. */ -export function shellUpstreamPin({ kind, platform = process.platform, arch = process.arch }) { +export function shellUpstreamPin({ kind, platform = process.platform, arch = process.arch }: { kind: ShellKind; platform?: string; arch?: string }) { return SHELL_UPSTREAM_PINS.find((pin) => ( pin.name === kind && pin.platform === platform && pin.arch === arch )) @@ -159,7 +161,7 @@ export function shellUpstreamPin({ kind, platform = process.platform, arch = pro * @param {NodeJS.ProcessEnv} [options.env] - the environment to read `DSH_HOME` from. * @returns {string} the absolute directory (`/dsh-ops/shells`). */ -export function provisionedShellStore({ env = process.env } = {}) { +export function provisionedShellStore({ env = process.env }: { env?: NodeJS.ProcessEnv } = {}) { return path.join(dshHome(env), 'dsh-ops', 'shells') } @@ -171,6 +173,6 @@ export function provisionedShellStore({ env = process.env } = {}) { * @param {NodeJS.ProcessEnv} [options.env] - the environment to read `DSH_HOME` from. * @returns {string} the absolute directory (`//`). */ -export function provisionedShellVersionDir({ kind, version, env = process.env }) { +export function provisionedShellVersionDir({ kind, version, env = process.env }: { kind: ShellKind; version: string; env?: NodeJS.ProcessEnv }) { return path.join(provisionedShellStore({ env }), kind, version) } diff --git a/src/shells/spawn-error.js b/src/shells/spawn-error.ts similarity index 89% rename from src/shells/spawn-error.js rename to src/shells/spawn-error.ts index 61e69d5a..3300be07 100644 --- a/src/shells/spawn-error.js +++ b/src/shells/spawn-error.ts @@ -11,6 +11,7 @@ */ import fs from 'node:fs' +import { errorMessage } from './types.js' import { GENERIC_BASH_REMEDY } from './paths.js' /** @@ -25,7 +26,7 @@ import { GENERIC_BASH_REMEDY } from './paths.js' * @param {unknown} workdir - the cwd handed to `ctx.subprocess.spawn`. * @returns {boolean} whether a workdir was named and is no longer a directory. */ -function workdirIsGone(workdir) { +function workdirIsGone(workdir: unknown) { if (typeof workdir !== 'string' || workdir === '') return false try { return fs.statSync(workdir, { throwIfNoEntry: false })?.isDirectory() !== true @@ -52,7 +53,7 @@ const SYSTEM_BASH_SPAWN_REMEDY = 'The system bash this deployment fell back to i * profile installation, so most were sent somewhere that could not help. * @type {Readonly>} */ -const BASH_SPAWN_REMEDY = Object.freeze({ +const BASH_SPAWN_REMEDY: Readonly> = Object.freeze({ bundled: "The bundled bash left the profile after it was resolved; restore the profile's " + 'dependencies, or run `dsh-ops provision-shells --bash` to fetch the pinned copy from ' + 'upstream releases instead.', @@ -86,9 +87,10 @@ const DEFAULT_BASH_SPAWN_REMEDY = `${GENERIC_BASH_REMEDY}.` * @param {string} [workdir] - the directory the spawn was told to run in. * @returns {string} the model-facing error text. */ -export function formatBashSpawnError(error, file, source, workdir) { - const code = typeof error?.code === 'string' ? ` (${error.code})` : '' - if (error?.code === 'ENOENT') { +export function formatBashSpawnError(error: unknown, file: string, source: string, workdir?: string) { + const errorCode = (typeof error === 'object' && error !== null || typeof error === 'function') && 'code' in error ? error.code : undefined + const code = typeof errorCode === 'string' ? ` (${errorCode})` : '' + if (errorCode === 'ENOENT') { // Name what is actually missing. A cwd that disappeared between the pre-spawn check and the // spawn is the case #180 reported: the payload remedy below sent the caller to reinstall bash // when the session's own directory had gone away. @@ -100,5 +102,5 @@ export function formatBashSpawnError(error, file, source, workdir) { : DEFAULT_BASH_SPAWN_REMEDY return `bash executable is unavailable${code}: ${file}. ${remedy}` } - return `bash could not be started${code}: ${String(error?.message ?? error)}` + return `bash could not be started${code}: ${errorMessage(error)}` } diff --git a/src/teardown.js b/src/teardown.ts similarity index 89% rename from src/teardown.js rename to src/teardown.ts index 30e92b7e..6fc345f2 100644 --- a/src/teardown.js +++ b/src/teardown.ts @@ -20,7 +20,7 @@ * @param {string} [label] - what was being torn down, for the log. * @returns {boolean} true when the callback ran (or was absent) without throwing. */ -export function safeDispose(fn, onError, label = 'disposer') { +export function safeDispose(fn?: () => void, onError?: (error: unknown, label: string) => void, label = 'disposer') { if (typeof fn !== 'function') return true try { fn() @@ -38,7 +38,7 @@ export function safeDispose(fn, onError, label = 'disposer') { * @param {string} [label] - what was being torn down, for the log. * @returns {number} how many callbacks threw. */ -export function safeDisposeAll(disposers, onError, label = 'disposers') { +export function safeDisposeAll(disposers: Iterable<() => void>, onError?: (error: unknown, label: string) => void, label = 'disposers') { let failed = 0 // Snapshot first: `Map.values()` and friends are single-use iterators, so a caller that drains // the list more than once (or inspects it on the way) would silently tear down nothing the From 855afe0afc5063daafcfaa3414b6741682a95a3a Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:07:50 +0800 Subject: [PATCH 061/118] refactor(file): type the trusted worker boundary --- docs/CHANGELOG.md | 6 ++ docs/native-kernel.md | 6 ++ ...37\347\224\237\345\206\205\346\240\270.md" | 4 ++ src/file/{worker.js => worker.ts} | 55 ++++++++++------- src/worker-boundary.ts | 13 ++++ test/types/worker-runtime.ts | 13 ++++ test/worker-boundary.test.mjs | 60 +++++++++++++++++++ 7 files changed, 135 insertions(+), 22 deletions(-) rename src/file/{worker.js => worker.ts} (80%) create mode 100644 src/worker-boundary.ts create mode 100644 test/worker-boundary.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 62e2689e..98b7b37e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,12 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Type the trusted Native worker boundary (#261, 16/16) + +- Compile host policy, observations and Native worker invocation under strict TypeScript; reject + non-object arguments before spawn and retain unknown effects for malformed process results. +- Native 桥使用严格宿主类型;非对象参数在启动前拒绝,非法进程结果保留未知副作用且不误记成功读取。 + ### Type worker resolution and Native runtime ownership (#261, 16/16) - Compile worker discovery and Native lifecycle as strict TypeScript; preserve runtime .js paths, diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 8dfd9849..4f0fba91 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -305,3 +305,9 @@ Worker selection and Native runtime lifecycle are strict TypeScript. Resolution success/path or failure/reason value; configuration and Native tool names are checked at compile time. Missing runtime initialization returns an unavailable value before creating call state. The shipped runtime paths remain .js, with the same provenance, scope and cancellation behavior. + +The Native worker bridge also compiles under strict TypeScript, using host policy, filesystem and +execution types with unknown-valued input/output records. Non-object arguments fail before spawning; +malformed or non-object process results report unknown effects and never record a successful read. +Receipt observations preserve the provider's version facts. This validation does not change the fixed +Native subprocess path, permission checks or the separate PTC confinement path. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index 53d56091..12e8f207 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -243,3 +243,7 @@ PDF 能力不进入默认 schema:Native 经 `ops_discover`、PTC 经 `ops_sdk` worker 选择与 Native 运行时生命周期改为严格 TypeScript。解析结果区分成功路径与失败原因, 配置及 Native 工具名在编译时检查。未初始化运行时在创建调用状态前返回不可用值。发布路径仍为 .js,来源校验、作用域与取消行为保持一致。 + +Native worker 桥也使用严格 TypeScript,采用宿主权限、文件系统及执行类型,输入/输出先按未知值 +检查。非对象参数在启动前拒绝;进程返回非法 JSON 或非对象值时报告未知副作用,不记录成功读取。 +写入回执观察保留宿主版本事实,固定 Native 子进程路径、权限校验及独立 PTC 限制路径保持一致。 diff --git a/src/file/worker.js b/src/file/worker.ts similarity index 80% rename from src/file/worker.js rename to src/file/worker.ts index 24754674..2716388b 100644 --- a/src/file/worker.js +++ b/src/file/worker.ts @@ -1,4 +1,11 @@ /** Native file worker bridge. The host owns path resolution and DSH authority. */ +import type { Context } from '@deepseek-ai/cordis' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import type { FileSystem, FsTarget, FsInfo, FsObservation } from '@deepseek-ai/dsh-fs' +import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import type { EffectState } from '../kernel/outcome.js' +import type { WorkerInvokeConfig } from '../worker-boundary.js' +import { record, errorText } from '../worker-boundary.js' import { workerEnvelope } from '../worker-protocol.js' import path from 'node:path' import { credentialPath, credentialRefusal, credentialRoots } from '../credential-policy.js' @@ -15,19 +22,21 @@ import { resultStorage } from '../kernel/result-storage.js' export { resolveWorker } -export function failure(code, message, phase = 'preflight', effectState = 'none') { +export function failure(code: string, message: string, phase = 'preflight', effectState: EffectState = 'none') { return { status: code === 'POLICY_DENIED' ? 'rejected' : 'error', phase, changed: 0, matched: 0, skipped: 0, failed: 0, complete: false, omitted: 0, error: { code, phase, retryable: false, effectState, nextAction: effectState === 'none' ? 'Correct the request or restore host authority.' : 'Inspect the result; do not blindly retry after an unknown effect.', message } } } -function absoluteTarget(raw, cwd) { +function absoluteTarget(raw: unknown, cwd: string): string | undefined { if (typeof raw !== 'string' || raw.length === 0 || raw.includes('\0')) return undefined return path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(cwd, raw) } +type Observation = { target: FsTarget; info: FsInfo | undefined } + /** Host `fs` service, when the deployment has one; observation is skipped without it. */ -function hostFs(ctx) { +function hostFs(ctx: Context): FileSystem | undefined { try { const service = ctx.get('fs') return typeof service?.resolve === 'function' && typeof service?.stat === 'function' ? service : undefined @@ -35,7 +44,7 @@ function hostFs(ctx) { } /** Resolve and stat one path through the host provider, as host read does before reading. */ -async function hostObservation(fsService, file, cwd, signal) { +async function hostObservation(fsService: FileSystem | undefined, file: string, cwd: string, signal: AbortSignal): Promise { if (!fsService) return undefined try { const target = await fsService.resolve(file, { cwd, signal }) @@ -44,27 +53,27 @@ async function hostObservation(fsService, file, cwd, signal) { } /** Record an observation in the host's read-before-edit table; best effort, never fails the call. */ -function emitObserved(ctx, observed, exec) { +function emitObserved(ctx: Context, observed: Observation | undefined, exec: ToolRunContext): void { if (!observed) return const { target, info } = observed - const observation = info === undefined ? { kind: 'absent' } + const observation: FsObservation | undefined = info === undefined ? { kind: 'absent' } : info.type === 'file' && info.version ? { kind: 'present', version: info.version } : undefined if (!observation) return try { ctx.emit('fs/observed', target, observation, exec) } catch { /* the completed effect stands */ } } /** Refresh host observations for files a completed replace wrote (#17). */ -async function observeReplaced(ctx, fsService, value, cwd, exec, signal) { - if (!fsService || value?.status !== 'completed' || value.dryRun || !(value.changed > 0) || !Array.isArray(value.items)) return +async function observeReplaced(ctx: Context, fsService: FileSystem | undefined, value: Record, cwd: string, exec: ToolRunContext, signal: AbortSignal): Promise { + if (!fsService || value?.status !== 'completed' || value.dryRun || !(Number(value.changed) > 0) || !Array.isArray(value.items)) return for (const item of value.items) { - if (typeof item?.path !== 'string') continue + if (!record(item) || typeof item.path !== 'string') continue const observed = await hostObservation(fsService, item.path, cwd, signal) // A size mismatch means another writer landed after our publish; leave the host to report stale. if ((item.removed && observed && observed.info === undefined) || (observed?.info?.type === 'file' && observed.info.size === item.bytesAfter)) emitObserved(ctx, observed, exec) } } -export async function invoke(ctx, file, tool, args, exec, signal, config) { +export async function invoke(ctx: Context, file: string, tool: string, args: unknown, exec: ToolRunContext, signal?: AbortSignal, config?: WorkerInvokeConfig): Promise { try { const session = exec?.agent?.session if (!session?.id) return failure('EXECUTOR_UNAVAILABLE', 'An authoritative session identity is required.') @@ -81,22 +90,23 @@ export async function invoke(ctx, file, tool, args, exec, signal, config) { return failure('EXECUTOR_UNAVAILABLE', 'DSH file authority or preparation is unavailable; no unrestricted fallback was attempted.') } } -async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { +async function invokeTrusted(ctx: Context, file: string, tool: string, args: unknown, exec: ToolRunContext, signal: AbortSignal, config?: WorkerInvokeConfig): Promise { const session = exec?.agent?.session const cwd = session?.header?.cwd if (!session?.id || typeof cwd !== 'string' || !path.isAbsolute(cwd)) return failure('EXECUTOR_UNAVAILABLE', 'An authoritative session identity and cwd are required.') const policyService = ctx.get('sandboxPolicy') const subprocess = ctx.get('subprocess') if (!policyService?.resolve || !subprocess?.spawn) return failure('EXECUTOR_UNAVAILABLE', 'DSH policy and subprocess services are required.') - let policy + let policy: SandboxExecutionPolicy try { policy = policyService.resolve({ session }) } catch { return failure('EXECUTOR_UNAVAILABLE', 'DSH policy resolution failed.') } if (!['read-only', 'workspace-write', 'danger-full-access'].includes(policy?.mode) || typeof policy.workspaceRoot !== 'string' || !path.isAbsolute(policy.workspaceRoot)) return failure('EXECUTOR_UNAVAILABLE', 'Invalid authoritative DSH policy.') args = normalizeArguments(tool, args) - const mutates = FILE_MUTATIONS.includes(tool) + if (!record(args)) return failure('INVALID_ARGUMENT', 'Arguments must be an object.') + const mutates = (FILE_MUTATIONS as readonly string[]).includes(tool) const reads = tool === 'read_many' ? resolveReadMany(args, cwd) : undefined - if (reads?.error) return failure('INVALID_ARGUMENT', reads.error) + if (reads && 'error' in reads) return failure('INVALID_ARGUMENT', reads.error) const operationPath = absoluteTarget(args?.path ?? '.', cwd) if (!operationPath) return failure('INVALID_ARGUMENT', 'path must be a non-empty path.') // Credential locations are refused before the worker is spawned, so no read of one ever reaches a @@ -125,10 +135,10 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { // does; the fence is deliberately the narrower of the two. const privateTemp = sessionTempDir(session.id) const anchors = sessionAnchors(policy, cwd) - const trustedPath = value => anchoredPath(value, anchors) + const trustedPath = (value: string): string => anchoredPath(value, anchors) const writeRoots = [canonicalAnchor(policy.workspaceRoot), trustedPath(privateTemp)] - const writeTargets = tool === 'copy' ? [destination] : [operationPath, ...(destination ? [destination] : [])] - const requiredMode = current => { + const writeTargets = tool === 'copy' && destination ? [destination] : [operationPath, ...(destination ? [destination] : [])] + const requiredMode = (current: SandboxExecutionPolicy) => { const roots = [canonicalAnchor(current.workspaceRoot), anchoredPath(privateTemp, sessionAnchors(current, cwd))] return writeTargets.every(target => roots.some(root => inside(root, anchoredPath(target, sessionAnchors(current, cwd))))) ? 'workspace-write' : 'danger-full-access' @@ -187,19 +197,20 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { if (signal?.aborted || outcome.signal) return failure('RESULT_UNKNOWN', 'Worker interrupted; inspect postconditions.', 'execute', 'unknown') if (stdout.lossy) return failure('OUTPUT_LIMIT', 'Worker output exceeded the byte budget; operation facts may have changed.', 'verify', 'unknown') if (outcome.exitCode !== 0) return failure('RESULT_UNKNOWN', `Worker did not return a settled result: ${stderr.text.slice(0, 500)}`, 'execute', 'unknown') - let value + let value: unknown try { value = JSON.parse(stdout.text) } catch { return failure('RESULT_UNKNOWN', 'Worker returned invalid JSON; inspect postconditions.', 'verify', 'unknown') } + if (!record(value)) return failure('RESULT_UNKNOWN', 'Worker returned a non-object result; inspect postconditions.', 'verify', 'unknown') // Report reads/writes to the host's read-before-edit table, exactly like host read/edit. (#17) if (['inspect_local_file', 'stat'].includes(tool) && value?.status === 'completed') emitObserved(ctx, readObservation, exec) if (reads && Array.isArray(value?.items)) { for (const [index, item] of value.items.entries()) { - if (item?.status === 'completed') emitObserved(ctx, readObservations[index], exec) + if (record(item) && item.status === 'completed') emitObserved(ctx, readObservations[index], exec) } } if (mutates) await observeReplaced(ctx, fsService, value, cwd, exec, signal) - value = normalizeNativeDenial(value, policy, tool, mutates ? requiredMode(policy) : 'workspace-write') - return { ...value, sandbox: { mode: policy.mode, enforcement: 'policy', boundary: 'trusted-native-file-operations' } } + const normalized = normalizeNativeDenial(value, policy, tool, mutates ? requiredMode(policy) : 'workspace-write') + return { ...normalized, sandbox: { mode: policy.mode, enforcement: 'policy', boundary: 'trusted-native-file-operations' } } } catch (error) { - return failure(spawned ? 'RESULT_UNKNOWN' : 'EXECUTOR_UNAVAILABLE', `DSH trusted file executor failed; this is not a user authorization denial. ${String(error?.message ?? error)}`, 'execute', spawned ? 'unknown' : 'none') + return failure(spawned ? 'RESULT_UNKNOWN' : 'EXECUTOR_UNAVAILABLE', `DSH trusted file executor failed; this is not a user authorization denial. ${errorText(error)}`, 'execute', spawned ? 'unknown' : 'none') } } diff --git a/src/worker-boundary.ts b/src/worker-boundary.ts new file mode 100644 index 00000000..4b00778e --- /dev/null +++ b/src/worker-boundary.ts @@ -0,0 +1,13 @@ +/** Narrow untrusted worker and argument values before touching host services. */ +export interface WorkerInvokeConfig { + binaryPath?: string + required?: boolean + denyCredentialReads?: boolean + toolCallTimeoutMs?: number +} +export function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} +export function errorText(error: unknown): string { + return String(record(error) ? error.message ?? error : error) +} diff --git a/test/types/worker-runtime.ts b/test/types/worker-runtime.ts index b5dee341..cc7918e2 100644 --- a/test/types/worker-runtime.ts +++ b/test/types/worker-runtime.ts @@ -24,3 +24,16 @@ if (selected.ok) { void selected.file void reason } + +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import { invoke as fileCall, failure as fileFailure } from '../../src/file/worker.js' +declare const exec: ToolRunContext +const nativeValue: unknown = await fileCall(ctx, '/worker', 'stat', { path: 'safe' }, exec, undefined, { denyCredentialReads: true }) +// @ts-expect-error The transport result must be narrowed before accessing canonical fields. +void nativeValue.status +// @ts-expect-error A worker executable path cannot be a number. +void fileCall(ctx, 3, 'stat', {}, exec) +// @ts-expect-error Credential policy configuration is boolean. +void fileCall(ctx, '/worker', 'stat', {}, exec, undefined, { denyCredentialReads: 'true' }) +// @ts-expect-error Effect state must be an explicit kernel fact. +void fileFailure('RESULT_UNKNOWN', 'lost output', 'execute', 'success') diff --git a/test/worker-boundary.test.mjs b/test/worker-boundary.test.mjs new file mode 100644 index 00000000..a8e7401a --- /dev/null +++ b/test/worker-boundary.test.mjs @@ -0,0 +1,60 @@ +/** Host worker boundaries treat arguments and process output as untrusted values. */ +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { bootHost, shutdown } from './lib/host.mjs' +import { invoke as fileCall } from '../lib/file/worker.js' + +async function fixture(body) { + await withTempDir('worker-boundary', async root => { + const { ctx } = await bootHost() + const state = { text: '{"status":"completed"}', spawns: 0, observed: 0 } + try { + await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: () => ({ mode: 'danger-full-access', workspaceRoot: root }) })) + await ctx.plugin(inner => inner.provide('subprocess', { spawn: () => { + state.spawns++ + return { done: Promise.resolve({ exitCode: 0 }), collected: { + stdout: { readFrom: () => ({ text: state.text, lossy: false }) }, + stderr: { readFrom: () => ({ text: '', lossy: false }) }, + } } + } })) + await ctx.plugin(inner => inner.provide('fs', { + resolve: async file => file, stat: async () => ({ type: 'file', size: 0, version: 'observed-version' }), + })) + ctx.on('fs/observed', () => state.observed++) + const exec = { agent: { session: { id: 'worker-boundary', header: { cwd: root } } } } + await body({ ctx, exec, state, signal: new AbortController().signal }) + } finally { await shutdown(ctx) } + }) +} + +await test('Native non-object arguments fail before spawning or observing files', async () => { + await fixture(async ({ ctx, exec, state, signal }) => { + for (const args of [undefined, null, 1, true, 'path', [], [{ path: 'safe.txt' }]]) { + const value = await fileCall(ctx, 'unused', 'stat', args, exec, signal) + assert.equal(value.error.code, 'INVALID_ARGUMENT', JSON.stringify(value)) + assert.equal(value.error.effectState, 'none') + } + assert.equal(state.spawns, 0); assert.equal(state.observed, 0) + }) +}) + +await test('Native invalid or scalar JSON after execution retains unknown effects and no read observation', async () => { + await fixture(async ({ ctx, exec, state, signal }) => { + const outputs = ['{', 'null', 'true', '0', '"completed"', '[]', '[{"status":"completed"}]'] + for (const text of outputs) { + state.text = text + const value = await fileCall(ctx, 'unused', 'stat', { path: 'safe.txt' }, exec, signal) + assert.equal(value.error.code, 'RESULT_UNKNOWN', JSON.stringify(value)) + assert.equal(value.error.effectState, 'unknown'); assert.equal(value.error.retryable, false) + assert.match(value.error.nextAction, /do not blindly retry/) + } + assert.equal(state.spawns, outputs.length); assert.equal(state.observed, 0) + state.text = '{"status":"completed","changed":1,"items":[null,0,{"path":"safe.txt","bytesAfter":0}]}' + const changed = await fileCall(ctx, 'unused', 'replace', { path: 'safe.txt', pattern: 'a', replacement: 'b' }, exec, signal) + assert.equal(changed.changed, 1); assert.equal(changed.status, 'completed') + assert.equal(state.observed, 1, 'only a valid matching receipt refreshes host observation') + state.text = 'null' + const unsettled = await fileCall(ctx, 'unused', 'write_file', { path: 'new.txt', content: 'new', expectedRevision: 'absent' }, exec, signal) + assert.equal(unsettled.error.effectState, 'unknown', 'a write may already have happened') + }) +}) +report('worker-boundary') From 059e0a2f1ae453f100df06f94535a8e982f7ce53 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:09:16 +0800 Subject: [PATCH 062/118] refactor(shell): type executable resolution probes --- src/{shells.js => shells.ts} | 2 +- src/shells/{paths.js => paths.ts} | 37 ++++++++++++++++----------- src/shells/{resolve.js => resolve.ts} | 16 +++++++++--- 3 files changed, 36 insertions(+), 19 deletions(-) rename src/{shells.js => shells.ts} (96%) rename src/shells/{paths.js => paths.ts} (91%) rename src/shells/{resolve.js => resolve.ts} (90%) diff --git a/src/shells.js b/src/shells.ts similarity index 96% rename from src/shells.js rename to src/shells.ts index 44b63d87..ab30731f 100644 --- a/src/shells.js +++ b/src/shells.ts @@ -67,4 +67,4 @@ export { export { publishShellTools } from './shells/publish.js' -/** @typedef {import('./shells/paths.js').ShellResolution} ShellResolution */ +export type { ShellResolution } from './shells/paths.js' diff --git a/src/shells/paths.js b/src/shells/paths.ts similarity index 91% rename from src/shells/paths.js rename to src/shells/paths.ts index 8d0737c1..f57499db 100644 --- a/src/shells/paths.js +++ b/src/shells/paths.ts @@ -20,6 +20,7 @@ import fs from 'node:fs' import path from 'node:path' import { createRequire } from 'node:module' +import type { ShellKind } from './pins.js' import { PLATFORM_PACKAGES, provisionedShellStore, @@ -27,6 +28,12 @@ import { shellUpstreamPin, } from './pins.js' +export type ShellResolution = { available: true; file: string; source: string; detail: string } + | { available: false; file?: undefined; source: string; detail: string } +export type BundledCause = 'no-manifest' | 'not-declared' | 'not-installed' | 'incomplete-tree' +interface BundleProbe { kind: ShellKind; bundleRoot: string; platform: string; arch: string } +type PackageProbe = { found: true; file: string } | { found: false; detail: string; cause: BundledCause } + /** * Historical PowerShell layout order, retained for compatibility probes only. * @type {readonly ['provisioned', 'package', 'vendor']} @@ -54,7 +61,7 @@ export const PWSH_LAYOUT_ORDER = Object.freeze(['provisioned', 'package', 'vendo * @param {string|undefined} file - the candidate path. * @returns {boolean} whether the candidate exists and is not a directory. */ -export function isSpawnableFile(file) { +export function isSpawnableFile(file: unknown): file is string { if (typeof file !== 'string' || file === '') return false try { const stat = fs.lstatSync(file) @@ -71,7 +78,7 @@ export function isSpawnableFile(file) { * @param {string} arch - `process.arch`. * @returns {string} the key. */ -function platformKey(platform, arch) { +function platformKey(platform: string, arch: string) { return `${platform}-${arch}` } @@ -100,7 +107,7 @@ export const BUNDLED_LAYOUT_ORDER = Object.freeze(['package', 'vendor']) * @param {string} options.arch - `process.arch`. * @returns {string} the path the bundled copy would occupy. */ -export function bundledShellPath({ kind, bundleRoot, platform, arch }) { +export function bundledShellPath({ kind, bundleRoot, platform, arch }: BundleProbe) { const target = PLATFORM_PACKAGES[kind] return path.join(bundleRoot, 'vendor', target.vendor, platformKey(platform, arch), shellExecutable(kind, platform)) } @@ -132,12 +139,12 @@ export function bundledShellPath({ kind, bundleRoot, platform, arch }) { * @param {string} options.arch - `process.arch`. * @returns {{found: true, file: string}|{found: false, detail: string, cause: BundledCause}} the candidate. */ -function platformPackageShell({ kind, bundleRoot, platform, arch }) { +function platformPackageShell({ kind, bundleRoot, platform, arch }: BundleProbe): PackageProbe { const target = PLATFORM_PACKAGES[kind] const name = `${target.prefix}${platformKey(platform, arch)}` let declared try { - const manifest = JSON.parse(fs.readFileSync(path.join(bundleRoot, 'package.json'), 'utf8')) + const manifest = JSON.parse(fs.readFileSync(path.join(bundleRoot, 'package.json'), 'utf8')) as { optionalDependencies?: Record; dependencies?: Record } declared = { ...manifest.optionalDependencies, ...manifest.dependencies } } catch { return { found: false, cause: 'no-manifest', detail: `${name} is not installed (no readable plugin manifest)` } @@ -166,7 +173,7 @@ function platformPackageShell({ kind, bundleRoot, platform, arch }) { * @param {(string|undefined)[]} candidates - candidate paths, most preferred first. * @returns {string|undefined} the first existing candidate. */ -export function preferredBundledFile(candidates) { +export function preferredBundledFile(candidates: readonly (string | undefined)[]) { for (const candidate of candidates) { if (isSpawnableFile(candidate)) return candidate } @@ -191,7 +198,7 @@ export function preferredBundledFile(candidates) { * the executable and, when there is none, why the plugin's own copy is * unusable — as text for a report, and as the cause behind it. */ -export function bundledShell({ kind, bundleRoot, platform, arch }) { +export function bundledShell({ kind, bundleRoot, platform, arch }: BundleProbe) { const packaged = platformPackageShell({ kind, bundleRoot, platform, arch }) const vendored = bundledShellPath({ kind, bundleRoot, platform, arch }) // The same preference rule, in the same order, as the L3 patch expression. @@ -200,7 +207,7 @@ export function bundledShell({ kind, bundleRoot, platform, arch }) { // the platform package's absence, and reporting the absence beside a usable // file would be a fact about nothing. return file === undefined - ? { file: undefined, detail: packaged.detail, cause: packaged.cause } + ? { file: undefined, detail: packaged.found ? undefined : packaged.detail, cause: packaged.found ? undefined : packaged.cause } : { file, detail: undefined, cause: undefined } } @@ -231,7 +238,7 @@ export function bundledShell({ kind, bundleRoot, platform, arch }) { * @returns {{file: string, version: string}|undefined} the executable and the * version directory it came from, or undefined when the store has none. */ -export function provisionedShell({ kind, env, platform = process.platform, arch = process.arch }) { +export function provisionedShell({ kind, env, platform = process.platform, arch = process.arch }: { kind: ShellKind; env: NodeJS.ProcessEnv; platform?: string; arch?: string }) { const store = path.join(provisionedShellStore({ env }), kind) let versions try { @@ -262,7 +269,7 @@ export function provisionedShell({ kind, env, platform = process.platform, arch * @param {string|undefined} options.pathValue - the PATH value to walk. * @returns {string|undefined} the first existing entry, or undefined. */ -export function fromPath({ names, pathValue, reject }) { +export function fromPath({ names, pathValue, reject }: { names: readonly string[]; pathValue?: string; reject?: (file: string) => boolean }) { if (typeof pathValue !== 'string' || pathValue.trim() === '') return undefined for (const entry of pathValue.split(path.delimiter)) { // PATH entries may carry surrounding quotes from `setx`-style definitions. @@ -287,11 +294,11 @@ export function fromPath({ names, pathValue, reject }) { * @param {string} platform - target platform. * @returns {boolean} whether the candidate is the WSL launcher. */ -export function isWslBashLauncher(file, env, platform) { +export function isWslBashLauncher(file: string, env: NodeJS.ProcessEnv, platform: string) { if (platform !== 'win32') return false const systemRoot = env.SystemRoot ?? env.SYSTEMROOT if (typeof systemRoot !== 'string' || systemRoot.trim() === '') return false - const normalize = value => path.win32.normalize(value).replace(/[\\/]+$/, '').toLowerCase() + const normalize = (value: string) => path.win32.normalize(value).replace(/[\\/]+$/, '').toLowerCase() return normalize(file) === normalize(path.win32.join(systemRoot, 'System32', 'bash.exe')) } @@ -309,7 +316,7 @@ export function isWslBashLauncher(file, env, platform) { * @param {string} options.platform - `process.platform`. * @returns {string[]} the candidate paths, in order. */ -export function wellKnownCandidates({ kind, env, platform }) { +export function wellKnownCandidates({ kind, env, platform }: { kind: ShellKind; env: NodeJS.ProcessEnv; platform: string }) { if (platform !== 'win32') { // Only bash has a POSIX rung here. PowerShell resolution belongs to // the host on every platform. @@ -349,7 +356,7 @@ export function wellKnownCandidates({ kind, env, platform }) { * @param {string} platform - `process.platform`. * @returns {string[]} the names, in probe order. */ -export function executableNames(kind, platform) { +export function executableNames(kind: ShellKind, platform: string) { const base = kind === 'bash' ? 'bash' : 'pwsh' return platform === 'win32' ? [`${base}.exe`, base] : [base] } @@ -418,7 +425,7 @@ export const POSIX_BASH_WELL_KNOWN = Object.freeze([ * @param {BundledCause|undefined} cause - why the plugin's own copy is unusable. * @returns {string} the remedy, as a clause that ends a detail line. */ -export function bashRemedy(cause) { +export function bashRemedy(cause: BundledCause | undefined) { if (cause === 'not-installed') { return 'the registry this profile installed from may not carry that platform package, ' + 'so run `dsh-ops provision-shells --bash` to fetch the pinned copy from upstream releases instead' diff --git a/src/shells/resolve.js b/src/shells/resolve.ts similarity index 90% rename from src/shells/resolve.js rename to src/shells/resolve.ts index 606008ea..8d9898c2 100644 --- a/src/shells/resolve.js +++ b/src/shells/resolve.ts @@ -23,6 +23,8 @@ */ import { PACKAGE_ROOT } from '../paths.js' +import type { ResolvedConfig } from '../config.js' +import type { BundledCause, ShellResolution } from './paths.js' import { POSIX_BASH_REMEDY, bashRemedy, @@ -35,6 +37,14 @@ import { wellKnownCandidates, } from './paths.js' +interface BashProbe { + configured?: string; provisioned?: { file: string; version: string }; bundled?: string + bundledDetail?: string; bundledCause?: BundledCause; allowSystemFallback: boolean + env: NodeJS.ProcessEnv; platform: string; posix?: boolean +} +export interface ShellResolutionOptions { bundleRoot?: string; env?: NodeJS.ProcessEnv; platform?: string; arch?: string } +type ShellConfig = Partial> + /** The directory this module was loaded from; the resolution root of a checkout. */ const MODULE_ROOT = PACKAGE_ROOT @@ -68,7 +78,7 @@ function resolveBash({ env, platform, posix = platform !== 'win32', -}) { +}: BashProbe): ShellResolution { if (configured !== undefined) { return isSpawnableFile(configured) ? { available: true, file: configured, source: 'config', detail: 'configured bash executable' } @@ -143,7 +153,7 @@ function resolveBash({ } /** The host owns PowerShell selection. Legacy OPS copies are never automatically activated. */ -function resolvePwsh() { +function resolvePwsh(): ShellResolution { return { available: false, file: undefined, source: 'host', detail: 'PowerShell is managed by the host; OPS does not supply or override its executable. Use dsh-ops upgrade-host-pwsh on Windows, then restart DSH. An explicit host pwshPath remains authoritative.' } } @@ -163,7 +173,7 @@ function resolvePwsh() { * @param {string} [options.arch] - `process.arch`. * @returns {{bash: import('./paths.js').ShellResolution, pwsh: import('./paths.js').ShellResolution}} one resolution per rung. */ -export function resolveShells(config, options = {}) { +export function resolveShells(config: ShellConfig | undefined, options: ShellResolutionOptions = {}): { bash: ShellResolution; pwsh: ShellResolution } { const bundleRoot = options.bundleRoot ?? MODULE_ROOT const env = options.env ?? process.env const platform = options.platform ?? process.platform From 534446c8575ce863af0bb94a1be57686f287611a Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:10:26 +0800 Subject: [PATCH 063/118] fix(gate): drain child output streams concurrently --- scripts/lib/gate-output.mjs | 12 ++++++++++++ scripts/verify-gate.mjs | 4 ++-- test/gate-output.test.mjs | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 scripts/lib/gate-output.mjs create mode 100644 test/gate-output.test.mjs diff --git a/scripts/lib/gate-output.mjs b/scripts/lib/gate-output.mjs new file mode 100644 index 00000000..7b946a03 --- /dev/null +++ b/scripts/lib/gate-output.mjs @@ -0,0 +1,12 @@ +/** Drain both pipes while a child is running; reading them in sequence can deadlock. */ +export async function collectProcessOutput(child, { stdout = process.stdout, stderr = process.stderr } = {}) { + let output = '' + const drain = async (stream, destination) => { + for await (const chunk of stream) { + output += chunk + destination.write(chunk) + } + } + await Promise.all([drain(child.stdout, stdout), drain(child.stderr, stderr)]) + return output +} diff --git a/scripts/verify-gate.mjs b/scripts/verify-gate.mjs index 0158a96d..510c81e2 100644 --- a/scripts/verify-gate.mjs +++ b/scripts/verify-gate.mjs @@ -42,6 +42,7 @@ import { tmpdir } from 'node:os' import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' +import { collectProcessOutput } from './lib/gate-output.mjs' const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const argv = process.argv.slice(2) @@ -116,8 +117,7 @@ async function run(title, command, args, { env = {}, optional = false, cwd = PAC }) let output = '' try { - for await (const chunk of child.stdout) { output += chunk; process.stdout.write(chunk) } - for await (const chunk of child.stderr) { output += chunk; process.stderr.write(chunk) } + output = await collectProcessOutput(child) } catch (error) { spawnError ??= error } diff --git a/test/gate-output.test.mjs b/test/gate-output.test.mjs new file mode 100644 index 00000000..e9816c71 --- /dev/null +++ b/test/gate-output.test.mjs @@ -0,0 +1,36 @@ +import { spawn } from 'node:child_process' +import { assert, test, report } from './lib/harness.mjs' +import { collectProcessOutput } from '../scripts/lib/gate-output.mjs' + +await test('gate drains saturated stderr and stdout concurrently and preserves a failing exit', async () => { + const size = 4 * 1024 * 1024 + const child = spawn(process.execPath, ['-e', ` + process.stderr.write('e'.repeat(${size})); + process.stdout.write('o'.repeat(${size})); + process.exitCode = 7; + `], { stdio: ['ignore', 'pipe', 'pipe'] }) + let timedOut = false + const timer = setTimeout(() => { timedOut = true; child.kill() }, 10000) + let stdout = '', stderr = '' + const finished = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', resolve) + }) + try { + const [output, code] = await Promise.all([ + collectProcessOutput(child, { + stdout: { write(chunk) { stdout += chunk } }, + stderr: { write(chunk) { stderr += chunk } }, + }), + finished, + ]) + assert.equal(timedOut, false, 'the stderr pipe must be drained before stdout closes') + assert.equal(code, 7, 'collecting output must not turn a failing step into success') + assert.equal(stdout, 'o'.repeat(size)) + assert.equal(stderr, 'e'.repeat(size)) + assert.equal(output.length, size * 2) + assert.equal(output.replaceAll('e', '').length, size) + assert.equal(output.replaceAll('o', '').length, size) + } finally { clearTimeout(timer); if (child.exitCode === null) child.kill() } +}) +report('gate-output') From 17eabb6a8f5c5367def03dc3952e776a95dca418 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:11:14 +0800 Subject: [PATCH 064/118] refactor(ptc): type confined worker execution --- docs/CHANGELOG.md | 6 ++++ docs/native-kernel.md | 6 ++++ ...37\347\224\237\345\206\205\346\240\270.md" | 4 +++ src/file/worker.ts | 2 +- src/ptc/{worker.js => worker.ts} | 34 ++++++++++++------- test/types/worker-runtime.ts | 9 +++++ test/worker-boundary.test.mjs | 29 ++++++++++++++++ 7 files changed, 76 insertions(+), 14 deletions(-) rename src/ptc/{worker.js => worker.ts} (80%) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 98b7b37e..66c1203b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,12 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Type the confined PTC worker boundary (#261, 16/16) + +- Compile PTC policy, confinement, runner diagnostics and receipts under strict TypeScript; retain + per-call authorization and unknown effects with audit identity when worker output is invalid. +- PTC 桥使用严格权限、限制执行与诊断类型;非法 worker 输出保留未知副作用及审计标识。 + ### Type the trusted Native worker boundary (#261, 16/16) - Compile host policy, observations and Native worker invocation under strict TypeScript; reject diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 4f0fba91..3be8b9c5 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -311,3 +311,9 @@ execution types with unknown-valued input/output records. Non-object arguments f malformed or non-object process results report unknown effects and never record a successful read. Receipt observations preserve the provider's version facts. This validation does not change the fixed Native subprocess path, permission checks or the separate PTC confinement path. + +The PTC bridge uses the same typed input/output narrowing, plus the host's exact confinement and +runner-failure contracts. Non-object argument failures precede batch admission; invalid JSON receipts +after a spawn retain unknown effects and the generated audit identity. Only recognized runner-failure +evidence may claim the wrapped worker never ran. Permission rechecks and per-call confined argv remain +mandatory; this migration adds no unconfined fallback or cache/business authority expansion. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index 12e8f207..e34de498 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -247,3 +247,7 @@ worker 选择与 Native 运行时生命周期改为严格 TypeScript。解析结 Native worker 桥也使用严格 TypeScript,采用宿主权限、文件系统及执行类型,输入/输出先按未知值 检查。非对象参数在启动前拒绝;进程返回非法 JSON 或非对象值时报告未知副作用,不记录成功读取。 写入回执观察保留宿主版本事实,固定 Native 子进程路径、权限校验及独立 PTC 限制路径保持一致。 + +PTC 桥复用类型化输入/输出检查,并采用宿主限制执行与 runner 失败契约。非对象参数在批任务启动前 +拒绝;启动后的非法 JSON 回执保留未知副作用与生成的审计标识。只有已识别的 runner 失败证据才可 +声明 worker 未运行;每次权限复查与限制 argv 保持必需,不增加无限制回退或缓存/业务权限。 diff --git a/src/file/worker.ts b/src/file/worker.ts index 2716388b..b20254b3 100644 --- a/src/file/worker.ts +++ b/src/file/worker.ts @@ -138,7 +138,7 @@ async function invokeTrusted(ctx: Context, file: string, tool: string, args: unk const trustedPath = (value: string): string => anchoredPath(value, anchors) const writeRoots = [canonicalAnchor(policy.workspaceRoot), trustedPath(privateTemp)] const writeTargets = tool === 'copy' && destination ? [destination] : [operationPath, ...(destination ? [destination] : [])] - const requiredMode = (current: SandboxExecutionPolicy) => { + const requiredMode = (current: SandboxExecutionPolicy): 'workspace-write' | 'danger-full-access' => { const roots = [canonicalAnchor(current.workspaceRoot), anchoredPath(privateTemp, sessionAnchors(current, cwd))] return writeTargets.every(target => roots.some(root => inside(root, anchoredPath(target, sessionAnchors(current, cwd))))) ? 'workspace-write' : 'danger-full-access' diff --git a/src/ptc/worker.js b/src/ptc/worker.ts similarity index 80% rename from src/ptc/worker.js rename to src/ptc/worker.ts index 14011f21..90b293d5 100644 --- a/src/ptc/worker.js +++ b/src/ptc/worker.ts @@ -1,4 +1,10 @@ /** Native worker bridge. Every execution uses DSH policy, confinement and process governance. */ +import type { Context } from '@deepseek-ai/cordis' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import type { SandboxExecutionPolicy, ConfinedArgv, RunnerFailureRule } from '@deepseek-ai/dsh-sandbox' +import type { EffectState } from '../kernel/outcome.js' +import type { WorkerInvokeConfig } from '../worker-boundary.js' +import { record, errorText } from '../worker-boundary.js' import { workerEnvelope } from '../worker-protocol.js' import { withCallBudget, CallBudgetError } from '../kernel/call-budget.js' import { withResultSession } from '../kernel/result-lifecycle.js' @@ -12,11 +18,11 @@ import { denied, authorityChanged, executionRestriction, normalizeNativeDenial, import { inside, canonicalAnchor } from '../authority-paths.js' import { prepareWorkspace } from '../workspace-preparation.js' export { resolveWorker } from './runtime.js' -export function failure(code, message, effectState = 'none') { +export function failure(code: string, message: string, effectState: EffectState = 'none') { return { status: 'error', error: { code, phase: effectState === 'none' ? 'preflight' : 'execute', retryable: false, effectState, nextAction: effectState === 'none' ? 'Correct the request or restore host authority.' : 'Inspect the persistent job manifest; never blindly rerun an interrupted batch.', message } } } -export async function invoke(ctx, file, tool, args, exec, signal, config) { +export async function invoke(ctx: Context, file: string, tool: string, args: unknown, exec: ToolRunContext, signal?: AbortSignal, config?: WorkerInvokeConfig): Promise { try { const session = exec?.agent?.session if (!session?.id) return failure('EXECUTOR_UNAVAILABLE', 'An authoritative session identity is required.') @@ -33,16 +39,17 @@ export async function invoke(ctx, file, tool, args, exec, signal, config) { return failure('EXECUTOR_UNAVAILABLE', 'DSH authority or execution services are unavailable; no unrestricted fallback was attempted.') } } -async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { +async function invokeTrusted(ctx: Context, file: string, tool: string, args: unknown, exec: ToolRunContext, signal: AbortSignal, config?: WorkerInvokeConfig): Promise { const session = exec?.agent?.session if (!session?.id) return failure('EXECUTOR_UNAVAILABLE', 'An authoritative session identity is required.') const policyService = ctx.get('sandboxPolicy'), subprocess = ctx.get('subprocess') if (!policyService?.resolve || !subprocess?.spawn) return failure('EXECUTOR_UNAVAILABLE', 'DSH policy and subprocess services are required.') + if (!record(args)) return failure('INVALID_ARGUMENT', 'Arguments must be an object.') const jobId = tool === 'batch' ? (args?.action === 'run' || args?.action === 'plan' ? `j-${randomUUID()}` : args?.id) : undefined - const errorResult = (code, message, effect = 'none') => ({ ...failure(code, message, effect), ...(jobId ? { detailsRef: jobId } : {}) }) - let policy, confined, spawned = false + const errorResult = (code: string, message: string, effect: EffectState = 'none') => ({ ...failure(code, message, effect), ...(jobId ? { detailsRef: jobId } : {}) }) + let confined: ConfinedArgv | undefined, spawned = false try { - policy = policyService.resolve({ session }) + const policy: SandboxExecutionPolicy = policyService.resolve({ session }) if (!['read-only', 'workspace-write', 'danger-full-access'].includes(policy?.mode) || !path.isAbsolute(policy?.workspaceRoot ?? '')) return failure('EXECUTOR_UNAVAILABLE', 'Invalid authoritative DSH policy; no worker was started.') // Model root is a selection, never an authority override. The DSH sandbox @@ -60,7 +67,7 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { if (verdict.denied) return credentialDenied(credentialRefusal(operationRoot, verdict.matched).message) } // Comparison only: never rewrite the selected root passed to the native worker. - const requiredMode = current => [current.workspaceRoot, sessionTempDir(session.id)] + const requiredMode = (current: SandboxExecutionPolicy): 'workspace-write' | 'danger-full-access' => [current.workspaceRoot, sessionTempDir(session.id)] .some(root => inside(canonicalAnchor(root), fs.realpathSync.native(operationRoot))) ? 'workspace-write' : 'danger-full-access' if (policy.mode === 'workspace-write' && requiredMode(policy) === 'danger-full-access') { return denied(policy, `${tool} outside the workspace and session temporary directory`, 'danger-full-access', 'The selected root is outside the user-authorized workspace.') @@ -72,7 +79,7 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { const sandbox = ctx.get('sandbox') if (!sandbox?.confine) return failure('EXECUTOR_UNAVAILABLE', 'No DSH process confinement provider; refusing unconfined execution.') await prepareWorkspace(policy) - confined = await sandbox.confine(argv, policy, signal) + confined = await sandbox.confine(argv, { ...policy, mode: policy.mode }, signal) if (!['full', 'partial'].includes(confined?.enforcement) || !Array.isArray(confined.argv) || !confined.argv.length) { return failure('EXECUTOR_UNAVAILABLE', 'Invalid DSH confinement result; refusing unconfined execution.') } @@ -115,16 +122,17 @@ async function invokeTrusted(ctx, file, tool, args, exec, signal, config) { } return errorResult('RESULT_UNKNOWN', 'Worker did not return a settled result; inspect audit records.', 'unknown') } - let value + let value: unknown try { value = JSON.parse(stdout.text) } catch { return errorResult('RESULT_UNKNOWN', 'Worker returned invalid JSON; inspect audit records.', 'unknown') } - value = normalizeNativeDenial(value, policy, `${tool} ${args?.action ?? ''}`.trim(), 'workspace-write') - return { ...value, ...(tool === 'batch' ? { root: operationRoot } : {}), ...(value.status === 'error' && jobId ? { detailsRef: jobId } : {}), sandbox: { mode: policy.mode, ...(confined ? { enforcement: confined.enforcement } : {}) } } + if (!record(value)) return errorResult('RESULT_UNKNOWN', 'Worker returned a non-object result; inspect audit records.', 'unknown') + const normalized = normalizeNativeDenial(value, policy, `${tool} ${args?.action ?? ''}`.trim(), 'workspace-write') + return { ...normalized, ...(tool === 'batch' ? { root: operationRoot } : {}), ...(normalized.status === 'error' && jobId ? { detailsRef: jobId } : {}), sandbox: { mode: policy.mode, ...(confined ? { enforcement: confined.enforcement } : {}) } } } catch (error) { return errorResult(spawned ? 'RESULT_UNKNOWN' : 'EXECUTOR_UNAVAILABLE', - `DSH execution infrastructure failed, not a user authorization denial: ${String(error?.message ?? error)}`, spawned ? 'unknown' : 'none') + `DSH execution infrastructure failed, not a user authorization denial: ${errorText(error)}`, spawned ? 'unknown' : 'none') } } -function runnerFailure(exitCode, stderr, rules) { +function runnerFailure(exitCode: number | null, stderr: string, rules: readonly RunnerFailureRule[]): boolean { if (exitCode === 0 || exitCode == null) return false return rules.some(rule => (!rule.allowedExitCodes || rule.allowedExitCodes.includes(exitCode)) && stderr.split(/\r?\n/).some(line => !(rule.informationalLines ?? []).some(s => s.toLowerCase() === line.toLowerCase()) diff --git a/test/types/worker-runtime.ts b/test/types/worker-runtime.ts index cc7918e2..b8a38690 100644 --- a/test/types/worker-runtime.ts +++ b/test/types/worker-runtime.ts @@ -37,3 +37,12 @@ void fileCall(ctx, 3, 'stat', {}, exec) void fileCall(ctx, '/worker', 'stat', {}, exec, undefined, { denyCredentialReads: 'true' }) // @ts-expect-error Effect state must be an explicit kernel fact. void fileFailure('RESULT_UNKNOWN', 'lost output', 'execute', 'success') + +import { invoke as ptcCall, failure as ptcFailure } from '../../src/ptc/worker.js' +const ptcValue: unknown = await ptcCall(ctx, '/worker', 'result', { action: 'page' }, exec) +// @ts-expect-error A transport receipt is narrowed before caller-specific projection. +void ptcValue.status +// @ts-expect-error A subprocess cancellation signal cannot be a configuration object. +void ptcCall(ctx, '/worker', 'batch', {}, exec, {}) +// @ts-expect-error PTC effects follow the same kernel vocabulary as Native effects. +void ptcFailure('RESULT_UNKNOWN', 'lost output', 'success') diff --git a/test/worker-boundary.test.mjs b/test/worker-boundary.test.mjs index a8e7401a..e6f45953 100644 --- a/test/worker-boundary.test.mjs +++ b/test/worker-boundary.test.mjs @@ -2,6 +2,7 @@ import { assert, test, report, withTempDir } from './lib/harness.mjs' import { bootHost, shutdown } from './lib/host.mjs' import { invoke as fileCall } from '../lib/file/worker.js' +import { invoke as ptcCall } from '../lib/ptc/worker.js' async function fixture(body) { await withTempDir('worker-boundary', async root => { @@ -57,4 +58,32 @@ await test('Native invalid or scalar JSON after execution retains unknown effect assert.equal(unsettled.error.effectState, 'unknown', 'a write may already have happened') }) }) +await test('PTC non-object arguments fail before spawning batch work', async () => { + await fixture(async ({ ctx, exec, state, signal }) => { + for (const args of [undefined, null, 1, true, 'run', [], [{ action: 'run' }]]) { + const value = await ptcCall(ctx, 'unused', 'batch', args, exec, signal) + assert.equal(value.error.code, 'INVALID_ARGUMENT', JSON.stringify(value)) + assert.equal(value.error.effectState, 'none'); assert.equal(value.detailsRef, undefined) + } + assert.equal(state.spawns, 0) + }) +}) + +await test('PTC unsettled JSON after spawning keeps possible batch effects and audit identity', async () => { + await fixture(async ({ ctx, exec, state, signal }) => { + const outputs = ['{', 'null', 'true', '0', '"completed"', '[]', '[{"status":"completed"}]'] + for (const text of outputs) { + state.text = text + const value = await ptcCall(ctx, 'unused', 'batch', { action: 'run', operations: [] }, exec, signal) + assert.equal(value.error.code, 'RESULT_UNKNOWN', JSON.stringify(value)) + assert.equal(value.error.effectState, 'unknown'); assert.equal(value.error.retryable, false) + assert.match(value.error.nextAction, /never blindly rerun/); assert.match(value.detailsRef, /^j-/) + } + assert.equal(state.spawns, outputs.length) + state.text = '{"status":"error","changed":1,"error":{"code":"POLICY_DENIED","reason":"write-roots","effectState":"applied"}}' + const denied = await ptcCall(ctx, 'unused', 'batch', { action: 'run', operations: [] }, exec, signal) + assert.equal(denied.status, 'rejected'); assert.equal(denied.changed, 1) + assert.equal(denied.error.effectState, 'applied'); assert.equal(denied.detailsRef, undefined) + }) +}) report('worker-boundary') From 0799ea814e82db1c4df1d6ded68d60da77381071 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:11:17 +0800 Subject: [PATCH 065/118] fix(hiding): require exact native replacement ownership --- docs/CHANGELOG.md | 9 ++ docs/component-reference.md | 4 +- docs/configuration-reference.md | 4 +- ...04\344\273\266\345\217\202\350\200\203.md" | 4 +- ...15\347\275\256\345\217\202\350\200\203.md" | 4 +- .../hide-official/{index.js => index.ts} | 6 +- src/config-error.ts | 7 ++ src/config.js | 38 +----- ...ol-catalog.js => official-tool-catalog.ts} | 4 +- src/official-tool-config.ts | 30 +++++ ...tool-hiding.js => official-tool-hiding.ts} | 86 ++++++++------ test/lib/official-tool-hiding.mjs | 82 ++++++++++--- test/official-tool-hiding.test.mjs | 4 +- test/presentation-readiness.test.mjs | 112 ++++++++++++++++++ 14 files changed, 300 insertions(+), 94 deletions(-) rename src/components/hide-official/{index.js => index.ts} (67%) create mode 100644 src/config-error.ts rename src/{official-tool-catalog.js => official-tool-catalog.ts} (88%) create mode 100644 src/official-tool-config.ts rename src/{official-tool-hiding.js => official-tool-hiding.ts} (62%) create mode 100644 test/presentation-readiness.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9b7318ab..71b24fe5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,12 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Drain release-gate stdout and stderr concurrently so a full diagnostic pipe cannot deadlock verification; retain both streams and failing exit codes. +- 发布门禁并行读取 stdout 和 stderr,避免诊断管道写满后验证死锁;保留两路完整输出及失败退出码。 + +- Require exact OPS-owned Native definitions for selected official replacements, including same-schema ownership changes; preserve fail-closed provider requests while discovery completes startup readiness. Move the replacement boundary and config validator to strict TypeScript. +- 所选官方替代工具必须是 OPS 拥有的确切 Native 定义,schema 相同的所有权变化也会重新校验;发现组件可完成启动就绪检查,普通提供方请求仍严格拒绝未就绪状态。替代边界和配置校验器迁移为严格 TypeScript。 + ### Select official read/search replacements individually (#261) - Replace blanket hiding with `replaceTools: []`: explicitly select reviewed `read`, `glob`, or `grep` alternatives. Unselected tools remain visible without warning lists; only selected counterparts are required for scoped readiness. @@ -557,6 +563,9 @@ The capability is taken; the duplicate component is not. ## [Unreleased] +- Drain release-gate stdout and stderr concurrently so a full diagnostic pipe cannot deadlock verification; retain both streams and failing exit codes. +- 发布门禁并行读取 stdout 和 stderr,避免诊断管道写满后验证死锁;保留两路完整输出及失败退出码。 + ### Read-only sidebar navigation - Add focused all/running/anomaly filters and local retained-metadata pages. Preserve unfiltered session totals, continue long diagnostics, clamp pages on resize/live settlement and release key handlers on unmount. No tools, result-page fetches, history reads or persistent preferences are added. diff --git a/docs/component-reference.md b/docs/component-reference.md index d7ca6785..41f3f4ad 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -199,7 +199,9 @@ All OPS calls keep their own session-cwd base, permission checks and output cont `write`, `edit`, and `str_replace_editor` are deliberately unavailable as selections. OPS write/edit require an observed `expectedRevision` and CAS publication; host write and replace-all edit do not share that contract. A similarly named registration is not evidence of compatibility. Shells, `run_code`, delegation, jobs, media and third-party tools also remain visible without a warning list. -Only the selected OPS counterparts must be registered and visible. An unavailable selected counterpart blocks the affected scope with `READINESS_BLOCKED` and restores its official tools; healthy scopes retain their masks. Removing a selection, unloading, or disabling the component restores the corresponding official tool. Old `strict`/`scope`/`additionalOfficialTools` values are validated but ignored until explicit `replaceTools` selections are supplied. +Only the selected OPS counterparts must be registered and visible as exact OPS-owned Native definitions. A foreign registration with the same name or identical schema cannot satisfy readiness. Ownership changes are checked even when the schema bytes stay unchanged; Native shadows created by the OPS PTC adapter retain their tracked ownership. PTC data APIs alone cannot replace a Native tool. An unavailable selected counterpart blocks the affected scope with `READINESS_BLOCKED` and lifts that scope's replacement masks; healthy scopes and inherited authority restrictions retain their masks. Removing a selection, unloading, or disabling the component restores the corresponding official tool. Old `strict`/`scope`/`additionalOfficialTools` values are validated but ignored until explicit `replaceTools` selections are supplied. + +The resident `ops_read_many` is not the reviewed replacement for `read`. Before selecting `read`, configure discovery `alwaysLoaded: [inspect_local_file]` or ensure that single-file capability is already active in that Native scope. Apply the same readiness rule to `glob` and `grep`. Internal presentation inspection lets discovery publish these configured tools during startup; ordinary prompt assembly and dispatch remain blocked until they are ready. The component exports a real `StandardSchemaV1` validator for Cordis. It does not claim a generic schema creates the host UI: the pinned host requires a browser `plugins.row.config` contribution for a selector. See the [configuration reference](configuration-reference.md#per-tool-official-replacements-dsh-opshide-official). diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 5a49e734..a6e88c33 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -131,7 +131,9 @@ For example, replace only host read and grep while retaining host glob: replaceTools: [read, grep] ``` -Set `replaceTools: []` or disable the row to restore every official tool. A selected OPS counterpart must be registered and visible in each agent scope. If any is missing, that scope restores its masks and refuses prompt assembly and execution with `READINESS_BLOCKED`; healthy scopes remain usable. Restore the counterpart, remove that selection, or disable the row. Unrelated OPS tools are not required. +Set `replaceTools: []` or disable the row to restore every official tool. A selected OPS counterpart must be an exact OPS-owned Native definition registered and visible in each agent scope; a same-name foreign tool or a PTC-only data API does not qualify. If any is missing, that scope restores its masks and refuses prompt assembly and execution with `READINESS_BLOCKED`; healthy scopes remain usable. Restore the counterpart, remove that selection, or disable the row. Unrelated OPS tools are not required. + +With the slim Native defaults, configure discovery `alwaysLoaded: [inspect_local_file, grep]` before selecting the example above, or activate those Native capabilities first. `read` still maps to `inspect_local_file`; resident `read_many` does not satisfy that selection. Removing or shadowing an owned counterpart rechecks readiness immediately, even if its schema is byte-identical. **Migration from 0.3:** `strict`, `scope`, and `additionalOfficialTools` are accepted only for compatibility and validated with their old types/values. They grant no hiding authority, including `scope: extended` and aliases. An existing row containing only those keys becomes inert. Explicit `replaceTools` is the sole source of selections even if legacy keys remain. No saved profile is rewritten by this component. diff --git "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" index 2512fc8f..566eb9ba 100644 --- "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" +++ "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" @@ -199,7 +199,9 @@ OPS 调用始终保留自己的会话 cwd 路径基准、权限检查和输出 `write`、`edit`、`str_replace_editor` 故意不提供选择。OPS 写入和编辑要求观测到的 `expectedRevision` 并以 CAS 发布;宿主的直接写入和全量匹配编辑不具有相同契约。名称相似的注册项不能证明兼容。Shell、`run_code`、委派、任务、媒体和第三方工具也保持可见,不产生警告列表。 -只要求所选 OPS 替代工具已注册且可见。任一所选替代工具不可用时,仅受影响作用域以 `READINESS_BLOCKED` 拒绝运行,并恢复其官方工具;健康作用域保留隐藏。移除选择、卸载或禁用组件会恢复对应官方工具。旧 `strict`/`scope`/`additionalOfficialTools` 会校验但不生效,必须显式提供 `replaceTools` 才会隐藏。 +所选 OPS 替代工具必须已注册、可见,并且是 OPS 拥有的确切 Native 定义。同名或 schema 完全相同的外部注册不能满足就绪条件。即使 schema 字节不变,所有权变化也会重新检查;OPS PTC 适配器创建的 Native 映射保留受跟踪的所有权。仅有 PTC 数据 API 不能替换 Native 工具。任一所选替代工具不可用时,仅受影响作用域以 `READINESS_BLOCKED` 拒绝运行,并撤销该作用域自身的替代隐藏;健康作用域及继承的权限限制保持生效。移除选择、卸载或禁用组件会恢复对应官方工具。旧 `strict`/`scope`/`additionalOfficialTools` 会校验但不生效,必须显式提供 `replaceTools` 才会隐藏。 + +常驻的 `ops_read_many` 不是经评审的 `read` 替代项。选择 `read` 前,应配置发现组件 `alwaysLoaded: [inspect_local_file]`,或确保该单文件能力已在当前 Native 作用域激活。`glob` 和 `grep` 同样需要就绪。内部展示模式检查允许发现组件在启动时发布这些已配置工具;普通提示词组装和工具执行在就绪前仍会被拒绝。 组件导出真实的 `StandardSchemaV1` 校验器供 Cordis 使用,但不会宣称通用 schema 能自动生成界面:固定宿主版本要求浏览器通过 `plugins.row.config` 提供选择器。配置方法见[配置参考](配置参考.md)。 diff --git "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" index bf51d482..53467b87 100644 --- "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" +++ "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" @@ -124,7 +124,9 @@ config: replaceTools: [read, grep] ``` -设为 `replaceTools: []` 或禁用该行即可恢复全部官方工具。每个作用域中,所选 OPS 替代工具必须已注册且可见。若其中任一工具缺失,该作用域会撤销隐藏,并以 `READINESS_BLOCKED` 拒绝提示词组装和执行;健康作用域仍可使用。恢复替代工具、移除相应选择或禁用该行即可解除。无需注册其它无关的 OPS 工具。 +设为 `replaceTools: []` 或禁用该行即可恢复全部官方工具。每个作用域中,所选 OPS 替代工具必须是已注册且可见的确切 OPS Native 定义;同名外部工具或仅有 PTC 数据 API 均不符合条件。若其中任一工具缺失,该作用域会撤销隐藏,并以 `READINESS_BLOCKED` 拒绝提示词组装和执行;健康作用域仍可使用。恢复替代工具、移除相应选择或禁用该行即可解除。无需注册其它无关的 OPS 工具。 + +Native 默认只保留精简核心工具。选择上例前,应先为发现组件配置 `alwaysLoaded: [inspect_local_file, grep]`,或先激活这些 Native 能力。`read` 仍映射到 `inspect_local_file`,常驻 `read_many` 不能满足此选择。撤销或覆盖受拥有的替代定义会立即重新检查就绪状态,即使 schema 字节完全相同。 **从 0.3 迁移:** 仅为兼容旧配置,接受并按旧类型和取值校验 `strict`、`scope`、`additionalOfficialTools`;它们均不授予隐藏权限,包括 `scope: extended` 和别名列表。只有旧键的既有行会变为无操作。即使保留旧键,`replaceTools` 也是唯一选择来源。本组件不改写已保存的 profile。 diff --git a/src/components/hide-official/index.js b/src/components/hide-official/index.ts similarity index 67% rename from src/components/hide-official/index.js rename to src/components/hide-official/index.ts index fb418a8a..cfdc17fe 100644 --- a/src/components/hide-official/index.js +++ b/src/components/hide-official/index.ts @@ -5,11 +5,11 @@ export { apply } // Cordis consumes StandardSchemaV1 directly. No host value import is needed. export const Config = { '~standard': { - version: 1, + version: 1 as const, vendor: 'dsh-ops', - validate(value) { + validate(value: unknown) { try { return { value: resolveHidingConfig(value) } } - catch (error) { return { issues: [{ message: error.message }] } } + catch (error) { return { issues: [{ message: error instanceof Error ? error.message : String(error) }] } } }, }, } diff --git a/src/config-error.ts b/src/config-error.ts new file mode 100644 index 00000000..b6208ef9 --- /dev/null +++ b/src/config-error.ts @@ -0,0 +1,7 @@ +/** Preserve the public configuration error identity across typed validators. */ +export class ConfigError extends Error { + constructor(message: string) { + super(`dsh-ops config: ${message}`) + this.name = 'ConfigError' + } +} diff --git a/src/config.js b/src/config.js index 359c42af..206312d8 100644 --- a/src/config.js +++ b/src/config.js @@ -11,7 +11,6 @@ * @module dsh-ops/config */ -import { OFFICIAL_REPLACEMENTS } from './official-tool-catalog.js' /** * Keys only the removed FastCtx surface read. Existing rows still carry them @@ -30,16 +29,8 @@ export const SHELL_POLICIES = ['advise', 'deny-host-shell'] /** Host tool names the `deny-host-shell` policy refuses by default. */ export const DEFAULT_DENIED_HOST_TOOLS = ['pwsh', 'bash', 'pwsh_persistent'] -/** Error raised for a configuration value this plugin cannot honour. */ -export class ConfigError extends Error { - /** - * @param {string} message - what is wrong and what is accepted. - */ - constructor(message) { - super(`dsh-ops config: ${message}`) - this.name = 'ConfigError' - } -} +import { ConfigError } from './config-error.js' +export { ConfigError } from './config-error.js' /** * Reject a key the plugin does not implement instead of silently ignoring it. @@ -196,30 +187,7 @@ export function resolveConfig(rawConfig) { } } -/** Explicit reviewed replacements. Legacy blanket settings are validated but inert. */ -export function resolveHidingConfig(raw = {}) { - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new ConfigError('hiding config must be an object') - const keys = ['replaceTools', 'strict', 'scope', 'additionalOfficialTools'] - for (const key of Object.keys(raw)) if (!keys.includes(key)) throw new ConfigError(`unknown hiding key: ${key}`) - // Saved 0.3 rows must not regain blanket hiding after an update. Retain old - // validation for diagnostics, but only replaceTools grants new authority. - if (raw.strict !== undefined && raw.strict !== true) throw new ConfigError('strict is a legacy key and must be true when present') - if (raw.scope != null && !['takeover', 'extended', 'off'].includes(raw.scope)) { - throw new ConfigError('scope is a legacy key and must be takeover, extended or off') - } - const aliases = raw.additionalOfficialTools ?? [] - if (!Array.isArray(aliases) || aliases.some(n => typeof n !== 'string' || !/^[A-Za-z0-9_-]+$/.test(n) || n.startsWith('ops_'))) { - throw new ConfigError('additionalOfficialTools must be an array of official tool names (not ops_* replacements)') - } - const selected = raw.replaceTools === undefined ? [] : raw.replaceTools - if (!Array.isArray(selected)) throw new ConfigError('replaceTools must be an array of reviewed official tool names') - for (const tool of selected) { - if (typeof tool !== 'string' || !Object.hasOwn(OFFICIAL_REPLACEMENTS, tool)) { - throw new ConfigError(`replaceTools contains unsupported tool: ${JSON.stringify(tool)}; choose read, glob or grep`) - } - } - return { replaceTools: [...new Set(selected)] } -} +export { resolveHidingConfig } from './official-tool-config.js' /** Independent PTC row configuration uses the same central validation entry point. */ export { parsePtcConfig as resolvePtcConfig } from './ptc/config.js' diff --git a/src/official-tool-catalog.js b/src/official-tool-catalog.ts similarity index 88% rename from src/official-tool-catalog.js rename to src/official-tool-catalog.ts index 6e7230fe..067746d1 100644 --- a/src/official-tool-catalog.js +++ b/src/official-tool-catalog.ts @@ -15,7 +15,9 @@ export const OFFICIAL_REPLACEMENTS = Object.freeze({ grep: publicToolName('grep'), }) -export function platformSurface(platform = process.platform) { +export type OfficialReplacement = keyof typeof OFFICIAL_REPLACEMENTS + +export function platformSurface(platform: string = process.platform) { if (!['win32', 'linux', 'darwin'].includes(platform)) throw new Error(`Unsupported official-tool hiding platform: ${platform}`) return { retainedCommand: platform === 'win32' ? 'pwsh' : 'bash', shellDefaultEnabled: true } } diff --git a/src/official-tool-config.ts b/src/official-tool-config.ts new file mode 100644 index 00000000..d544fe42 --- /dev/null +++ b/src/official-tool-config.ts @@ -0,0 +1,30 @@ +/** Explicit reviewed replacements. Legacy blanket settings are validated but inert. */ +import { ConfigError } from './config-error.js' +import { OFFICIAL_REPLACEMENTS } from './official-tool-catalog.js' +import type { OfficialReplacement } from './official-tool-catalog.js' +export interface HidingConfig { replaceTools: OfficialReplacement[] } +export function resolveHidingConfig(raw: unknown = {}): HidingConfig { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new ConfigError('hiding config must be an object') + const input = raw as Record + const keys = ['replaceTools', 'strict', 'scope', 'additionalOfficialTools'] + for (const key of Object.keys(input)) if (!keys.includes(key)) throw new ConfigError(`unknown hiding key: ${key}`) + // Saved 0.3 rows must not regain blanket hiding after an update. + if (input.strict !== undefined && input.strict !== true) throw new ConfigError('strict is a legacy key and must be true when present') + if (input.scope != null && (typeof input.scope !== 'string' || !['takeover', 'extended', 'off'].includes(input.scope))) { + throw new ConfigError('scope is a legacy key and must be takeover, extended or off') + } + const aliases = input.additionalOfficialTools ?? [] + if (!Array.isArray(aliases) || aliases.some(n => typeof n !== 'string' || !/^[A-Za-z0-9_-]+$/.test(n) || n.startsWith('ops_'))) { + throw new ConfigError('additionalOfficialTools must be an array of official tool names (not ops_* replacements)') + } + const selected = input.replaceTools === undefined ? [] : input.replaceTools + if (!Array.isArray(selected)) throw new ConfigError('replaceTools must be an array of reviewed official tool names') + const replaceTools: OfficialReplacement[] = [] + for (const tool of selected) { + if (typeof tool !== 'string' || !Object.hasOwn(OFFICIAL_REPLACEMENTS, tool)) { + throw new ConfigError(`replaceTools contains unsupported tool: ${JSON.stringify(tool)}; choose read, glob or grep`) + } + replaceTools.push(tool as OfficialReplacement) + } + return { replaceTools: [...new Set(replaceTools)] } +} diff --git a/src/official-tool-hiding.js b/src/official-tool-hiding.ts similarity index 62% rename from src/official-tool-hiding.js rename to src/official-tool-hiding.ts index f74ef460..9a3d4be1 100644 --- a/src/official-tool-hiding.js +++ b/src/official-tool-hiding.ts @@ -2,31 +2,41 @@ * Only explicit reviewed selections are masked. Missing selected counterparts * block the affected scope and restore its official tools until readiness returns. */ +import type { Context } from '@deepseek-ai/cordis' +import type { ToolGuard } from '@deepseek-ai/dsh-tools' +import type { OfficialReplacement } from './official-tool-catalog.js' +import { nativeProviders } from './file/result-provider.js' +import { isPresentationProbe } from './presentation.js' import { resolveHidingConfig } from './config.js' export { resolveHidingConfig } from './config.js' import { OFFICIAL_REPLACEMENTS } from './official-tool-catalog.js' import { safeDispose } from './teardown.js' +interface Entry { context: Context; lifts: (() => void)[]; cleanupError?: string; error?: string; reported?: string } +interface Plan { deny: string[]; failure?: string } +const message = (error: unknown): string => error instanceof Error ? error.message : String(error) + export const name = 'dsh-ops-hide-official' -const blocked = detail => `READINESS_BLOCKED (EXECUTOR_UNAVAILABLE): dsh-ops hide-official cannot verify selected replacements: ${detail}. Restore the selected OPS tools, remove the selection from replaceTools, or disable the dsh-ops-hide-official row.` +const blocked = (detail: string): string => `READINESS_BLOCKED (EXECUTOR_UNAVAILABLE): dsh-ops hide-official cannot verify selected replacements: ${detail}. Restore the selected OPS tools, remove the selection from replaceTools, or disable the dsh-ops-hide-official row.` -export function apply(root, raw) { - const { replaceTools } = resolveHidingConfig(raw) +export function apply(root: Context, raw?: unknown): void { + const { replaceTools }: { replaceTools: OfficialReplacement[] } = resolveHidingConfig(raw) // Empty and legacy-only rows install no hooks and cannot block a session. if (!replaceTools.length) return - const selected = new Set(replaceTools) - const hideable = tool => selected.has(tool) + const selected = new Set(replaceTools) + const hideable = (tool: string): boolean => selected.has(tool) root.inject(['tools', 'systemPrompt'], ctx => { - const entries = new Map() - let stopped = false, mutating = false, lastSurface - const tools = ctx.get('tools') - const apiError = ['schemas', 'get', 'restrict', 'guard'].filter(api => typeof tools[api] !== 'function') - const fail = message => ctx.logger?.error?.(message) - const clear = entry => { + const entries = new Map() + let stopped = false, mutating = false + let lastSurface: string | undefined + const tools = ctx.get('tools')! + const apiError = (['schemas', 'get', 'restrict', 'guard'] as const).filter(api => typeof tools[api] !== 'function') + const fail = (message: string): void => ctx.logger?.error?.(message) + const clear = (entry: Entry): boolean => { const pending = entry.lifts.splice(0) const failed = [] for (const lift of pending) { - if (!safeDispose(lift, error => fail(`dsh-ops: restriction cleanup failed: ${error.message}`), 'official-tool restriction')) failed.push(lift) + if (!safeDispose(lift, error => fail(`dsh-ops: restriction cleanup failed: ${message(error)}`), 'official-tool restriction')) failed.push(lift) } entry.lifts.push(...failed) entry.cleanupError = failed.length > 0 ? 'host restriction cleanup failed; retrying before proceeding' : undefined @@ -34,12 +44,20 @@ export function apply(root, raw) { } // Readiness examines the unmasked scope. It never accepts ops_ // as an equivalence claim, nor uses run_code as a native replacement. - const prepare = (agent, entry) => { + const readiness = (agent: object): boolean[] => { + const owned = new Set(typeof tools.get === 'function' ? nativeProviders(ctx, agent).map(provider => provider.definition) : []) + return replaceTools.map(tool => { + const visible = typeof tools.get === 'function' ? tools.get(OFFICIAL_REPLACEMENTS[tool], agent) : undefined + return visible !== undefined && owned.has(visible) + }) + } + const prepare = (agent: object, entry: Entry): Plan => { const names = typeof tools.schemas === 'function' ? tools.schemas(agent).map(schema => schema.name) : [] const hidden = names.filter(hideable) - const missing = replaceTools.flatMap(tool => { + const ready = readiness(agent) + const missing = replaceTools.flatMap((tool, index) => { const replacement = OFFICIAL_REPLACEMENTS[tool] - return names.includes(replacement) && typeof tools.get === 'function' && tools.get(replacement, agent) + return names.includes(replacement) && ready[index] ? [] : [`${tool} -> ${replacement}`] }) const diagnostics = [ @@ -54,7 +72,8 @@ export function apply(root, raw) { // Compare public surfaces before touching masks. Other components also // reconcile tools/change; emitting a fresh restriction for an unchanged // surface would otherwise cause a publication/withdrawal feedback loop. - const snapshot = () => JSON.stringify([tools.schemas(), ...[...entries.keys()].map(agent => tools.schemas(agent))]) + // Schema bytes alone cannot detect a foreign tool shadowing an OPS definition. + const snapshot = (): string => JSON.stringify([tools.schemas(), ...[...entries.keys()].map(agent => [tools.schemas(agent), readiness(agent)])]) const reconcile = () => { if (stopped || mutating) return try { if (snapshot() === lastSurface) return } catch { /* reported by per-agent verification below */ } @@ -63,11 +82,11 @@ export function apply(root, raw) { // Clear all masks first: inherited restrictions must not conceal a // parent's missing capability while checking a child. for (const entry of entries.values()) clear(entry) - const plans = [] + const plans: [object, Entry, Plan][] = [] for (const [agent, entry] of entries) { - let plan + let plan: Plan try { plan = prepare(agent, entry) } - catch (error) { plan = { deny: [], failure: blocked(error.message) } } + catch (error) { plan = { deny: [], failure: blocked(message(error)) } } plans.push([agent, entry, plan]) } // Each scope owns its readiness and mask. A failed child is blocked at @@ -77,11 +96,11 @@ export function apply(root, raw) { try { // One restriction per complete plan; never partially hide when a // single required replacement is absent. - if (plan.deny.length) entry.lifts.push(agent.ctx.tools.restrict({ deny: plan.deny })) + if (plan.deny.length) entry.lifts.push(entry.context.tools.restrict({ deny: plan.deny })) const leaked = tools.schemas(agent).map(schema => schema.name).filter(hideable) if (leaked.length) throw new Error(`host scoped restriction cannot hide own-scope tools: ${leaked.join(', ')}`) entry.error = undefined - } catch (error) { clear(entry); entry.error = blocked(error.message) } + } catch (error) { clear(entry); entry.error = blocked(message(error)) } } for (const entry of entries.values()) { if (entry.error !== entry.reported) { @@ -92,7 +111,7 @@ export function apply(root, raw) { try { lastSurface = snapshot() } catch { lastSurface = undefined } } finally { mutating = false } } - const block = (agent, detail) => { + const block = (agent: object, detail: string): Error => { const message = blocked(detail) // Tool providers run before this waterfall, so keep the verified mask in // place for the next request's schema collection. Only this assembly @@ -102,12 +121,12 @@ export function apply(root, raw) { lastSurface = undefined return new Error(message) } - const attach = agent => { - if (!agent?.ctx || entries.has(agent)) return - entries.set(agent, { lifts: [], error: blocked('surface verification pending') }) + const attach = (agent: object): void => { + if (entries.has(agent) || !('ctx' in agent) || !agent.ctx || typeof agent.ctx !== 'object') return + entries.set(agent, { context: agent.ctx as Context, lifts: [], error: blocked('surface verification pending') }) reconcile() } - const refusal = exec => { + const refusal: ToolGuard = exec => { if (stopped) return blocked('component disposed') if (!exec?.agent?.ctx) return blocked('calling agent scope unavailable') attach(exec.agent) @@ -115,14 +134,15 @@ export function apply(root, raw) { return entries.get(exec.agent)?.error ?? (hideable(exec.name) ? blocked(`official tool ${exec.name} is selected for replacement`) : undefined) } - ctx.on('tools/pre-execute', (exec, next) => { + ctx.on('tools/pre-execute', async (exec, next) => { const reason = refusal(exec) - return reason ? { kind: 'deny', reason, info: { code: 'READINESS_BLOCKED' } } : next() + return reason ? { kind: 'deny', reason, info: { name: 'ReadinessError', code: 'READINESS_BLOCKED' } } : next() }) if (typeof tools.guard === 'function') ctx.effect(() => ctx.tools.guard(refusal), 'dsh-ops: replacement surface guard') ctx.on('system-prompt/assemble', async (assembly, context, next) => { - // A global diagnostic assembly is not an agent AI surface. - if (!context.scope) return next() + // Only this package's exact, short-lived inspection context may pass before + // discovery publishes a selected replacement. Provider requests stay guarded. + if (!context.scope || isPresentationProbe(context)) return next() attach(context.scope) reconcile() const entry = entries.get(context.scope) @@ -130,15 +150,15 @@ export function apply(root, raw) { if (error) throw new Error(error) let result try { result = await next() } - catch (error) { throw block(context.scope, `agent prompt assembly could not be verified: ${error.message}`) } + catch (error) { throw block(context.scope, `agent prompt assembly could not be verified: ${message(error)}`) } reconcile() - if (entry.error) throw new Error(entry.error) + if (entry?.error) throw new Error(entry.error) if (!Array.isArray(result?.tools)) throw block(context.scope, 'assembled tool schemas unavailable') const leaked = result.tools.map(schema => schema.name).filter(hideable) if (leaked.length) throw block(context.scope, `assembled surface still contains hidden official tools: ${leaked.join(', ')}`) return result }) - ctx.on('agent/created', ({ agent }) => attach(agent)) + ctx.on('agent/created', ({ agent }) => { attach(agent); return undefined }) ctx.on('agent/disposed', ({ agent }) => { const entry = entries.get(agent) if (!entry) return diff --git a/test/lib/official-tool-hiding.mjs b/test/lib/official-tool-hiding.mjs index 3c137ce6..b4da8acb 100644 --- a/test/lib/official-tool-hiding.mjs +++ b/test/lib/official-tool-hiding.mjs @@ -1,4 +1,5 @@ /** Real host mount checks, also run before the legacy mount suite's runtime gate. */ +import { registerNativeProvider } from '../../lib/file/result-provider.js' import { createScope } from '@deepseek-ai/dsh-scope' import { apply } from '../../lib/official-tool-hiding.js' import { platformSurface } from '../../lib/official-tool-catalog.js' @@ -51,7 +52,8 @@ function warningsFrom(ctx) { }) return warnings } -function replacements(ctx) { return FILE_TOOLS.map(name => ctx.tools.register(tool(publicToolName(name)))) } +const replacement = (ctx, name) => registerNativeProvider(ctx, [tool(publicToolName(name))], definition => ctx.tools.register(definition)) +const replacements = ctx => Promise.all(FILE_TOOLS.map(name => replacement(ctx, name))) const assemble = (ctx, agent) => ctx.get('systemPrompt').assemble({ scope: agent }) const execute = (ctx, agent, name) => ctx.get('tools').execute({ agent, name, arguments: {}, callId: 'hide-test', signal: new AbortController().signal }) @@ -59,7 +61,7 @@ export async function officialToolHidingMountChecks() { await test('selected replacements remove official schemas and dispatch, preserves unrelated scopes, and lifts on unload', async () => { const { ctx } = await bootHost() try { - replacements(ctx) + await replacements(ctx) const retained = platformSurface().retainedCommand for (const name of ['read', 'glob', 'grep', retained, 'foreign']) ctx.tools.register(tool(name)) const existing = await mint(ctx, 'existing-hide-agent') @@ -93,7 +95,7 @@ export async function officialToolHidingMountChecks() { await test('unselected stock tools stay visible and dispatchable without warning lists', async () => { const { ctx } = await bootHost() try { - replacements(ctx) + await replacements(ctx) for (const name of ['read', 'glob', 'grep', ...STOCK_UNSUPPORTED_48]) ctx.tools.register(tool(name)) const warnings = warningsFrom(ctx) await mount(ctx, { replaceTools: ['read'] }) @@ -112,7 +114,7 @@ export async function officialToolHidingMountChecks() { for (const [official, replacement] of [['read', 'inspect_local_file'], ['glob', 'glob'], ['grep', 'grep']]) { const { ctx } = await bootHost() try { - ctx.tools.register(tool(publicToolName(replacement))) + await registerNativeProvider(ctx, [tool(publicToolName(replacement))], definition => ctx.tools.register(definition)) for (const name of ['read', 'glob', 'grep', 'write', 'edit', 'foreign']) ctx.tools.register(tool(name)) await mount(ctx, { replaceTools: [official] }) const agent = await mint(ctx, 'one-selection-' + official) @@ -127,7 +129,7 @@ export async function officialToolHidingMountChecks() { await test('a missing selected counterpart restores every mask and fails closed', async () => { const { ctx } = await bootHost() try { - ctx.tools.register(tool(publicToolName('glob'))) + await replacement(ctx, 'glob') for (const name of ['read', 'glob', 'foreign', 'ops_read']) ctx.tools.register(tool(name)) await mount(ctx, { replaceTools: ['read', 'glob'] }) const agent = await mint(ctx, 'missing-selection') @@ -139,7 +141,7 @@ export async function officialToolHidingMountChecks() { await test('replacement withdrawal restores official tools but blocks the turn until readiness returns', async () => { const { ctx } = await bootHost() try { - const disposers = replacements(ctx) + const disposers = await replacements(ctx) ctx.tools.register(tool('read')) await mount(ctx) const agent = await mint(ctx, 'live-readiness') @@ -147,7 +149,7 @@ export async function officialToolHidingMountChecks() { disposers[0]() assert.ok(scopedToolNames(ctx, agent).includes('read')) await assert.rejects(() => assemble(ctx, agent), /missing OPS surface:.*ops_inspect_local_file/) - ctx.tools.register(tool(publicToolName(FILE_TOOLS[0]))) + await replacement(ctx, FILE_TOOLS[0]) await waitFor(() => !scopedToolNames(ctx, agent).includes('read')) await assemble(ctx, agent) // Unselected write stays visible without affecting selected read readiness. @@ -162,7 +164,7 @@ export async function officialToolHidingMountChecks() { await test('own-scope official registration cannot be silently hidden by an inherited restriction', async () => { const { ctx } = await bootHost() try { - replacements(ctx) + await replacements(ctx) await mount(ctx) const agent = await mint(ctx, 'own-official') agent.ctx.tools.register(tool('read')) @@ -174,7 +176,7 @@ export async function officialToolHidingMountChecks() { await test('the unselected PTC transport stays visible without warnings', async () => { const { ctx } = await bootHost() try { - replacements(ctx) + await replacements(ctx) ctx.provide('ptcRuntime', { language: 'typescript' }) const warnings = warningsFrom(ctx) await mount(ctx) @@ -208,7 +210,7 @@ export async function officialToolHidingMountChecks() { await test('a root that loses its OPS surface does not stop a healthy root', async () => { const { ctx } = await bootHost() try { - replacements(ctx); ctx.tools.register(tool('read')); ctx.tools.register(tool('foreign')) + await replacements(ctx); ctx.tools.register(tool('read')); ctx.tools.register(tool('foreign')) await mount(ctx) const a = await mint(ctx, 'independent-a'), b = await mint(ctx, 'independent-b') await assemble(ctx, a); await assemble(ctx, b) @@ -227,7 +229,7 @@ export async function officialToolHidingMountChecks() { await test('a failed child is blocked without stopping its healthy parent or unrelated root', async () => { const { ctx } = await bootHost() try { - replacements(ctx); ctx.tools.register(tool('read')); ctx.tools.register(tool('foreign')) + await replacements(ctx); ctx.tools.register(tool('read')); ctx.tools.register(tool('foreign')) const hiding = await mount(ctx) const parent = await mint(ctx, 'independent-parent') const other = await mint(ctx, 'independent-root') @@ -250,7 +252,7 @@ export async function officialToolHidingMountChecks() { await test('inherited masks cannot conceal a child missing an OPS replacement', async () => { const { ctx } = await bootHost() try { - replacements(ctx); ctx.tools.register(tool('read')); ctx.tools.register(tool('foreign')) + await replacements(ctx); ctx.tools.register(tool('read')); ctx.tools.register(tool('foreign')) await mount(ctx) const parent = await mint(ctx, 'ready-parent') const child = await mint(ctx, 'filtered-child', parent) @@ -277,6 +279,54 @@ export async function officialToolHidingMountChecks() { assert.equal(decision.info.code, 'READINESS_BLOCKED') } finally { await shutdown(ctx) } }) + await test('foreign same-name tools and owned read_many cannot satisfy selected single-file read', async () => { + const { ctx } = await bootHost() + try { + const removeForeign = ctx.tools.register(tool(publicToolName('inspect_local_file'))) + await replacement(ctx, 'read_many') + for (const name of ['read', 'foreign']) ctx.tools.register(tool(name)) + await mount(ctx, { replaceTools: ['read'] }) + const agent = await mint(ctx, 'foreign-read-counterpart') + await assert.rejects(() => assemble(ctx, agent), /missing OPS surface: read -> ops_inspect_local_file/) + assert.ok(scopedToolNames(ctx, agent).includes('read')) + assert.equal((await execute(ctx, agent, 'foreign')).isError, true) + removeForeign() + const removeOwned = await replacement(ctx, 'inspect_local_file') + await assemble(ctx, agent) + assert.equal(scopedToolNames(ctx, agent).includes('read'), false) + removeOwned() + ctx.tools.register(tool(publicToolName('inspect_local_file'))) + await assert.rejects(() => assemble(ctx, agent), /READINESS_BLOCKED/) + assert.ok(scopedToolNames(ctx, agent).includes('read')) + } finally { await shutdown(ctx) } + }) + await test('a byte-identical foreign child shadow invalidates owned readiness without harming siblings', async () => { + const { ctx } = await bootHost() + try { + await replacement(ctx, 'inspect_local_file') + for (const name of ['read', 'foreign']) ctx.tools.register(tool(name)) + await mount(ctx, { replaceTools: ['read'] }) + const parent = await mint(ctx, 'identity-parent') + const child = await mint(ctx, 'identity-child', parent) + const sibling = await mint(ctx, 'identity-sibling', parent) + const original = ctx.get('tools').get(publicToolName('inspect_local_file'), child) + const before = JSON.stringify(ctx.get('tools').schemas(child)) + const removeShadow = child.ctx.tools.register({ ...original }) + assert.equal(JSON.stringify(ctx.get('tools').schemas(child)), before, 'schema bytes stay identical despite foreign ownership') + assert.notEqual(ctx.get('tools').get(original.name, child), original) + await assert.rejects(() => assemble(ctx, child), /READINESS_BLOCKED.*ops_inspect_local_file/) + assert.equal((await execute(ctx, child, 'foreign')).isError, true) + for (const healthy of [parent, sibling]) { + await assemble(ctx, healthy) + assert.equal((await execute(ctx, healthy, 'foreign')).isError, false) + assert.equal(scopedToolNames(ctx, healthy).includes('read'), false) + } + removeShadow() + await assemble(ctx, child) + assert.equal(ctx.get('tools').get(original.name, child), original) + assert.equal((await execute(ctx, child, 'foreign')).isError, false) + } finally { await shutdown(ctx) } + }) await test('schema-only counterpart claims do not satisfy selected readiness', async () => { const { ctx } = await bootHost({ withTools: false }) try { @@ -295,7 +345,7 @@ export async function officialToolHidingMountChecks() { await test('assembled injection of a hidden tool is rejected even when registry masking succeeded', async () => { const { ctx } = await bootHost() try { - replacements(ctx); ctx.tools.register(tool('read')) + await replacements(ctx); ctx.tools.register(tool('read')) await mount(ctx) const agent = await mint(ctx, 'assembly-verification') ctx.on('system-prompt/assemble', async (assembly, _context, next) => { @@ -311,7 +361,7 @@ export async function officialToolHidingMountChecks() { await test('a fail-once prompt provider blocks only that assembly and recovers on the next request', async () => { const { ctx } = await bootHost() try { - replacements(ctx); ctx.tools.register(tool('read')); ctx.tools.register(tool('foreign')) + await replacements(ctx); ctx.tools.register(tool('read')); ctx.tools.register(tool('foreign')) await mount(ctx) const a = await mint(ctx, 'transient-a'), b = await mint(ctx, 'transient-b') await assemble(ctx, a); await assemble(ctx, b) @@ -333,7 +383,7 @@ export async function officialToolHidingMountChecks() { await test('agent disposal lifts restrictions and remount does not retain old masks', async () => { const { ctx } = await bootHost() try { - replacements(ctx); ctx.tools.register(tool('read')) + await replacements(ctx); ctx.tools.register(tool('read')) const first = await mount(ctx) const agent = await mint(ctx, 'disposal') assert.equal(scopedToolNames(ctx, agent).includes('read'), false) @@ -395,7 +445,7 @@ export async function officialToolHidingMountChecks() { await test('changing selection through remount restores deselected tools', async () => { const { ctx } = await bootHost() try { - replacements(ctx) + await replacements(ctx) for (const name of ['read', 'glob', 'grep']) ctx.tools.register(tool(name)) const first = await mount(ctx, { replaceTools: ['read'] }) const agent = await mint(ctx, 'changed-selection') diff --git a/test/official-tool-hiding.test.mjs b/test/official-tool-hiding.test.mjs index dd5f8bc6..1e1d3824 100644 --- a/test/official-tool-hiding.test.mjs +++ b/test/official-tool-hiding.test.mjs @@ -2,7 +2,7 @@ import fs from 'node:fs' import path from 'node:path' import { parse } from 'yaml' import { Context } from '@deepseek-ai/cordis' -import { resolveHidingConfig } from '../lib/config.js' +import { ConfigError, resolveHidingConfig } from '../lib/config.js' import { OFFICIAL_REPLACEMENTS, platformSurface } from '../lib/official-tool-catalog.js' import { Config } from '../lib/components/hide-official/index.js' import { assert, PACKAGE_ROOT, report, test } from './lib/harness.mjs' @@ -23,7 +23,7 @@ await test('invalid configuration names its key or unsupported selection', () => for (const tool of ['write', 'edit', 'str_replace_editor', 'bash', 'ops_read', 'run_code', 'toString', '', null, {}]) { assert.throws(() => resolveHidingConfig({ replaceTools: [tool] }), /replaceTools contains unsupported tool/) } - assert.throws(() => resolveHidingConfig({ replacementTools: ['read'] }), /unknown hiding key: replacementTools/) + assert.throws(() => resolveHidingConfig({ replacementTools: ['read'] }), error => error instanceof ConfigError && /unknown hiding key: replacementTools/.test(error.message)) }) await test('legacy blanket settings are validated but never authorize replacements', () => { for (const scope of ['takeover', 'extended', 'off', undefined, null]) { diff --git a/test/presentation-readiness.test.mjs b/test/presentation-readiness.test.mjs new file mode 100644 index 00000000..13c4b263 --- /dev/null +++ b/test/presentation-readiness.test.mjs @@ -0,0 +1,112 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createScope } from '@deepseek-ai/dsh-scope' +import Sandbox from '@deepseek-ai/dsh-sandbox-local' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import * as Ptc from '../lib/ptc/index.js' +import { registerNativeProvider, nativeProviders } from '../lib/file/result-provider.js' +import * as Discovery from '../lib/components/discovery/index.js' +import * as Hiding from '../lib/components/hide-official/index.js' +import { isNativePresentation, isPresentationProbe } from '../lib/presentation.js' +import { publicToolName } from '../lib/policy.js' +import { bootHost, shutdown, scopedToolNames, registryHasOwnRegister } from './lib/host.mjs' +import { assert, test, report, withTempDir, waitFor } from './lib/harness.mjs' +const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done }); return { promise, resolve } } +const tool = name => ({ name, description: name, parameters: { type: 'object' }, output: { schema: { type: 'object' }, render: () => [] }, execute: async () => ({}) }) +let sequence = 0 +async function agentFor(ctx, cwd, parent, mode) { + const id = 'readiness-' + ++sequence + const agent = { id, session: { id, mode: 'danger-full-access', header: { cwd } } } + await (parent?.ctx ?? ctx).plugin({ inject: ['tools'], apply(inner) { agent.ctx = createScope(inner, agent, parent ? { parent } : undefined).ctx } }) + if (mode) await agent.ctx.plugin({ inject: ['tools'], apply(inner) { inner.tools.presentAs(mode) } }) + await ctx.serial('agent/created', { agent }) + return agent +} +for (const official of ['read', 'glob']) for (const hidingFirst of [true, false]) await test(`selected ${official} replacement becomes ready on first attach (hiding first: ${hidingFirst})`, () => withTempDir('presentation-ready', async cwd => { + const raw = official === 'read' ? 'inspect_local_file' : 'glob' + const retained = official === 'read' ? 'glob' : 'read' + const { ctx } = await bootHost() + try { + await ctx.plugin(Subprocess); await ctx.plugin(Sandbox, {}) + await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: () => ({ mode: 'danger-full-access', workspaceRoot: cwd }) })) + ctx.tools.register(tool('glob')); ctx.tools.register(tool('read')) + const mountHiding = () => ctx.plugin(Hiding, { replaceTools: [official] }) + const mountDiscovery = () => ctx.plugin(Discovery, { required: true, enabled: false, allowedTools: [raw], alwaysLoaded: [raw] }) + let discovery + if (hidingFirst) { await mountHiding(); discovery = await mountDiscovery() } + else { discovery = await mountDiscovery(); await mountHiding() } + const agent = await agentFor(ctx, cwd) + const assemble = () => ctx.systemPrompt.assemble({ scope: agent }) + assert.deepEqual((await assemble()).tools.map(tool => tool.name).sort(), [publicToolName(raw), retained].sort()) + assert.equal(registryHasOwnRegister(ctx), false) + fs.writeFileSync(path.join(cwd, 'ready.txt'), 'ready') + const request = name => ctx.tools.execute({ name, arguments: raw === 'glob' ? { pattern: ['*.txt'] } : { path: 'ready.txt' }, agent, callId: 'readiness', signal: new AbortController().signal }) + const executed = await request(publicToolName(raw)) + assert.equal(executed.isError, false); assert.equal(executed.value.status, 'completed', JSON.stringify(executed)) + assert.equal((await request(official)).isError, true, 'selected official dispatch remains blocked') + ctx.provide('ptcRuntime', { language: 'typescript' }) + const lift = agent.ctx.tools.presentAs('ptc') + await assert.rejects(assemble(), /READINESS_BLOCKED/) + await waitFor(() => !scopedToolNames(ctx, agent).includes(publicToolName(raw))) + assert.equal((await request(publicToolName(raw))).isError, true) + lift() + await waitFor(() => scopedToolNames(ctx, agent).includes(publicToolName(raw))) + assert.ok((await assemble()).tools.some(tool => tool.name === publicToolName(raw))) + await discovery.dispose() + await assert.rejects(assemble(), /READINESS_BLOCKED/) + assert.equal((await request(retained)).isError, true, 'missing selected counterpart blocks all execution in that scope') + assert.ok(scopedToolNames(ctx, agent).includes(official), 'missing counterpart restores official visibility') + } finally { await shutdown(ctx) } +})) + +await test('an internal presentation probe never exempts a concurrent provider assembly or dispatch', () => withTempDir('presentation-isolation', async cwd => { + const { ctx } = await bootHost() + try { + ctx.tools.register(tool('glob')) + await ctx.plugin(Hiding, { replaceTools: ['glob'] }) + const agent = await agentFor(ctx, cwd) + const entered = deferred(), release = deferred() + let probedContext + ctx.on('system-prompt/assemble', async (_assembly, context, next) => { + if (isPresentationProbe(context)) { probedContext = context; entered.resolve(); await release.promise } + return next() + }) + const probing = isNativePresentation(ctx.systemPrompt, agent) + await entered.promise + await assert.rejects(ctx.systemPrompt.assemble({ scope: agent }), /READINESS_BLOCKED/) + const refused = await ctx.tools.execute({ name: 'glob', arguments: {}, agent, callId: 'ordinary', signal: new AbortController().signal }) + assert.equal(refused.isError, true) + release.resolve() + assert.equal(await probing, true) + assert.equal(isPresentationProbe(probedContext), false, 'inspection identity is retired after completion') + await assert.rejects(ctx.systemPrompt.assemble(probedContext), /READINESS_BLOCKED/, 'reusing a retired context is ordinary assembly') + } finally { await shutdown(ctx) } +})) +await test('real PTC Native shadows retain exact OPS ownership while PTC definitions are not Native replacements', () => withTempDir('presentation-shadow', async cwd => { + const { ctx } = await bootHost() + try { + await ctx.plugin(Subprocess); await ctx.plugin(Sandbox, {}) + ctx.provide('sandboxPolicy', { resolve: () => ({ mode: 'danger-full-access', workspaceRoot: cwd }) }) + ctx.provide('ptcRuntime', { language: 'typescript' }) + const original = tool(publicToolName('inspect_local_file')) + const removeOwned = await registerNativeProvider(ctx, [original], definition => ctx.tools.register(definition)) + ctx.tools.register(tool('read')) + await ctx.plugin(Ptc, { required: true }) + const parent = await agentFor(ctx, cwd, undefined, 'ptc') + const child = await agentFor(ctx, cwd, parent, 'native') + await waitFor(() => { + const visible = ctx.tools.get(original.name, child) + return visible !== original && nativeProviders(ctx, child).some(provider => provider.definition === visible) + }, { what: 'tracked Native child shadow' }) + ctx.provide('agents', { list: () => [parent, child] }) + await ctx.plugin(Hiding, { replaceTools: ['read'] }) + const assembled = await ctx.systemPrompt.assemble({ scope: child }) + assert.equal(assembled.tools.some(tool => tool.name === 'read'), false) + const value = await ctx.tools.execute({ name: original.name, arguments: {}, agent: child, callId: 'owned-shadow', signal: new AbortController().signal }) + assert.equal(value.isError, false, JSON.stringify(value)) + await assert.rejects(ctx.systemPrompt.assemble({ scope: parent }), /READINESS_BLOCKED/) + removeOwned() + await assert.rejects(ctx.systemPrompt.assemble({ scope: child }), /READINESS_BLOCKED/) + } finally { await shutdown(ctx) } +})) +report('presentation-readiness') From 76c442e2055b3dd8fa879b17fe0d501ba676fa01 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:10:26 +0800 Subject: [PATCH 066/118] fix(gate): drain child output streams concurrently --- scripts/lib/gate-output.mjs | 12 ++++++++++++ scripts/verify-gate.mjs | 4 ++-- test/gate-output.test.mjs | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 scripts/lib/gate-output.mjs create mode 100644 test/gate-output.test.mjs diff --git a/scripts/lib/gate-output.mjs b/scripts/lib/gate-output.mjs new file mode 100644 index 00000000..7b946a03 --- /dev/null +++ b/scripts/lib/gate-output.mjs @@ -0,0 +1,12 @@ +/** Drain both pipes while a child is running; reading them in sequence can deadlock. */ +export async function collectProcessOutput(child, { stdout = process.stdout, stderr = process.stderr } = {}) { + let output = '' + const drain = async (stream, destination) => { + for await (const chunk of stream) { + output += chunk + destination.write(chunk) + } + } + await Promise.all([drain(child.stdout, stdout), drain(child.stderr, stderr)]) + return output +} diff --git a/scripts/verify-gate.mjs b/scripts/verify-gate.mjs index dce31da7..9b7def28 100644 --- a/scripts/verify-gate.mjs +++ b/scripts/verify-gate.mjs @@ -42,6 +42,7 @@ import { tmpdir } from 'node:os' import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' +import { collectProcessOutput } from './lib/gate-output.mjs' const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const argv = process.argv.slice(2) @@ -116,8 +117,7 @@ async function run(title, command, args, { env = {}, optional = false, cwd = PAC }) let output = '' try { - for await (const chunk of child.stdout) { output += chunk; process.stdout.write(chunk) } - for await (const chunk of child.stderr) { output += chunk; process.stderr.write(chunk) } + output = await collectProcessOutput(child) } catch (error) { spawnError ??= error } diff --git a/test/gate-output.test.mjs b/test/gate-output.test.mjs new file mode 100644 index 00000000..e9816c71 --- /dev/null +++ b/test/gate-output.test.mjs @@ -0,0 +1,36 @@ +import { spawn } from 'node:child_process' +import { assert, test, report } from './lib/harness.mjs' +import { collectProcessOutput } from '../scripts/lib/gate-output.mjs' + +await test('gate drains saturated stderr and stdout concurrently and preserves a failing exit', async () => { + const size = 4 * 1024 * 1024 + const child = spawn(process.execPath, ['-e', ` + process.stderr.write('e'.repeat(${size})); + process.stdout.write('o'.repeat(${size})); + process.exitCode = 7; + `], { stdio: ['ignore', 'pipe', 'pipe'] }) + let timedOut = false + const timer = setTimeout(() => { timedOut = true; child.kill() }, 10000) + let stdout = '', stderr = '' + const finished = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', resolve) + }) + try { + const [output, code] = await Promise.all([ + collectProcessOutput(child, { + stdout: { write(chunk) { stdout += chunk } }, + stderr: { write(chunk) { stderr += chunk } }, + }), + finished, + ]) + assert.equal(timedOut, false, 'the stderr pipe must be drained before stdout closes') + assert.equal(code, 7, 'collecting output must not turn a failing step into success') + assert.equal(stdout, 'o'.repeat(size)) + assert.equal(stderr, 'e'.repeat(size)) + assert.equal(output.length, size * 2) + assert.equal(output.replaceAll('e', '').length, size) + assert.equal(output.replaceAll('o', '').length, size) + } finally { clearTimeout(timer); if (child.exitCode === null) child.kill() } +}) +report('gate-output') From e20da4dd75a934959ad34c198124253b62eb6987 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:12:51 +0800 Subject: [PATCH 067/118] refactor(build): type Cargo discovery and execution --- docs/CHANGELOG.md | 6 ++++ docs/native-kernel.md | 6 ++++ ...37\347\224\237\345\206\205\346\240\270.md" | 4 +++ src/{rust.js => rust.ts} | 30 ++++++++++++------- test/types/worker-runtime.ts | 16 ++++++++++ 5 files changed, 52 insertions(+), 10 deletions(-) rename src/{rust.js => rust.ts} (84%) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 66c1203b..86d4c66e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,12 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Type Rust toolchain discovery and source builds (#261, 16/16) + +- Compile Cargo discovery and explicit locked build helpers under strict TypeScript with checked + toolchain inputs, output callbacks and numeric completion codes; retain the shipped CLI paths. +- Rust 工具链解析与显式锁定构建改为严格类型,检查输入、文本回调及退出码,发布 CLI 路径不变。 + ### Type the confined PTC worker boundary (#261, 16/16) - Compile PTC policy, confinement, runner diagnostics and receipts under strict TypeScript; retain diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 3be8b9c5..c5812fe2 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -317,3 +317,9 @@ runner-failure contracts. Non-object argument failures precede batch admission; after a spawn retain unknown effects and the generated audit identity. Only recognized runner-failure evidence may claim the wrapped worker never ran. Permission rechecks and per-call confined argv remain mandatory; this migration adds no unconfined fallback or cache/business authority expansion. + +Source-build helpers are strict TypeScript as well. Cargo discovery returns an executable, provenance +label, version and environment; building requires that resolved toolchain and a crate directory, +streams text diagnostics, and returns a numeric exit code with collected output. The CLI still runs +an explicit locked release build and stages its existing provenance receipt; no dependency, toolchain +installation, runtime path or automatic-build behavior changes in this typing slice. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index e34de498..d47e7656 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -251,3 +251,7 @@ Native worker 桥也使用严格 TypeScript,采用宿主权限、文件系统 PTC 桥复用类型化输入/输出检查,并采用宿主限制执行与 runner 失败契约。非对象参数在批任务启动前 拒绝;启动后的非法 JSON 回执保留未知副作用与生成的审计标识。只有已识别的 runner 失败证据才可 声明 worker 未运行;每次权限复查与限制 argv 保持必需,不增加无限制回退或缓存/业务权限。 + +源码构建辅助模块也采用严格 TypeScript。Cargo 解析返回可执行文件、来源、版本与环境;构建必须 +提供已解析工具链及 crate 目录,流式输出文本诊断并返回数字退出码和收集输出。CLI 仍显式执行锁定 +依赖的 release 构建并暂存原有来源回执;此类型迁移不改变依赖、安装、发布路径或自动构建行为。 diff --git a/src/rust.js b/src/rust.ts similarity index 84% rename from src/rust.js rename to src/rust.ts index 51c9aa2e..a139e80f 100644 --- a/src/rust.js +++ b/src/rust.ts @@ -14,16 +14,27 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' +export interface CargoDiscoveryOptions { env?: NodeJS.ProcessEnv; platform?: string } +export interface CargoToolchain { file: string; source: string; version: string; env: NodeJS.ProcessEnv } +export interface CargoBuildOptions { + cargo: Pick + sourceDir: string + extraArgs?: readonly string[] + onLine?: (line: string) => void +} +export interface CargoBuildResult { code: number; output: string } + /** Environment variable that names the `cargo` executable to use. */ export const CARGO_ENV = 'DSH_OPS_CARGO' /** Error raised when no usable Rust toolchain is found. */ export class CargoNotFoundError extends Error { + readonly tried: string[] /** * @param {string} message - the failure summary. * @param {string[]} tried - every candidate considered. */ - constructor(message, tried) { + constructor(message: string, tried: string[]) { super(message) this.name = 'CargoNotFoundError' this.tried = tried @@ -39,7 +50,7 @@ export class CargoNotFoundError extends Error { * @param {NodeJS.ProcessEnv} [options.env] - environment. * @returns {{status: number|null, stdout: string, stderr: string}} the outcome. */ -function run(command, args, { cwd, env }) { +function run(command: string, args: readonly string[], { cwd, env }: { cwd?: string; env?: NodeJS.ProcessEnv }) { const result = spawnSync(command, args, { cwd, env, encoding: 'utf8', windowsHide: true }) return { status: result.status, @@ -55,10 +66,9 @@ function run(command, args, { cwd, env }) { * @param {string} [options.platform] - `process.platform`. * @returns {{file: string, source: string}[]} the candidates. */ -function cargoCandidates({ env = process.env, platform = process.platform } = {}) { +function cargoCandidates({ env = process.env, platform = process.platform }: CargoDiscoveryOptions = {}): { file: string; source: string }[] { const name = platform === 'win32' ? 'cargo.exe' : 'cargo' - /** @type {{file: string, source: string}[]} */ - const candidates = [] + const candidates: { file: string; source: string }[] = [] if (env[CARGO_ENV] !== undefined && env[CARGO_ENV].trim() !== '') { candidates.push({ file: path.resolve(env[CARGO_ENV]), source: CARGO_ENV }) } @@ -88,9 +98,8 @@ function cargoCandidates({ env = process.env, platform = process.platform } = {} * @returns {{file: string, source: string, version: string, env: NodeJS.ProcessEnv}} the toolchain. * @throws {CargoNotFoundError} when no usable `cargo` is found. */ -export function findCargo({ env = process.env, platform = process.platform } = {}) { - /** @type {string[]} */ - const tried = [] +export function findCargo({ env = process.env, platform = process.platform }: CargoDiscoveryOptions = {}): CargoToolchain { + const tried: string[] = [] for (const candidate of cargoCandidates({ env, platform })) { if (candidate.file !== 'cargo' && candidate.file !== 'cargo.exe' && !fs.existsSync(candidate.file)) { tried.push(`${candidate.source}: ${candidate.file} (missing)`) @@ -122,7 +131,8 @@ export function findCargo({ env = process.env, platform = process.platform } = { * @param {(line: string) => void} [options.onLine] - receives each output line. * @returns {Promise<{code: number, output: string}>} the exit code and everything cargo wrote. */ -export function cargoBuild({ cargo, sourceDir, extraArgs = [], onLine } = {}) { +export function cargoBuild(options: CargoBuildOptions): Promise { + const { cargo, sourceDir, extraArgs = [], onLine } = options ?? {} if (typeof sourceDir !== 'string' || sourceDir === '') throw new Error('cargoBuild requires the crate sourceDir') const args = ['build', '--release', '--locked', ...extraArgs] return new Promise((resolve, reject) => { @@ -133,7 +143,7 @@ export function cargoBuild({ cargo, sourceDir, extraArgs = [], onLine } = {}) { stdio: ['ignore', 'pipe', 'pipe'], }) let output = '' - const forward = (chunk) => { + const forward = (chunk: Buffer) => { const text = chunk.toString('utf8') output += text if (onLine !== undefined) for (const line of text.split('\n')) if (line !== '') onLine(line) diff --git a/test/types/worker-runtime.ts b/test/types/worker-runtime.ts index b8a38690..5633e636 100644 --- a/test/types/worker-runtime.ts +++ b/test/types/worker-runtime.ts @@ -46,3 +46,19 @@ void ptcValue.status void ptcCall(ctx, '/worker', 'batch', {}, exec, {}) // @ts-expect-error PTC effects follow the same kernel vocabulary as Native effects. void ptcFailure('RESULT_UNKNOWN', 'lost output', 'success') + +import { cargoBuild, findCargo, CargoNotFoundError } from '../../src/rust.js' +const cargo = findCargo({ platform: 'linux', env: { DSH_OPS_CARGO: '/cargo' } }) +const built = await cargoBuild({ cargo, sourceDir: '/crate', extraArgs: ['--offline'], onLine: line => { const text: string = line; void text } }) +const exitCode: number = built.code +const output: string = built.output +const tried: string[] = new CargoNotFoundError('unavailable', ['/cargo']).tried +// @ts-expect-error Rust builds require a crate source directory. +void cargoBuild({ cargo }) +// @ts-expect-error Rust builds require an explicitly resolved toolchain. +void cargoBuild({ sourceDir: '/crate' }) +// @ts-expect-error Output handlers receive text rather than byte counts. +void cargoBuild({ cargo, sourceDir: '/crate', onLine: (line: number) => { void line } }) +// @ts-expect-error Environment overrides are process environment strings. +void findCargo({ env: { DSH_OPS_CARGO: false } }) +void [exitCode, output, tried] From 4f137fb80c57b18fae0747014171c09106736f8a Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:15:15 +0800 Subject: [PATCH 068/118] refactor(shell): type tool publication and session adapters --- src/{session-shells.js => session-shells.ts} | 49 ++++++++++++-------- src/shells/{publish.js => publish.ts} | 10 ++-- src/shells/{tool.js => tool.ts} | 26 ++++++----- src/shells/types.ts | 4 +- 4 files changed, 53 insertions(+), 36 deletions(-) rename src/{session-shells.js => session-shells.ts} (82%) rename src/shells/{publish.js => publish.ts} (90%) rename src/shells/{tool.js => tool.ts} (93%) diff --git a/src/session-shells.js b/src/session-shells.ts similarity index 82% rename from src/session-shells.js rename to src/session-shells.ts index 70b72f96..e8103090 100644 --- a/src/session-shells.js +++ b/src/session-shells.ts @@ -1,5 +1,11 @@ /** Plugin-owned bash layers; permission changes never mutate the shared registry. */ import path from 'node:path' +import type { Context, Fiber } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ResolvedConfig } from './config.js' +import type { ShellResolution } from './shells/paths.js' +import type { HostShellDefinition, ShellResult } from './shells/types.js' +import { errorMessage } from './shells/types.js' import { BASH_TOOL } from './policy.js' import { bashToolDefinition, DEFAULT_BASH_TIMEOUT_MS } from './shells.js' import { BashArgumentError } from './shells/argument-error.js' @@ -19,7 +25,7 @@ import { startSessionBash } from './shells/session-execution.js' * @param {unknown} cwd - the session workspace. * @returns {boolean} whether Windows path rules apply. */ -function windowsFlavoured(cwd) { +function windowsFlavoured(cwd: unknown) { return typeof cwd === 'string' && (/^[A-Za-z]:[\\/]/.test(cwd) || (cwd[0] === '\\' && cwd[1] === '\\')) } @@ -43,7 +49,7 @@ function windowsFlavoured(cwd) { * @throws {BashArgumentError} when a relative path has no absolute session workspace to anchor at, * or names a drive other than the session's. */ -export function sessionWorkdir(args, session) { +export function sessionWorkdir(args: { workdir?: unknown } | null | undefined, session: { header?: { cwd?: string } } | undefined) { const raw = args?.workdir const explicit = typeof raw === 'string' && raw.trim() === '' ? undefined : raw const cwd = session?.header?.cwd @@ -86,9 +92,9 @@ export function sessionWorkdir(args, session) { * @param {unknown} error - the failure the executor raised. * @returns {object} the settled value, still to be stamped with `status`. */ -export function bashRefusalValue(error) { +export function bashRefusalValue(error: unknown): ShellResult { return { - error: String(error?.message ?? error), + error: errorMessage(error), timedOut: false, timeoutMs: DEFAULT_BASH_TIMEOUT_MS, stdout: { text: '', truncated: false }, @@ -96,25 +102,27 @@ export function bashRefusalValue(error) { } } -export function mountSessionBash(ctx, config, shells) { +interface Entry { ctx?: Context; fiber?: Fiber; dispose?: () => void; definition?: HostShellDefinition } + +export function mountSessionBash(ctx: Context, config: Pick, shells: { bash: ShellResolution }) { if (!config.publishBashTool) return const bash = shells.bash if (!bash.available) { ctx.logger?.warn?.(`dsh-ops: bash unavailable: ${bash.detail}`) return } - const entries = new Map() + const entries = new Map() let ready = false let stopped = false - const allowed = agent => { + const allowed = (agent: Agent) => { if (!ready || stopped || !agent?.session) return false try { const policy = ctx.get('sandboxPolicy')?.resolve({ session: agent.session }) - return MODES.includes(policy?.mode) && (policy.mode === 'danger-full-access' || !!ctx.get('sandbox')?.confine) + return policy !== undefined && MODES.includes(policy.mode) && (policy.mode === 'danger-full-access' || !!ctx.get('sandbox')?.confine) } catch { return false } } - const clear = entry => { + const clear = (entry: Entry) => { // One broken teardown must not strand the rest: `reconcile()` clears every entry before // republishing, so an exception escaping here stops the loop at the first entry and leaves the // later sessions without a bash tool. Log it — a silently swallowed dispose failure would hide @@ -122,12 +130,13 @@ export function mountSessionBash(ctx, config, shells) { try { entry.dispose?.() } catch (error) { - ctx.logger?.warn?.(`dsh-ops: bash tool teardown failed; continuing with the remaining sessions: ${error?.message ?? error}`) + ctx.logger?.warn?.(`dsh-ops: bash tool teardown failed; continuing with the remaining sessions: ${errorMessage(error)}`) } finally { entry.dispose = undefined } } - const publish = (entry, definition) => { + const publish = (entry: Entry, definition: HostShellDefinition) => { + if (!entry.ctx) return // A failed publication must leave the session with the definition that last worked rather than // with no bash tool at all (#30). `entry.definition` survives `clear()`, so it holds the last // definition registered successfully for this entry. @@ -135,10 +144,10 @@ export function mountSessionBash(ctx, config, shells) { entry.dispose = entry.ctx.tools.register(definition) entry.definition = definition } catch (error) { - ctx.logger?.warn?.(`dsh-ops: bash publication failed: ${error.message}`) + ctx.logger?.warn?.(`dsh-ops: bash publication failed: ${errorMessage(error)}`) if (entry.definition === undefined) return try { entry.dispose = entry.ctx.tools.register(entry.definition) } - catch (restoreError) { ctx.logger?.warn?.(`dsh-ops: bash restoration failed: ${restoreError.message}`) } + catch (restoreError) { ctx.logger?.warn?.(`dsh-ops: bash restoration failed: ${errorMessage(restoreError)}`) } } } const reconcile = () => { @@ -148,21 +157,21 @@ export function mountSessionBash(ctx, config, shells) { if (!entry.ctx || !allowed(agent)) continue const base = bashToolDefinition({ file: bash.file, source: bash.source, subprocess: ctx.get('subprocess') }) const definition = hostBashDefinition(ctx, base, - (args, exec, standing, policy, dshEnv, options) => startSessionBash({ ctx, bash, active: () => ready && !stopped && entries.has(exec.agent) }, + (args, exec, standing, policy, dshEnv, options) => startSessionBash({ ctx, bash, active: () => ready && !stopped && exec.agent !== undefined && entries.has(exec.agent) }, args, exec, standing, policy, dshEnv, options), sessionWorkdir) publish(entry, definition) } for (const [agent, entry] of entries) { if (!entry.ctx || allowed(agent) || stopped) continue - if ((ctx.get('tools').schemas(agent) ?? []).some(tool => tool.name === BASH_TOOL)) { + if ((ctx.get('tools')!.schemas(agent) ?? []).some(tool => tool.name === BASH_TOOL)) { try { entry.dispose = entry.ctx.tools.restrict({ deny: [BASH_TOOL] }) } - catch (error) { ctx.logger?.warn?.(`dsh-ops: inherited bash restriction failed: ${error.message}`) } + catch (error) { ctx.logger?.warn?.(`dsh-ops: inherited bash restriction failed: ${errorMessage(error)}`) } } } } - const attach = agent => { + const attach = (agent: Agent) => { if (stopped || !agent?.ctx || entries.has(agent)) return - const entry = { ctx: undefined, fiber: undefined, dispose: undefined, definition: undefined } + const entry: Entry = { ctx: undefined, fiber: undefined, dispose: undefined, definition: undefined } entries.set(agent, entry) entry.fiber = agent.ctx.plugin(child => { child.inject(['tools'], toolsCtx => { @@ -171,7 +180,7 @@ export function mountSessionBash(ctx, config, shells) { }) }) } - const detach = agent => { + const detach = (agent: Agent) => { const entry = entries.get(agent) if (!entry) return entries.delete(agent) @@ -184,7 +193,7 @@ export function mountSessionBash(ctx, config, shells) { // allSettled: one failing detach must not leave the remaining sessions' bash layers published. await Promise.allSettled([...entries.keys()].map(detach)) }, 'dsh-ops: session bash layers') - ctx.on('agent/created', ({ agent }) => attach(agent)) + ctx.on('agent/created', ({ agent }) => { attach(agent) }) ctx.on('agent/disposed', ({ agent }) => detach(agent)) ctx.on('session/event', (_session, event) => { if (event.type === 'sandbox/mode') reconcile() }) ctx.inject(['sandbox'], () => { reconcile() }) diff --git a/src/shells/publish.js b/src/shells/publish.ts similarity index 90% rename from src/shells/publish.js rename to src/shells/publish.ts index 08952797..2b598d2c 100644 --- a/src/shells/publish.js +++ b/src/shells/publish.ts @@ -22,6 +22,10 @@ * @module dsh-ops/shells/publish */ +import type { Context } from '@deepseek-ai/cordis' +import type { ResolvedConfig } from '../config.js' +import type { ShellResolution } from './paths.js' +import { errorMessage } from './types.js' import { BASH_TOOL_NAME } from './constants.js' import { bashToolDefinition } from './tool.js' @@ -31,7 +35,7 @@ import { bashToolDefinition } from './tool.js' * @param {string} message - the report. * @returns {void} */ -function reportShellProblem(ctx, message) { +function reportShellProblem(ctx: Context, message: string) { const logger = ctx?.logger if (typeof logger?.warn === 'function') { logger.warn(message) @@ -57,7 +61,7 @@ function reportShellProblem(ctx, message) { * @param {{bash: import('./paths.js').ShellResolution, pwsh: import('./paths.js').ShellResolution}} shells - the resolution to publish for. * @returns {() => void} the disposer for every registration made here. */ -export function publishShellTools(ctx, config, shells) { +export function publishShellTools(ctx: Context, config: Pick, shells: { bash: ShellResolution; pwsh: ShellResolution }) { if (config?.publishBashTool === false) return () => {} const bash = shells?.bash if (bash === undefined || bash.available !== true || typeof bash.file !== 'string') { @@ -95,7 +99,7 @@ export function publishShellTools(ctx, config, shells) { // A registry conflict (a foreign `ops_bash`) or a schema rejection is // reported and skipped: it must not fail plugin activation. reportShellProblem(ctx, `dsh-ops: ${BASH_TOOL_NAME} could not be registered ` - + `(${String(error?.message ?? error)}).`) + + `(${errorMessage(error)}).`) return () => {} } if (typeof dispose !== 'function') return () => {} diff --git a/src/shells/tool.js b/src/shells/tool.ts similarity index 93% rename from src/shells/tool.js rename to src/shells/tool.ts index 1a1dad91..42966882 100644 --- a/src/shells/tool.js +++ b/src/shells/tool.ts @@ -14,6 +14,8 @@ */ import fs from 'node:fs' +import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import type { ShellStream, ShellResult, SettledShellResult, ShellExecutorOptions, LegacyShellDefinition } from './types.js' import { BASH_ARGUMENTS, BASH_TOOL_NAME, @@ -31,8 +33,8 @@ import { startBashExecution } from './execution.js' * @returns {void} * @throws {BashArgumentError} when the value is not a positive finite number. */ -function assertPositiveFinite(name, value) { - if (!Number.isFinite(value) || value <= 0) { +function assertPositiveFinite(name: string, value: unknown): asserts value is number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { throw new BashArgumentError(`invalid ${name}: expected a positive finite number, got ${JSON.stringify(value)}`, name) } } @@ -58,7 +60,7 @@ function assertPositiveFinite(name, value) { * @returns {void} * @throws {BashArgumentError} when `args` is not an object, or carries an undeclared key. */ -function assertKnownArguments(args, known) { +function assertKnownArguments(args: unknown, known: readonly string[]): asserts args is Record | null | undefined { if (args === undefined || args === null) return if (typeof args !== 'object' || Array.isArray(args)) { throw new BashArgumentError(`invalid arguments: expected an object, got ${JSON.stringify(args)}`, 'arguments') @@ -85,8 +87,8 @@ function assertKnownArguments(args, known) { * @param {object} value - the canonical result value. * @returns {string} the rendered text. */ -export function renderBashResult(value) { - const stream = (output) => (output.truncated +export function renderBashResult(value: ShellResult) { + const stream = (output: ShellStream) => (output.truncated ? `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]` : output.text) const out = stream(value.stdout) @@ -124,7 +126,7 @@ export function renderBashResult(value) { * output". */ /** @type {import('@deepseek-ai/dsh-tools').JsonSchemaNode} */ -export const BASH_OUTPUT_SCHEMA = Object.freeze({ +export const BASH_OUTPUT_SCHEMA: JsonSchemaNode = Object.freeze({ type: 'object', additionalProperties: false, required: ['status', 'timedOut', 'timeoutMs', 'stdout', 'stderr'], @@ -176,7 +178,7 @@ export const BASH_OUTPUT_SCHEMA = Object.freeze({ * @param {import('./types.js').ShellResult} value - the canonical result value, without `status`. * @returns {import('./types.js').SettledShellResult} the same value with `status` (re)computed. */ -export function withBashStatus(value) { +export function withBashStatus(value: ShellResult): SettledShellResult { const completed = value.exitCode === 0 && value.error === undefined && value.permission === undefined && value.restriction === undefined && value.signal == null && value.timedOut !== true && value.cancelled !== true return { ...value, status: completed ? 'completed' : 'failed' } @@ -214,8 +216,8 @@ export function bashToolDescription() { * @param {import('./types.js').ShellExecutorOptions} options - the typed host executor inputs. * @returns {import('./types.js').LegacyShellDefinition} the legacy executor definition wrapped by the host projection. */ -export function bashToolDefinition({ file, source, subprocess, prepareArgv, env = {}, projectResult = withBashStatus }) { - const definition = { +export function bashToolDefinition({ file, source, subprocess, prepareArgv, env = {}, projectResult = withBashStatus }: ShellExecutorOptions): LegacyShellDefinition { + const definition: LegacyShellDefinition = { name: BASH_TOOL_NAME, description: bashToolDescription(), parameters: { @@ -256,7 +258,7 @@ export function bashToolDefinition({ file, source, subprocess, prepareArgv, env }, output: { schema: BASH_OUTPUT_SCHEMA, - render: (_args, value) => [{ type: 'text', text: renderBashResult(value) }], + render: (_args, value) => [{ type: 'text', text: renderBashResult(value as unknown as ShellResult) }], }, /** * Run one command through the harness subprocess service. @@ -275,7 +277,7 @@ export function bashToolDefinition({ file, source, subprocess, prepareArgv, env if (args?.description !== undefined && typeof args.description !== 'string') throw new BashArgumentError('invalid description: expected a string', 'description') const rawCommand = args?.command const rawScriptPath = args?.script_path - for (const [name, value] of [['command', rawCommand], ['script_path', rawScriptPath]]) { + for (const [name, value] of [['command', rawCommand], ['script_path', rawScriptPath]] as const) { if (value != null && typeof value !== 'string') { throw new BashArgumentError(`invalid ${name}: expected a string`, name) } @@ -321,7 +323,7 @@ export function bashToolDefinition({ file, source, subprocess, prepareArgv, env try { stat = fs.statSync(workdir, { throwIfNoEntry: false }) } catch (error) { - throw new BashArgumentError(`invalid workdir: cannot access directory (${error?.code ?? 'unknown error'}): ${workdir}`, 'workdir') + throw new BashArgumentError(`invalid workdir: cannot access directory (${typeof error === 'object' && error !== null && 'code' in error ? error.code ?? 'unknown error' : 'unknown error'}): ${workdir}`, 'workdir') } if (stat?.isDirectory() !== true) { throw new BashArgumentError(`invalid workdir: must be an existing directory, got ${workdir}`, 'workdir') diff --git a/src/shells/types.ts b/src/shells/types.ts index 2145ab5e..e241dfe3 100644 --- a/src/shells/types.ts +++ b/src/shells/types.ts @@ -63,10 +63,12 @@ export type CreateShellExecution = (args: Record, exec: ShellCa export interface ShellBase { name: string description: string - parameters: JsonSchemaNode & { properties: Record } + parameters: JsonSchemaNode & Record & { properties: Record } resolve(args: Record): unknown } export interface LegacyShellDefinition extends ShellBase { + resolve(args: unknown): ShellExecutionSpec + output: ToolDefinition['output'] start(args: Record, exec?: ShellCall, options?: ExecutionOptions): ShellExecution execute(args: Record, exec?: ShellCall): Promise } From 3bd3c7faa90e4e87310606ac163341000c0fa9cc Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:35:42 +0800 Subject: [PATCH 069/118] feat(settings): expose validated persistent row forms --- docs/CHANGELOG.md | 4 + docs/CONTRIBUTING.md | 5 + docs/configuration-reference.md | 8 + ...41\347\214\256\346\214\207\345\215\227.md" | 5 + ...15\347\275\256\345\217\202\350\200\203.md" | 7 + package-lock.json | 506 ++++++++++++++++++ package.json | 20 +- src/components/discovery/index.ts | 8 + src/components/hide-official/index.ts | 16 +- src/components/shell/index.js | 3 - src/components/shell/index.ts | 14 + src/settings/schema.ts | 45 ++ test/lib/settings-profile.mjs | 28 + test/settings-host.test.mjs | 125 +++++ 14 files changed, 776 insertions(+), 18 deletions(-) delete mode 100644 src/components/shell/index.js create mode 100644 src/components/shell/index.ts create mode 100644 src/settings/schema.ts create mode 100644 test/lib/settings-profile.mjs create mode 100644 test/settings-host.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e658577e..74b29dc1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Validate and persist host settings forms (#261) + +- Expose shell executor, discovery pool/always-loaded and per-tool replacement fields through detached settings descriptors and existing validators. Accepted changes persist through the host profile writer and reload the row; stale/invalid writes preserve the profile. +- 为 shell 执行器、发现允许池/常驻项和逐项替换提供宿主设置描述及既有校验;成功保存后重载对应行,版本过期或无效配置不会改写文件。 - Drain release-gate stdout and stderr concurrently so a full diagnostic pipe cannot deadlock verification; retain both streams and failing exit codes. - 发布门禁并行读取 stdout 和 stderr,避免诊断管道写满后验证死锁;保留两路完整输出及失败退出码。 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 34f25728..ca0f2ebd 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -192,3 +192,8 @@ reproduced in `NOTICE` and the READMEs **must stay verbatim** — do not "polish Native build and acceptance details: [native-ptc.md](native-ptc.md) and [manual-validation.md](manual-validation.md). + +The settings integration tests use exact development-only host settings, profile boot and config +editor packages at `0.2.0-rc.2`, with matching Cordis loader/group/include, CosmoKit and Schemastery +patch versions. They exercise the real profile writer and component reload instead of substituting +an in-memory save. These packages are not production dependencies or host runtime imports. diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 937016a1..7df07e71 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -154,3 +154,11 @@ Prompts are registered as independent runtime sections, **not written to AGENTS. - [Usage](usage.md) — install, update, remove, CLI - [Component reference](component-reference.md) — components and tools - [Troubleshooting](troubleshooting.md) — errors and diagnosis + +## Host settings forms + +The enabled shell, discovery and official-replacement rows expose validated fields to the host +settings service. Saving uses the profile revision the form read; a stale revision or invalid +selection is rejected before writing. The host persists accepted changes and reloads the affected +row, so the running configuration follows the saved selection. Ordinary row fields are preserved. +A disabled row has no editable form; re-enable it with the host's built-in row switch first. diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 827c6db1..73e90a56 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -126,3 +126,8 @@ npm run build:bash-ipc # 仅 Windows:Bash IPC 除 `licenses/fastctx/` 外均为 MIT;FastCtx 为 Apache-2.0。`NOTICE` 与 README 中上游要求逐字转载的致谢**必须原样保留**,不要「顺手润色」。 原生组件的编译与验收细节见 [native-ptc.md](native-ptc.md) 与 [manual-validation.md](manual-validation.md)。 + +设置集成测试使用固定 `0.2.0-rc.2` 版本的宿主设置、配置启动及配置编辑器开发依赖, +并固定兼容的 Cordis loader/group/include、CosmoKit 和 Schemastery 补丁版本。 +测试实际的配置文件写入及组件重新加载,而不是用内存保存代替;这些包不属于生产依赖, +插件宿主代码也不会在运行时导入它们。 diff --git "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" index 2a0a0c5f..5270d254 100644 --- "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" +++ "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" @@ -147,3 +147,10 @@ Cordis 通过 `StandardSchemaV1` 校验配置。在固定版本 DSH 0.2.0-rc.2 - [使用指南](使用指南.md) —— 安装、更新、卸载、命令行接口 - [组件参考](组件参考.md) —— 组件与工具列表 - [常见问题](常见问题.md) —— 报错与排查 + +## 宿主设置表单 + +已启用的 shell、discovery 和官方工具替换行向宿主设置服务提供经过校验的字段。 +保存使用表单读取时的配置版本;版本过期或选项无效时,在写入前拒绝更改。 +宿主持久化成功后重新加载对应行,使运行中的设置与已保存选择一致,同时保留其他行字段。 +禁用的行没有可编辑表单;请先使用宿主内置的行开关重新启用。 diff --git a/package-lock.json b/package-lock.json index 30df2750..64cf5a64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,18 +22,26 @@ }, "devDependencies": { "@deepseek-ai/cordis": "4.0.4", + "@deepseek-ai/cordis-plugin-group": "1.0.4", + "@deepseek-ai/cordis-plugin-include": "1.0.9", + "@deepseek-ai/cordis-plugin-loader": "1.0.5", + "@deepseek-ai/cosmokit": "1.8.5", "@deepseek-ai/dsh-agent-loop": "0.2.0-rc.2", + "@deepseek-ai/dsh-app-boot": "0.2.0-rc.2", + "@deepseek-ai/dsh-config-editor": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-local": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-observation-policy": "0.2.0-rc.2", "@deepseek-ai/dsh-ptc-runtime-node": "0.2.0-rc.2", "@deepseek-ai/dsh-sandbox-local": "0.2.0-rc.2", "@deepseek-ai/dsh-scope": "0.2.0-rc.2", + "@deepseek-ai/dsh-settings": "0.2.0-rc.2", "@deepseek-ai/dsh-subprocess": "0.2.0-rc.2", "@deepseek-ai/dsh-subprocess-local": "0.2.0-rc.2", "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2", "@deepseek-ai/dsh-tool-fs": "0.2.0-rc.2", "@deepseek-ai/dsh-tool-fs-search": "0.2.0-rc.2", "@deepseek-ai/dsh-tools": "0.2.0-rc.2", + "@deepseek-ai/schemastery": "3.18.4", "@dsh-std/manifest": "0.1.1-rc.3", "@types/node": "22.20.5", "js-tiktoken": "1.0.21", @@ -82,6 +90,55 @@ } } }, + "node_modules/@deepseek-ai/cordis-plugin-group": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis-plugin-group/-/cordis-plugin-group-1.0.4.tgz", + "integrity": "sha512-/7tuY5pMQetHava4v6QUeOa4CwZA+mleNvbIoj+iqEmzkYUtlXbLqhzJIcbnDa/Hxgr1jaCwROrI77nuLyeCFg==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/cordis-plugin-loader": "~1.0.5" + } + }, + "node_modules/@deepseek-ai/cordis-plugin-include": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis-plugin-include/-/cordis-plugin-include-1.0.9.tgz", + "integrity": "sha512-Xmekm7u5Kpeym6nABL4T1/Mg6aQYFZxsipB0WycmCctvX1DqAv8nCFwRmg2XMx/f7EbZgNEbXQwJfabcMu8zxQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/cosmokit": "~1.8.5", + "js-yaml": "^4.1.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/cordis-plugin-loader": "~1.0.5" + } + }, + "node_modules/@deepseek-ai/cordis-plugin-loader": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis-plugin-loader/-/cordis-plugin-loader-1.0.5.tgz", + "integrity": "sha512-200huL2ttWcjyeYllvPYn+qMR7I89qy8MhZasbHltPzB/KMrSfsyoCYKdCGc5KqPGj5iJugcmibQzjINMO9I+Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/cosmokit": "~1.8.5", + "@deepseek-ai/schemastery": "~3.18.4" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "node-addon-require-builtin": "^0.1.6" + }, + "peerDependenciesMeta": { + "node-addon-require-builtin": { + "optional": true + } + } + }, "node_modules/@deepseek-ai/cosmokit": { "version": "1.8.5", "resolved": "https://registry.npmjs.org/@deepseek-ai/cosmokit/-/cosmokit-1.8.5.tgz", @@ -139,6 +196,43 @@ "@deepseek-ai/dsh-tools": "0.2.0-rc.2" } }, + "node_modules/@deepseek-ai/dsh-app-boot": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-app-boot/-/dsh-app-boot-0.2.0-rc.2.tgz", + "integrity": "sha512-WvgNhBHSj85Z7u9vC1oQr9C7yZQ/L/JS+eVK4bueWEkaoqdEna504V5bi7qtWXxAz5JkeVZTy1sX86p8XG7LJg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/dsh-atomic-write": "0.2.0-rc.2", + "@deepseek-ai/dsh-package-manifest": "0.2.0-rc.2", + "@eslint-community/regexpp": "^4.12.2", + "ajv": "^8.20.0", + "js-yaml": "^4.2.0", + "node-addon-require-builtin": "^0.1.6", + "resolve.exports": "^2.0.3", + "semver": "^7.8.5" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/cordis-plugin-group": "~1.0.4", + "@deepseek-ai/cordis-plugin-include": "~1.0.9", + "@deepseek-ai/cordis-plugin-loader": "~1.0.5", + "@deepseek-ai/dsh-home-paths": "0.2.0-rc.2", + "@deepseek-ai/dsh-launch-environment": "0.2.0-rc.2", + "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-atomic-write": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-atomic-write/-/dsh-atomic-write-0.2.0-rc.2.tgz", + "integrity": "sha512-CezxdPmADDnOyG/ed9R18In/NifnLgu8hfJEjTBKxd7kbu0XXenyqnk25jeWD6bJYwu8AWAwcpVFuf438EJPJA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, "node_modules/@deepseek-ai/dsh-attachment": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-attachment/-/dsh-attachment-0.2.0-rc.2.tgz", @@ -161,6 +255,30 @@ "@deepseek-ai/cordis": "~4.0.4" } }, + "node_modules/@deepseek-ai/dsh-config-editor": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-config-editor/-/dsh-config-editor-0.2.0-rc.2.tgz", + "integrity": "sha512-LG/4EyMMBEwcUAsewqn9+6m9+mFeXVGaze0gaz3YOO/bCnJRI/2f4iuejsk/n+yMI9CNczOaIfWT5b3s7zyhfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-atomic-write": "0.2.0-rc.2", + "js-yaml": "^4.1.0", + "yaml": "^2.8.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/cordis-plugin-include": "~1.0.9", + "@deepseek-ai/cordis-plugin-loader": "~1.0.5", + "@deepseek-ai/dsh-app-boot": "0.2.0-rc.2", + "@deepseek-ai/dsh-hmr": "0.2.0-rc.2" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-hmr": { + "optional": true + } + } + }, "node_modules/@deepseek-ai/dsh-fs": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs/-/dsh-fs-0.2.0-rc.2.tgz", @@ -203,6 +321,17 @@ "@deepseek-ai/dsh-fs": "0.2.0-rc.2" } }, + "node_modules/@deepseek-ai/dsh-home-paths": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-home-paths/-/dsh-home-paths-0.2.0-rc.2.tgz", + "integrity": "sha512-n4O5C9pNIVWbBklB5RzSTc3kr9hmUnDlZ6Leqj3TIk+CO593DTAmDGTt0rHMQe8H+1Ur+Jqj9Hqgsug/WRkkvQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, "node_modules/@deepseek-ai/dsh-http-proxy": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-http-proxy/-/dsh-http-proxy-0.2.0-rc.2.tgz", @@ -231,6 +360,17 @@ "@deepseek-ai/cordis": "~4.0.4" } }, + "node_modules/@deepseek-ai/dsh-launch-environment": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-launch-environment/-/dsh-launch-environment-0.2.0-rc.2.tgz", + "integrity": "sha512-IyhZjJJQ7RVLcKlc4pPPjojOKrr2XCYm7VRpslFq7bvGj3DGt8WMTSCCZUWxCThY2jg6kFF1ujiTGunELfRO6Q==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, "node_modules/@deepseek-ai/dsh-lazy-require": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-lazy-require/-/dsh-lazy-require-0.2.0-rc.2.tgz", @@ -272,6 +412,16 @@ "@deepseek-ai/cordis": "~4.0.4" } }, + "node_modules/@deepseek-ai/dsh-package-manifest": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-package-manifest/-/dsh-package-manifest-0.2.0-rc.2.tgz", + "integrity": "sha512-CFycoLxUT4g4w1IGjaHi/IjTzTRgnQ2PQVeHHSWoEppD+0NTZ1rHHW3ofkh74ZgQagoeo7n+91Yjl0rDGuWE3g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, "node_modules/@deepseek-ai/dsh-ptc-runtime": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-ptc-runtime/-/dsh-ptc-runtime-0.2.0-rc.2.tgz", @@ -439,6 +589,27 @@ "@deepseek-ai/dsh-session": "0.2.0-rc.2" } }, + "node_modules/@deepseek-ai/dsh-settings": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-settings/-/dsh-settings-0.2.0-rc.2.tgz", + "integrity": "sha512-Py4ZbCyady6st+h8wlsPJxrAOg9VrT1+tltymnJUiAmdnZjMADZKWec9JrB2KwUuo27R+IbibxzaYQOvnZdUkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/cordis-plugin-loader": "~1.0.5", + "@deepseek-ai/cosmokit": "~1.8.5", + "@deepseek-ai/dsh-config-editor": "0.2.0-rc.2", + "@deepseek-ai/dsh-util-values": "0.2.0-rc.2", + "yaml": "^2.8.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-brand": "0.2.0-rc.2", + "@deepseek-ai/dsh-invariants": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2", + "@deepseek-ai/schemastery": "~3.18.4" + } + }, "node_modules/@deepseek-ai/dsh-skill": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-skill/-/dsh-skill-0.2.0-rc.2.tgz", @@ -796,6 +967,16 @@ "node": "^22.19 || >=24" } }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/@koromix/koffi-darwin-arm64": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.1.tgz", @@ -1257,6 +1438,30 @@ "win32" ] }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1304,6 +1509,30 @@ "node": ">=0.3.1" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/js-tiktoken": { "version": "1.0.21", "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", @@ -1314,6 +1543,36 @@ "base64-js": "^1.5.1" } }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/koffi": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.1.tgz", @@ -1349,6 +1608,220 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-native-custom-loader": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/node-addon-native-custom-loader/-/node-addon-native-custom-loader-0.1.9.tgz", + "integrity": "sha512-Zh87aubSc1UF8CzZsRws1KtSwXiPDH0tbcg8V/2OA8Tcq9yGJRESFcEOC5NGLFama5IGfC3aJhubZbrj9Un2qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-addon-require-builtin": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin/-/node-addon-require-builtin-0.1.9.tgz", + "integrity": "sha512-BCxXCMZDs/VLLh64SwKNw/me7Qx1I7Be4qxWwYVRRm8tJoOB8fL7qg6CmXhSXeUqsKt72t8RaKCX4EN+5nocVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-addon-native-custom-loader": "0.1.9" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "node-addon-require-builtin-darwin-arm64": "0.1.9", + "node-addon-require-builtin-darwin-x64": "0.1.9", + "node-addon-require-builtin-linux-arm64-gnu": "0.1.9", + "node-addon-require-builtin-linux-arm64-musl": "0.1.9", + "node-addon-require-builtin-linux-x64-gnu": "0.1.9", + "node-addon-require-builtin-linux-x64-musl": "0.1.9", + "node-addon-require-builtin-win32-arm64-msvc": "0.1.9", + "node-addon-require-builtin-win32-ia32-msvc": "0.1.9", + "node-addon-require-builtin-win32-x64-msvc": "0.1.9" + } + }, + "node_modules/node-addon-require-builtin-darwin-arm64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-darwin-arm64/-/node-addon-require-builtin-darwin-arm64-0.1.9.tgz", + "integrity": "sha512-n45xEILQVOLZ2hVhzRXesTMYS4odHquyI/B/IkPMPUCONa8PjNfBag6lYBH4wDrhgpLNNBwMsUD8d8O/13QPPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "node-addon-native-custom-loader": "0.1.9" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-addon-require-builtin-darwin-x64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-darwin-x64/-/node-addon-require-builtin-darwin-x64-0.1.9.tgz", + "integrity": "sha512-Sm4Liqh1uo9zgJB2F/pACPPZdHu+fj9Qniyr1Q99dDQB0v71VgZgkAgv3K0/E1iPQBlZq9rakFkY7MGUIfWc0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "node-addon-native-custom-loader": "0.1.9" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-addon-require-builtin-linux-arm64-gnu": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-linux-arm64-gnu/-/node-addon-require-builtin-linux-arm64-gnu-0.1.9.tgz", + "integrity": "sha512-ToU4wGtOD/069i2y4fJ3cjDJiiWtcjSQTZMf0YKlotb5BmTM7FjhR3X1aub8J/YjlOwl6665J/XAYuvCaDO5sA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "dependencies": { + "node-addon-native-custom-loader": "0.1.9" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-addon-require-builtin-linux-arm64-musl": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-linux-arm64-musl/-/node-addon-require-builtin-linux-arm64-musl-0.1.9.tgz", + "integrity": "sha512-85Z5Avp++wfa9iiTIH8LC/r6qvlC/CiwpifiwIz0gYQ9PzajTSkz06gX7YDqf1oUqWYxU9uwNEOmo7mktHOOfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "dependencies": { + "node-addon-native-custom-loader": "0.1.9" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-addon-require-builtin-linux-x64-gnu": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-linux-x64-gnu/-/node-addon-require-builtin-linux-x64-gnu-0.1.9.tgz", + "integrity": "sha512-fh3K3/JvBfSYwU8vEOpp4Wdp4N/jX0EnZYq5GsaftlQin1FVqgve/yDNoh+asxBMOQyHOG8F4dXlV1WCierrfg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "dependencies": { + "node-addon-native-custom-loader": "0.1.9" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-addon-require-builtin-linux-x64-musl": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-linux-x64-musl/-/node-addon-require-builtin-linux-x64-musl-0.1.9.tgz", + "integrity": "sha512-B6fu/5xmbRxnifM7nWz0IESkYnUl8dwFZmRDMsxdhzv2nb+EtBRFAXg4lf73qhX3yoZD4SnWJILBQMlIAwBCzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "dependencies": { + "node-addon-native-custom-loader": "0.1.9" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-addon-require-builtin-win32-arm64-msvc": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-win32-arm64-msvc/-/node-addon-require-builtin-win32-arm64-msvc-0.1.9.tgz", + "integrity": "sha512-FOQuzNe8B1BANu886rJgdbQuRxPGUQmewkykw87wSr32U0ibQ3AWGuUM/Gh7LG4RekjFaifj7gEI/tq2+6GU6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "dependencies": { + "node-addon-native-custom-loader": "0.1.9" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-addon-require-builtin-win32-ia32-msvc": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-win32-ia32-msvc/-/node-addon-require-builtin-win32-ia32-msvc-0.1.9.tgz", + "integrity": "sha512-N29P0kmIBT6hnkNNFYzxn+o9ydT6OYZe9RFAi8dSxV1rNSul1FlxtuKWYe8w2sQpbQGYuGDEtpOwdeFtq2iFTg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "dependencies": { + "node-addon-native-custom-loader": "0.1.9" + }, + "engines": { + "node": ">=20 <23" + } + }, + "node_modules/node-addon-require-builtin-win32-x64-msvc": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-win32-x64-msvc/-/node-addon-require-builtin-win32-x64-msvc-0.1.9.tgz", + "integrity": "sha512-+i6N4A7m7txjbw8pDYESdWXiKO9EEVq0SzTzwPS0WZuV4F1MzQnaDJwJJnO7T6zAc0BvqHXBOkpharCrUjFhbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "dependencies": { + "node-addon-native-custom-loader": "0.1.9" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/node-pty": { "version": "1.2.0-beta.15", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz", @@ -1374,6 +1847,39 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.4.tgz", + "integrity": "sha512-zepuGL0ulf+nt+XaFrlKeaiTJeyPFPLLY62+ncq1/VTHBdt3HsfXOH60priZ6tnXQozdGcDeIF5JVTanvIcYWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", diff --git a/package.json b/package.json index 4c522567..40733d1e 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,9 @@ "./ptc": { "default": "./lib/components/ptc/index.js" }, - "./ptc-sdk": { "types": "./lib/ptc/sdk.d.ts" }, + "./ptc-sdk": { + "types": "./lib/ptc/sdk.d.ts" + }, "./ptc/package.json": "./lib/components/ptc/package.json", "./ptc/locale/*.json": "./lib/components/ptc/locale/*.json", "./panel": { @@ -68,10 +70,14 @@ "./panel/package.json": "./lib/components/panel/package.json", "./panel/locale/*.json": "./lib/components/panel/locale/*.json", "./panel/icon": "./lib/components/panel/icon.png", - "./tui": { "default": "./lib/components/tui/index.js" }, + "./tui": { + "default": "./lib/components/tui/index.js" + }, "./tui/package.json": "./lib/components/tui/package.json", "./tui/locale/*.json": "./lib/components/tui/locale/*.json", - "./discovery": { "default": "./lib/components/discovery/index.js" }, + "./discovery": { + "default": "./lib/components/discovery/index.js" + }, "./discovery/package.json": "./lib/components/discovery/package.json", "./discovery/locale/*.json": "./lib/components/discovery/locale/*.json", "./hide-official": { @@ -150,18 +156,26 @@ }, "devDependencies": { "@deepseek-ai/cordis": "4.0.4", + "@deepseek-ai/cordis-plugin-group": "1.0.4", + "@deepseek-ai/cordis-plugin-include": "1.0.9", + "@deepseek-ai/cordis-plugin-loader": "1.0.5", + "@deepseek-ai/cosmokit": "1.8.5", "@deepseek-ai/dsh-agent-loop": "0.2.0-rc.2", + "@deepseek-ai/dsh-app-boot": "0.2.0-rc.2", + "@deepseek-ai/dsh-config-editor": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-local": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-observation-policy": "0.2.0-rc.2", "@deepseek-ai/dsh-ptc-runtime-node": "0.2.0-rc.2", "@deepseek-ai/dsh-sandbox-local": "0.2.0-rc.2", "@deepseek-ai/dsh-scope": "0.2.0-rc.2", + "@deepseek-ai/dsh-settings": "0.2.0-rc.2", "@deepseek-ai/dsh-subprocess": "0.2.0-rc.2", "@deepseek-ai/dsh-subprocess-local": "0.2.0-rc.2", "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2", "@deepseek-ai/dsh-tool-fs": "0.2.0-rc.2", "@deepseek-ai/dsh-tool-fs-search": "0.2.0-rc.2", "@deepseek-ai/dsh-tools": "0.2.0-rc.2", + "@deepseek-ai/schemastery": "3.18.4", "@dsh-std/manifest": "0.1.1-rc.3", "@types/node": "22.20.5", "js-tiktoken": "1.0.21", diff --git a/src/components/discovery/index.ts b/src/components/discovery/index.ts index 8639b064..03622670 100644 --- a/src/components/discovery/index.ts +++ b/src/components/discovery/index.ts @@ -1 +1,9 @@ export { name, apply } from '../../discovery/index.js' +import { resolveDiscoveryConfig } from '../../discovery/config.js' +import { editable, settingsConfig } from '../../settings/schema.js' +export const Config = settingsConfig(resolveDiscoveryConfig, { + enabled: editable('boolean', 'Publish the discovery entry.'), + maxResults: editable('number', 'Maximum capabilities selected per discovery call, from 1 to 5.'), + allowedTools: editable('array', 'Implemented capabilities available for discovery.', { type: 'string' }), + alwaysLoaded: editable('array', 'Allowed capabilities published without a discovery request.', { type: 'string' }), +}) diff --git a/src/components/hide-official/index.ts b/src/components/hide-official/index.ts index cfdc17fe..8b0e1ac8 100644 --- a/src/components/hide-official/index.ts +++ b/src/components/hide-official/index.ts @@ -1,15 +1,7 @@ import { apply, resolveHidingConfig } from '../../official-tool-hiding.js' +import { editable, settingsConfig, configObject } from '../../settings/schema.js' export const name = 'dsh-ops-hide-official' export { apply } - -// Cordis consumes StandardSchemaV1 directly. No host value import is needed. -export const Config = { - '~standard': { - version: 1 as const, - vendor: 'dsh-ops', - validate(value: unknown) { - try { return { value: resolveHidingConfig(value) } } - catch (error) { return { issues: [{ message: error instanceof Error ? error.message : String(error) }] } } - }, - }, -} +export const Config = settingsConfig(value => resolveHidingConfig(configObject(value)), { + replaceTools: editable('array', 'Official read/search tools explicitly selected for replacement.', { type: 'string' }), +}) diff --git a/src/components/shell/index.js b/src/components/shell/index.js deleted file mode 100644 index bb896784..00000000 --- a/src/components/shell/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { applyComponent } from "../../index.js" -export const name = "dsh-ops-bash & host PowerShell" -export function apply(ctx, config) { return applyComponent(ctx, config, "shell") } diff --git a/src/components/shell/index.ts b/src/components/shell/index.ts new file mode 100644 index 00000000..f60d6aff --- /dev/null +++ b/src/components/shell/index.ts @@ -0,0 +1,14 @@ +import type { Context } from '@deepseek-ai/cordis' +import { applyComponent } from '../../index.js' +import { resolveConfig } from '../../config.js' +import { editable, settingsConfig, configObject } from '../../settings/schema.js' +export const name = 'dsh-ops-bash & host PowerShell' +export const Config = settingsConfig(value => { + const raw = configObject(value), validated = resolveConfig(raw) + // Keep raw ordinary keys: applyComponent owns normalization and migration. + return { ...raw, publishBashTool: validated.publishBashTool } +}, { + bashPath: editable('string', 'Absolute Bash executor path. Remove the override to select the optional managed installation.'), + publishBashTool: editable('boolean', 'Publish Bash for authorized sessions.'), +}) +export function apply(ctx: Context, config: unknown): void { applyComponent(ctx, config, 'shell') } diff --git a/src/settings/schema.ts b/src/settings/schema.ts new file mode 100644 index 00000000..dffbd6d7 --- /dev/null +++ b/src/settings/schema.ts @@ -0,0 +1,45 @@ +/** Public settings descriptors beside the existing strict Standard Schema validators. + * Settings reads meta.volatile to select editable fields. Keep our own vendor: + * the loader must remount these components instead of assuming Schemastery refs. + */ +export type Field = { + type: 'string' | 'boolean' | 'number' | 'array' | 'object' + meta?: { volatile?: boolean; description?: string } + inner?: Field + dict?: Record +} +interface Descriptor extends Field { + meta: NonNullable + inner?: Descriptor + dict?: Record + toJSON(): Field +} +function descriptor(field: Field): Descriptor { + return { + type: field.type, meta: { ...field.meta }, + ...(field.inner ? { inner: descriptor(field.inner) } : {}), + ...(field.dict ? { dict: Object.fromEntries(Object.entries(field.dict).map(([key, value]) => [key, descriptor(value)])) } : {}), + // The host strips volatile metadata from its validation copy. Returning a + // detached tree keeps that operation from corrupting subsequent descriptions. + toJSON() { return JSON.parse(JSON.stringify({ ...this, toJSON: undefined })) as Field }, + } +} +export const editable = (type: Field['type'], description: string, inner?: Field): Field => ({ + type, meta: { volatile: true, description }, ...(inner ? { inner } : {}), +}) +export function settingsConfig(normalize: (value: unknown) => T, fields: Record) { + return Object.assign(descriptor({ type: 'object', dict: fields }), { + '~standard': { + version: 1 as const, vendor: 'dsh-ops', + validate(value: unknown): { value: T } | { issues: { message: string }[] } { + try { return { value: normalize(value) } } + catch (error) { return { issues: [{ message: error instanceof Error ? error.message : String(error) }] } } + }, + }, + }) +} +export function configObject(value: unknown): Record { + if (value === undefined) return {} + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('config must be an object') + return value as Record +} diff --git a/test/lib/settings-profile.mjs b/test/lib/settings-profile.mjs new file mode 100644 index 00000000..373ce3df --- /dev/null +++ b/test/lib/settings-profile.mjs @@ -0,0 +1,28 @@ +import fs from 'node:fs' +import path from 'node:path' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Settings from '@deepseek-ai/dsh-settings' +import ConfigEditor from '@deepseek-ai/dsh-config-editor' +import { mountRootInclude, readProfilePatches } from '@deepseek-ai/dsh-app-boot' +import { bootHost } from './host.mjs' +import { TEST_HOME, PACKAGE_ROOT } from './harness.mjs' + +export async function profileFixture(label, plugin, config) { + const { ctx } = await bootHost() + const home = path.join(TEST_HOME, label), dir = path.join(home, 'profiles/fixture') + fs.mkdirSync(dir, { recursive: true }) + const patchPath = path.join(dir, 'cordis.patch.yml'), baseFile = path.join(home, 'base.yml') + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'fixture', private: true, dsh: { profile: { bundles: [] } } })) + fs.writeFileSync(baseFile, '[]\n') + fs.writeFileSync(patchPath, JSON.stringify([{ insert: [{ id: label, name: 'cordis:fixture', config }] }])) + const profile = { home, dir, patchPath, overlays: [], installAnchor: path.join(PACKAGE_ROOT, 'package.json') } + await ctx.plugin(Loader, {}) + ctx.loader.builtins.fixture = plugin + await ctx.plugin(inner => inner.provide('profileContext', profile)) + await mountRootInclude(ctx, baseFile, readProfilePatches('dsh', profile)) + await ctx.loader.await() + await ctx.plugin(ConfigEditor) + await ctx.plugin(Settings) + return { ctx, patchPath, snapshot: () => ctx.settings.describe().find(value => value.ns === label), + mutate: (operations, revision) => ctx.settings.mutate(label, operations, revision) } +} diff --git a/test/settings-host.test.mjs b/test/settings-host.test.mjs new file mode 100644 index 00000000..b4d80a77 --- /dev/null +++ b/test/settings-host.test.mjs @@ -0,0 +1,125 @@ +/** Real pinned settings/profile writer: persistence, validation and component reload. */ +import fs from 'node:fs' +import path from 'node:path' +import { parse } from 'yaml' +import { profileFixture } from './lib/settings-profile.mjs' +import { createScope } from '@deepseek-ai/dsh-scope' +import * as Hiding from '../lib/components/hide-official/index.js' +import { Config as DiscoveryConfig } from '../lib/components/discovery/index.js' +import * as Shell from '../lib/components/shell/index.js' +const ShellConfig = Shell.Config +import { publicToolName } from '../lib/policy.js' +import { registerNativeProvider } from '../lib/file/result-provider.js' +import { shutdown, scopedToolNames } from './lib/host.mjs' +import { assert, test, report, TEST_HOME } from './lib/harness.mjs' + + +const set = (key, value) => ({ op: 'set', path: [key], value }) +const tool = name => ({ name, description: name, parameters: { type: 'object', properties: {} }, + output: { schema: { type: 'object' }, render: () => [] }, execute: async () => ({}) }) + +await test('real settings save persists selection and changes the active official-tool mask', async () => { + const fixture = await profileFixture('ops-replacements', Hiding, { replaceTools: [], strict: true }) + const { ctx, patchPath, snapshot, mutate } = fixture + try { + for (const name of ['read', 'glob']) ctx.tools.register(tool(name)) + const stop = await registerNativeProvider(ctx, [tool(publicToolName('inspect_local_file'))], definition => ctx.tools.register(definition)) + ctx.effect(() => stop) + const agent = { id: 'settings-agent', session: { id: 'settings-agent', header: { cwd: TEST_HOME } } } + await ctx.plugin({ inject: ['tools', 'systemPrompt'], apply(inner) { agent.ctx = createScope(inner, agent).ctx } }) + await ctx.plugin(inner => inner.provide('agents', { list: () => [agent] })) + await ctx.serial('agent/created', { agent }) + assert.ok(scopedToolNames(ctx, agent).includes('read')) + const before = snapshot() + assert.deepEqual(before.value, { replaceTools: [] }) + await mutate([set('replaceTools', ['read'])], before.revision) + await ctx.loader.await() + await new Promise(resolve => setImmediate(resolve)) + await ctx.get('systemPrompt').assemble({ scope: agent }) + assert.equal(scopedToolNames(ctx, agent).includes('read'), false) + assert.ok(scopedToolNames(ctx, agent).includes('glob')) + const saved = fs.readFileSync(patchPath, 'utf8') + assert.match(saved, /replaceTools:/) + assert.match(saved, /- read/) + assert.match(saved, /strict: true/) + const current = snapshot() + assert.deepEqual(current.value, { replaceTools: ['read'] }) + await assert.rejects(mutate([set('replaceTools', ['glob'])], before.revision), /revision|conflict|changed/i) + await assert.rejects(mutate([set('replaceTools', ['write'])], current.revision), /unsupported tool/) + assert.equal(fs.readFileSync(patchPath, 'utf8'), saved) + assert.equal(scopedToolNames(ctx, agent).includes('read'), false) + await mutate([set('replaceTools', [])], current.revision) + await ctx.loader.await() + assert.ok(scopedToolNames(ctx, agent).includes('read')) + const entry = ctx.configEditor.entries().find(entry => entry.options.id === 'ops-replacements') + await entry.update({ disabled: true }) + await ctx.loader.await() + assert.equal(snapshot(), undefined) + } finally { await shutdown(ctx) } +}) + +await test('real discovery form validates pool/always-loaded atomically and reloads accepted config', async () => { + let observed, mounts = 0 + const { ctx, patchPath, snapshot, mutate } = await profileFixture('ops-discovery', { + Config: DiscoveryConfig, apply(_ctx, config) { observed = config; mounts++ }, + }, { allowedTools: ['glob', 'grep'], alwaysLoaded: [], binaryPath: '/preserved/worker' }) + try { + const before = snapshot(), count = mounts + assert.deepEqual(Object.keys(before.value).sort(), ['allowedTools', 'alwaysLoaded', 'enabled', 'maxResults']) + await mutate([set('allowedTools', ['glob']), set('alwaysLoaded', ['glob'])], before.revision) + assert.deepEqual(observed.allowedTools, ['glob']) + assert.deepEqual(observed.alwaysLoaded, ['glob']) + assert.equal(mounts, count + 1) + assert.equal(observed.binaryPath, '/preserved/worker') + const saved = fs.readFileSync(patchPath, 'utf8'), current = snapshot() + await assert.rejects(mutate([set('alwaysLoaded', ['grep'])], current.revision), /must be in allowedTools/) + await assert.rejects(mutate([set('allowedTools', ['pdf'])], current.revision), /unavailable capability/) + await assert.rejects(mutate([set('maxResults', 6)], current.revision), /from 1 to 5/) + assert.equal(fs.readFileSync(patchPath, 'utf8'), saved) + assert.equal(mounts, count + 1) + assert.ok(parse(saved)) + } finally { await shutdown(ctx) } +}) + +await test('real shell form persists executor selection without erasing ordinary policy', async () => { + let observed + const { ctx, snapshot, mutate, patchPath } = await profileFixture('ops-shell', { + Config: ShellConfig, apply(_ctx, config) { observed = config }, + }, { shellPolicy: 'deny-host-shell', allowSystemShellFallback: false }) + try { + const selected = path.join(TEST_HOME, 'selected', 'bash.exe') + await mutate([set('bashPath', selected), set('publishBashTool', false)], snapshot().revision) + assert.equal(observed.bashPath, selected) + assert.equal(observed.publishBashTool, false) + assert.equal(observed.shellPolicy, 'deny-host-shell') + assert.equal(observed.allowSystemShellFallback, false) + const saved = fs.readFileSync(patchPath, 'utf8') + await assert.rejects(mutate([set('bashPath', '')], snapshot().revision), /non-empty string/) + assert.equal(fs.readFileSync(patchPath, 'utf8'), saved) + } finally { await shutdown(ctx) } +}) + +await test('the actual shell component starts and reloads through the profile adapter without normalizing twice', async () => { + const { ctx, snapshot, mutate, patchPath } = await profileFixture('actual-shell', Shell, { promptPolicy: false, publishBashTool: false }) + try { + assert.equal(snapshot().value.publishBashTool, false) + await mutate([set('publishBashTool', true)], snapshot().revision) + await ctx.loader.await() + assert.equal(snapshot().value.publishBashTool, true) + assert.match(fs.readFileSync(patchPath, 'utf8'), /publishBashTool: true/) + assert.equal(ShellConfig['~standard'].validate({ extraGuidance: '' }).issues.length, 1) + } finally { await shutdown(ctx) } +}) + +await test('host descriptor projections cannot remove metadata from later descriptions', () => { + for (const Config of [Hiding.Config, DiscoveryConfig, ShellConfig]) { + assert.equal(Config['~standard'].vendor, 'dsh-ops') + const copy = Config.toJSON() + for (const key of Object.keys(copy.dict)) { + delete copy.dict[key].meta.volatile + assert.equal(Config.dict[key].meta.volatile, true) + assert.equal(Config.toJSON().dict[key].meta.volatile, true) + } + } +}) +report('settings-host') From ab5a1647c0ceaecba2401028f4dbddc2ac8b4cc4 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:21:41 +0800 Subject: [PATCH 070/118] refactor(tui): type bounded activity and rendering --- docs/CHANGELOG.md | 6 ++ docs/native-kernel.md | 6 ++ ...37\347\224\237\345\206\205\346\240\270.md" | 5 ++ src/panel/{render.js => render.ts} | 5 +- src/tui/{activity.js => activity.ts} | 81 +++++++++++++------ src/tui/{attention.js => attention.ts} | 29 +++++-- src/tui/{panel.js => panel.ts} | 66 +++++++++++---- src/tui/result.js | 35 -------- src/tui/result.ts | 47 +++++++++++ test/tui-result.test.mjs | 5 ++ test/types/tsconfig.json | 3 +- test/types/tui.ts | 56 +++++++++++++ 12 files changed, 257 insertions(+), 87 deletions(-) rename src/panel/{render.js => render.ts} (92%) rename src/tui/{activity.js => activity.ts} (52%) rename src/tui/{attention.js => attention.ts} (54%) rename src/tui/{panel.js => panel.ts} (70%) delete mode 100644 src/tui/result.js create mode 100644 src/tui/result.ts create mode 100644 test/types/tui.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 86d4c66e..10761718 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,12 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Type optional TUI projections and panel rendering (#261, 16/16) + +- Compile bounded activity, attention, diagnostics and injected rendering under strict TypeScript; + preserve readonly metadata, separate running/settled facts and packaged viewer paths without UI dependencies. +- TUI 与面板辅助模块严格类型化,保留只读元数据、运行/完成状态区别与资源路径,不新增 UI 依赖。 + ### Type Rust toolchain discovery and source builds (#261, 16/16) - Compile Cargo discovery and explicit locked build helpers under strict TypeScript with checked diff --git a/docs/native-kernel.md b/docs/native-kernel.md index c5812fe2..23baba93 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -323,3 +323,9 @@ label, version and environment; building requires that resolved toolchain and a streams text diagnostics, and returns a numeric exit code with collected output. The CLI still runs an explicit locked release build and stages its existing provenance receipt; no dependency, toolchain installation, runtime path or automatic-build behavior changes in this typing slice. + +The optional TUI activity, attention and rendering helpers and the packaged HTML template reader are +strict TypeScript. Readonly snapshots retain only bounded counters and diagnostic metadata; running +rows have no settlement facts. The injected React/UI seam preserves the host's node type without +loading a TUI or React dependency. Existing notification, session, pagination and HTML asset behavior +is preserved. Invalid result-payload shapes remain unconfirmed and never retain their raw bodies. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index d47e7656..1a201a46 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -255,3 +255,8 @@ PTC 桥复用类型化输入/输出检查,并采用宿主限制执行与 runne 源码构建辅助模块也采用严格 TypeScript。Cargo 解析返回可执行文件、来源、版本与环境;构建必须 提供已解析工具链及 crate 目录,流式输出文本诊断并返回数字退出码和收集输出。CLI 仍显式执行锁定 依赖的 release 构建并暂存原有来源回执;此类型迁移不改变依赖、安装、发布路径或自动构建行为。 + +可选 TUI 的活动、提醒、渲染辅助模块及 HTML 模板读取器采用严格 TypeScript。只读快照仅保留 +有界计数和诊断元数据,运行中记录没有完成事实;注入 React/UI 的边界保留宿主节点类型,不加载 +TUI 或 React 依赖。提醒、会话隔离、分页及 HTML 资源行为保持一致;非法结果形状保持未确认, +不保留原始正文。 diff --git a/src/panel/render.js b/src/panel/render.ts similarity index 92% rename from src/panel/render.js rename to src/panel/render.ts index 4d03e9e9..bf962e1c 100644 --- a/src/panel/render.js +++ b/src/panel/render.ts @@ -15,15 +15,14 @@ import { readFileSync } from 'node:fs' /** The published viewer asset, resolved from this module rather than the cwd. */ export const PANEL_TEMPLATE_URL = new URL('./panel.html', import.meta.url) -/** @type {string|undefined} */ -let cached +let cached: string | undefined /** * The viewer HTML: a static page that reads a JSON document and inlines none. * @returns {string} the template text. * @throws when the shipped asset cannot be read (packaging defect). */ -export function renderPanelHtml() { +export function renderPanelHtml(): string { if (cached === undefined) cached = readFileSync(PANEL_TEMPLATE_URL, 'utf8') return cached } diff --git a/src/tui/activity.js b/src/tui/activity.ts similarity index 52% rename from src/tui/activity.js rename to src/tui/activity.ts index 4ece2a85..3505df08 100644 --- a/src/tui/activity.js +++ b/src/tui/activity.ts @@ -1,33 +1,57 @@ /** Read-only projection of live DSH tool events; never executes or retains tool input/output. */ import { FILE_TOOLS, IN_PROCESS_TOOLS, publicToolName } from '../policy.js' import { PTC_TOOLS } from '../ptc/contract.js' -import { BUSINESS_OUTCOMES, resultDetails } from './result.js' +import type { BusinessOutcome, ResultDetails } from './result.js' +import { record, resultDetails } from './result.js' const OWN_TOOLS = new Set([...FILE_TOOLS, ...IN_PROCESS_TOOLS, ...PTC_TOOLS].map(publicToolName)) +export interface ActivityConfig { maxSessions: number; maxActive: number; recentLimit: number } +export type ActiveCall = Readonly<{ id: string; name: string; startedAt: number; status: 'running' }> +export type SettledCall = Readonly & ResultDetails & { status: 'error' | 'returned'; durationMs: number }> +export type ActivityRow = ActiveCall | SettledCall +export interface ActivitySnapshot { + readonly business: Readonly> + readonly total: number; readonly running: number; readonly returned: number; readonly errors: number + readonly limited: boolean; readonly since: number | undefined + readonly active: readonly ActiveCall[]; readonly recent: readonly SettledCall[] +} +export interface ActivityStore { + observe(sessionId: unknown, event: unknown): boolean + snapshot(sessionId?: string): ActivitySnapshot + subscribe(listener: () => void): () => void + dispose(): void +} +interface ActivityState { + seq: number; since: number; total: number; returned: number; errors: number; limited: boolean + active: Map; recent: SettledCall[]; snapshot: ActivitySnapshot + business: Record +} export const ACTIVITY_DEFAULTS = Object.freeze({ maxSessions: 32, maxActive: 128, recentLimit: 8 }) -const emptyBusiness = () => Object.fromEntries(BUSINESS_OUTCOMES.map(key => [key, 0])) -const EMPTY = Object.freeze({ business: Object.freeze(emptyBusiness()), total: 0, running: 0, returned: 0, errors: 0, limited: false, +const emptyBusiness = (): Record => ({ completed: 0, planned: 0, rejected: 0, error: 0, partial: 0, unknown: 0, unconfirmed: 0 }) +const EMPTY: ActivitySnapshot = Object.freeze({ business: Object.freeze(emptyBusiness()), total: 0, running: 0, returned: 0, errors: 0, limited: false, since: undefined, active: Object.freeze([]), recent: Object.freeze([]) }) -function validId(value) { return typeof value === 'string' && value.length > 0 && value.length <= 1024 } -function validTime(value) { return typeof value === 'number' && Number.isFinite(value) && value >= 0 } +function validId(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && value.length <= 1024 } +function validTime(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) && value >= 0 } /** Native and PTC results share transport facts, but a normal return is NOT business success. */ -function transportFailed(data, nested) { +function transportFailed(data: Record, nested: boolean): boolean { if (data.error !== undefined) return true if (nested) return data.isError === true - return data.message?.isError === true + return record(data.message) && data.message.isError === true } /** Validate retention settings shared by the optional observer and its bundle row. */ -export function resolveActivityConfig(options = {}) { - if (options === null || typeof options !== 'object' || Array.isArray(options)) throw new Error('TUI activity config must be an object') +export function resolveActivityConfig(options: unknown = {}): Readonly { + if (!record(options)) throw new Error('TUI activity config must be an object') for (const key of Object.keys(options)) if (!Object.hasOwn(ACTIVITY_DEFAULTS, key)) throw new Error('Unknown TUI activity config key: ' + key) - const config = { ...ACTIVITY_DEFAULTS, ...options } - for (const [key, ceiling] of [['maxSessions', 256], ['maxActive', 1024], ['recentLimit', 64]]) { - if (!Number.isSafeInteger(config[key]) || config[key] < 1 || config[key] > ceiling) { + const config: ActivityConfig = { ...ACTIVITY_DEFAULTS } + for (const [key, ceiling] of [['maxSessions', 256], ['maxActive', 1024], ['recentLimit', 64]] as const) { + const value = Object.hasOwn(options, key) ? options[key] : config[key] + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > ceiling) { throw new Error(key + ' must be an integer from 1 to ' + ceiling) } + config[key] = value } return Object.freeze(config) } @@ -37,12 +61,12 @@ export function resolveActivityConfig(options = {}) { * At capacity, new active calls are omitted and limited is set; existing calls still settle. * Session LRU eviction drops statistics only, never running work. Call IDs are opaque. */ -export function createActivityStore(options = {}) { +export function createActivityStore(options: Partial = {}): ActivityStore { const config = resolveActivityConfig(options) - const sessions = new Map(), listeners = new Set() + const sessions = new Map(), listeners = new Set<() => void>() let disposed = false - function publish(state) { + function publish(state: ActivityState): void { state.snapshot = Object.freeze({ business: Object.freeze({ ...state.business }), total: state.total, running: state.active.size, returned: state.returned, errors: state.errors, limited: state.limited, since: state.since, active: Object.freeze([...state.active.values()]), recent: Object.freeze([...state.recent]) }) @@ -51,27 +75,34 @@ export function createActivityStore(options = {}) { } } - function observe(sessionId, event) { - if (disposed || !validId(sessionId) || !event || !Number.isSafeInteger(event.seq) || event.seq < 0 || !validTime(event.time)) return false + function observe(sessionId: unknown, event: unknown): boolean { + if (disposed || !validId(sessionId) || !record(event) || typeof event.seq !== 'number' || !Number.isSafeInteger(event.seq) || event.seq < 0 || !validTime(event.time)) return false const data = event.data - if (!data || typeof data !== 'object') return false + if (!record(data)) return false + if (typeof event.type !== 'string') return false const start = event.type === 'tool/call' || event.type === 'tool/ptc-dispatch-start' const settle = event.type === 'tool/result' || event.type === 'tool/ptc-dispatch' if (!start && !settle) return false const nested = event.type.startsWith('tool/ptc-') - const id = nested ? data.subCallId : start ? data.callId : data.message?.source?.callId + const message = record(data.message) ? data.message : undefined + const source = record(message?.source) ? message.source : undefined + const id = nested ? data.subCallId : start ? data.callId : source?.callId + const ownName = typeof data.name === 'string' && OWN_TOOLS.has(data.name) if (!validId(id)) return false let state = sessions.get(sessionId) // Only tracked native results belong to us; do not allocate state for arbitrary tools. - if ((start || nested) && !OWN_TOOLS.has(data.name)) return false + if ((start || nested) && !ownName) return false if (!start && !state?.active.has(id)) { - if (state && event.seq > state.seq && (nested && OWN_TOOLS.has(data.name))) { + if (state && event.seq > state.seq && (nested && ownName)) { state.seq = event.seq; state.limited = true; publish(state) } return false } if (!state) { - if (sessions.size >= config.maxSessions) sessions.delete(sessions.keys().next().value) + if (sessions.size >= config.maxSessions) { + const oldest = sessions.keys().next().value + if (oldest !== undefined) sessions.delete(oldest) + } state = { seq: -1, since: event.time, total: 0, returned: 0, errors: 0, limited: false, active: new Map(), recent: [], snapshot: EMPTY, business: emptyBusiness() } sessions.set(sessionId, state) @@ -80,12 +111,14 @@ export function createActivityStore(options = {}) { state.seq = event.seq sessions.delete(sessionId); sessions.set(sessionId, state) if (start) { + if (typeof data.name !== 'string') return false if (state.active.has(id)) return false if (state.active.size >= config.maxActive) { state.limited = true; publish(state); return false } state.active.set(id, Object.freeze({ id, name: data.name, startedAt: event.time, status: 'running' })) state.total += 1 } else { const call = state.active.get(id) + if (!call) return false if (nested && call.name !== data.name) return false state.active.delete(id) const failed = transportFailed(data, nested) @@ -102,8 +135,8 @@ export function createActivityStore(options = {}) { return Object.freeze({ observe, - snapshot: sessionId => sessions.get(sessionId)?.snapshot ?? EMPTY, - subscribe(listener) { + snapshot: (sessionId?: string) => (sessionId === undefined ? undefined : sessions.get(sessionId)?.snapshot) ?? EMPTY, + subscribe(listener: () => void) { if (disposed) return () => {} listeners.add(listener) return () => listeners.delete(listener) diff --git a/src/tui/attention.js b/src/tui/attention.ts similarity index 54% rename from src/tui/attention.js rename to src/tui/attention.ts index c480c0b8..46b95e07 100644 --- a/src/tui/attention.js +++ b/src/tui/attention.ts @@ -1,17 +1,30 @@ /** Constant-memory aggregate notifications; never a current-session status or issue ledger. */ -const EMPTY = Object.freeze({ unread: 0, capped: false, level: undefined }) +export type AttentionLevel = 'error' | 'warning' +export interface AttentionSnapshot { readonly unread: number; readonly capped: boolean; readonly level: AttentionLevel | undefined } +export interface AttentionStore { + snapshot(): AttentionSnapshot + observe(row: unknown): boolean + acknowledge(): boolean + subscribe(listener: () => void): () => void + dispose(): void +} +const EMPTY: AttentionSnapshot = Object.freeze({ unread: 0, capped: false, level: undefined }) export const ALERT_CAP = 999 -export function anomalyLevel(row) { +function rowFields(row: unknown): row is { status?: unknown; outcome?: unknown } { + return row !== null && typeof row === 'object' +} +export function anomalyLevel(row: unknown): AttentionLevel | undefined { + if (!rowFields(row)) return undefined if (row?.status === 'error' || row?.outcome === 'error' || row?.outcome === 'unknown') return 'error' if (row?.outcome === 'rejected' || row?.outcome === 'partial') return 'warning' } /** Record only newly accepted settlements. Deduplication belongs to the activity store. */ -export function createAttentionStore() { - const listeners = new Set() +export function createAttentionStore(): AttentionStore { + const listeners = new Set<() => void>() let snapshot = EMPTY, disposed = false - function publish(next) { + function publish(next: AttentionSnapshot): void { snapshot = Object.freeze(next) for (const listener of [...listeners]) { try { listener() } catch { /* Notification failures cannot alter tool results. */ } @@ -19,10 +32,10 @@ export function createAttentionStore() { } return Object.freeze({ snapshot: () => snapshot, - observe(row) { + observe(row: unknown) { const level = anomalyLevel(row) if (disposed || !level) return false - const next = { unread: Math.min(ALERT_CAP, snapshot.unread + 1), capped: snapshot.capped || snapshot.unread === ALERT_CAP, + const next: AttentionSnapshot = { unread: Math.min(ALERT_CAP, snapshot.unread + 1), capped: snapshot.capped || snapshot.unread === ALERT_CAP, level: snapshot.level === 'error' ? 'error' : level } // Even at the cap, a new anomaly must relight a host-cleared tab indicator. publish(next) @@ -33,7 +46,7 @@ export function createAttentionStore() { publish(EMPTY) return true }, - subscribe(listener) { + subscribe(listener: () => void) { if (disposed) return () => {} listeners.add(listener) return () => listeners.delete(listener) diff --git a/src/tui/panel.js b/src/tui/panel.ts similarity index 70% rename from src/tui/panel.js rename to src/tui/panel.ts index 798cbd89..9e9aaf5d 100644 --- a/src/tui/panel.js +++ b/src/tui/panel.ts @@ -1,7 +1,40 @@ /** Host React/UI only: no runtime TUI/React imports, timers, commands or filesystem access. */ import { anomalyLevel } from './attention.js' +import type { AttentionStore, AttentionSnapshot } from './attention.js' +import type { ActivitySnapshot, ActivityRow, ActivityStore } from './activity.js' -export const ACTIVITY_FILTERS = Object.freeze(['all', 'running', 'anomalies']) +export type ActivityFilter = typeof ACTIVITY_FILTERS[number] +export interface ActivityViewOptions { filter?: ActivityFilter; page?: number; alert?: AttentionSnapshot } +export interface ActivityView { + filter: ActivityFilter; page: number; pageCount: number; rowCount: number; lines: string[] +} +export interface PanelKeyEvent { + input: string + key: { ctrl?: boolean; meta?: boolean; shift?: boolean; pageDown?: boolean; pageUp?: boolean } + preventDefault(): void +} +export interface PanelHost { + readonly focused: boolean + snapshot(): { sessionId?: string; lang?: string } + onKey(listener: (event: PanelKeyEvent) => void): () => void +} +/** The optional host supplies React and UI components; importing this module loads neither. */ +export interface PanelReact { + useSyncExternalStore(subscribe: (listener: () => void) => () => void, snapshot: () => T): T + useState(initial: T): [T, (value: T) => void] + useEffect(effect: () => void | (() => void), dependencies: readonly unknown[]): void + createElement(component: Component, props: Record, ...children: (Node | string)[]): Node +} +export interface OpsPanelProps { + React: PanelReact + ui: { Box: Component; Text: Component } + host: PanelHost + width: number; height: number +} +interface Selection { sessionId?: string; filter: ActivityFilter; page: number } + + +export const ACTIVITY_FILTERS = Object.freeze(['all', 'running', 'anomalies'] as const) const COPY = { en: { window: 'This session · since enabled', empty: 'No OPS calls observed yet', noSession: 'No active session', calls: 'Calls', running: 'Running', returned: 'Returned', errors: 'Tool errors', limited: 'Tracking limit reached; incomplete', @@ -22,10 +55,11 @@ const COPY = { status: { running: '运行中', returned: '已返回', error: '业务/执行错误', completed: '完成', planned: '预览/计划', rejected: '拒绝', partial: '部分失败', unknown: '副作用未知', unconfirmed: '业务状态未确认' } }, } -const copyFor = lang => COPY[lang.startsWith('zh') ? 'zh' : 'en'] -const alertLines = (alert, text) => alert?.unread ? [text.alerts + ' ' + alert.unread + (alert.capped ? '+' : '')] : [] +type Copy = typeof COPY.en +const copyFor = (lang: string): Copy => COPY[lang.startsWith('zh') ? 'zh' : 'en'] +const alertLines = (alert: AttentionSnapshot | undefined, text: Copy): string[] => alert?.unread ? [text.alerts + ' ' + alert.unread + (alert.capped ? '+' : '')] : [] -function summaryLines(snapshot, text) { +function summaryLines(snapshot: ActivitySnapshot, text: Copy): string[] { const lines = [text.window], outcomes = snapshot.business if (snapshot.limited) lines.push(text.limited) if (outcomes.rejected || outcomes.unknown) lines.push(text.rejected + ' ' + outcomes.rejected + ' · ' + text.unknown + ' ' + outcomes.unknown) @@ -34,7 +68,7 @@ function summaryLines(snapshot, text) { text.returned + ' ' + snapshot.returned + ' · ' + text.errors + ' ' + snapshot.errors) return lines } -function rowLines(row, text) { +function rowLines(row: ActivityRow, text: Copy): string[] { if (row.status === 'running') return [row.name, ' ' + text.status.running] const outcome = row.status === 'error' && row.outcome !== 'unknown' ? 'error' : row.outcome ?? row.status const lines = [row.name, ' ' + text.status[outcome] + ' · ' + row.durationMs + ' ms'] @@ -47,10 +81,10 @@ function rowLines(row, text) { } /** Keep a record together when it fits; long diagnostics continue with their tool name. */ -function paginate(rows, capacity, text) { +function paginate(rows: readonly ActivityRow[], capacity: number, text: Copy): string[][] { if (capacity < 1) return [[]] - const pages = [] - let current = [] + const pages: string[][] = [] + let current: string[] = [] const flush = () => { if (current.length) pages.push(current); current = [] } for (const row of rows) { const lines = rowLines(row, text) @@ -75,30 +109,30 @@ function paginate(rows, capacity, text) { } /** Local pages of bounded retained metadata, never result pages or session-history reads. */ -export function activityView(snapshot, lang = 'en', height = 24, options = {}) { +export function activityView(snapshot: ActivitySnapshot, lang = 'en', height = 24, options: ActivityViewOptions = {}): ActivityView { const text = copyFor(lang), size = Number.isFinite(height) ? Math.max(0, Math.floor(height)) : 0 - const filter = ACTIVITY_FILTERS.includes(options.filter) ? options.filter : 'all' + const filter = options.filter !== undefined && ACTIVITY_FILTERS.includes(options.filter) ? options.filter : 'all' const rows = [...snapshot.active, ...snapshot.recent].filter(row => filter === 'all' || (filter === 'running' ? row.status === 'running' : anomalyLevel(row) !== undefined)) const header = [...alertLines(options.alert, text), ...summaryLines(snapshot, text)] const capacity = Math.max(0, size - header.length - 3) const pages = paginate(rows, capacity, text), pageCount = pages.length - const page = Math.max(0, Math.min(pageCount - 1, Number.isSafeInteger(options.page) ? options.page : 0)) + const page = Math.max(0, Math.min(pageCount - 1, typeof options.page === 'number' && Number.isSafeInteger(options.page) ? options.page : 0)) const body = rows.length ? pages[page] : [snapshot.total ? text.noMatch : text.empty].slice(0, capacity) const footer = [text.filters[filter] + ' · ' + (page + 1) + '/' + pageCount + ' · ' + text.kept + ' ' + rows.length, text.keys + (options.alert?.unread ? text.ackKey : ''), text.neutral] const padding = Array(Math.max(0, capacity - body.length)).fill('') return { filter, page, pageCount, rowCount: rows.length, lines: [...header, ...body, ...padding, ...footer].slice(0, size) } } -export function activityLines(snapshot, lang = 'en', height = 24) { return activityView(snapshot, lang, height).lines } +export function activityLines(snapshot: ActivitySnapshot, lang = 'en', height = 24): string[] { return activityView(snapshot, lang, height).lines } -export function createOpsPanel(store, attention) { - return function OpsPanel({ React, ui, host, width, height }) { +export function createOpsPanel(store: ActivityStore, attention: AttentionStore) { + return function OpsPanel({ React, ui, host, width, height }: OpsPanelProps): Node { const context = host.snapshot(), sessionId = context.sessionId, lang = context.lang ?? 'en', text = copyFor(lang) const snapshot = React.useSyncExternalStore(store.subscribe, () => store.snapshot(sessionId)) const alert = React.useSyncExternalStore(attention.subscribe, attention.snapshot) - const [selection, setSelection] = React.useState({ sessionId, filter: 'all', page: 0 }) - const selected = selection.sessionId === sessionId ? selection : { filter: 'all', page: 0 } + const [selection, setSelection] = React.useState({ sessionId, filter: 'all', page: 0 }) + const selected: Selection = selection.sessionId === sessionId ? selection : { filter: 'all', page: 0 } const view = activityView(snapshot, lang, height, { ...selected, alert }) React.useEffect(() => { if (selection.sessionId !== sessionId) setSelection({ sessionId, filter: 'all', page: 0 }) diff --git a/src/tui/result.js b/src/tui/result.js deleted file mode 100644 index 7a82e347..00000000 --- a/src/tui/result.js +++ /dev/null @@ -1,35 +0,0 @@ -/** Read only the six OPS native JSON envelopes, never shell stdout or arbitrary tool prose. */ -import { FILE_TOOLS, publicToolName } from '../policy.js' -import { PTC_TOOLS } from '../ptc/contract.js' -const JSON_TOOLS = new Set([...FILE_TOOLS, ...PTC_TOOLS].map(publicToolName)) -const MAX_JSON_BYTES = 64 * 1024 -const UNCONFIRMED = Object.freeze({ outcome: 'unconfirmed' }) -export const BUSINESS_OUTCOMES = Object.freeze(['completed', 'planned', 'rejected', 'error', 'partial', 'unknown', 'unconfirmed']) -const integer = value => Number.isSafeInteger(value) && value >= 0 - -export function resultDetails(tool, data, nested) { - if (!JSON_TOOLS.has(tool)) return UNCONFIRMED - const content = nested ? data.content : data.message?.content - // Multiple blocks can be a spill preview plus locator, not the original JSON envelope. - if (!Array.isArray(content) || content.length !== 1 || content[0]?.type !== 'text') return UNCONFIRMED - const text = content[0].text - if (typeof text !== 'string' || text.length > MAX_JSON_BYTES || Buffer.byteLength(text, 'utf8') > MAX_JSON_BYTES) return UNCONFIRMED - let value - try { value = JSON.parse(text) } catch { return UNCONFIRMED } - if (!value || typeof value !== 'object' || Array.isArray(value)) return UNCONFIRMED - const summary = {} - for (const key of ['done', 'total', 'failed', 'unknown', 'omitted', 'nextOffset']) { - if (integer(value[key])) summary[key] = value[key] - } - for (const key of ['complete', 'dryRun']) if (typeof value[key] === 'boolean') summary[key] = value[key] - const code = value.error?.code - if (typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(code)) summary.errorCode = code - const outcome = value.error?.effectState === 'unknown' || (summary.unknown ?? 0) > 0 || value.status === 'unknown' ? 'unknown' - : value.status === 'rejected' ? 'rejected' - : value.status === 'error' || value.status === 'failed' ? 'error' - : (summary.failed ?? 0) > 0 || value.status === 'partial' ? 'partial' - : value.status === 'planned' || (value.status === 'completed' && value.dryRun === true) ? 'planned' - : value.status === 'completed' ? 'completed' : 'unconfirmed' - const hostFailed = data.error !== undefined || (nested ? data.isError === true : data.message?.isError === true) - return Object.freeze({ outcome: hostFailed && (outcome === 'completed' || outcome === 'planned') ? 'unconfirmed' : outcome, ...summary }) -} diff --git a/src/tui/result.ts b/src/tui/result.ts new file mode 100644 index 00000000..2beba499 --- /dev/null +++ b/src/tui/result.ts @@ -0,0 +1,47 @@ +/** Read only the six OPS native JSON envelopes, never shell stdout or arbitrary tool prose. */ +import { FILE_TOOLS, publicToolName } from '../policy.js' +import { PTC_TOOLS } from '../ptc/contract.js' +const JSON_TOOLS = new Set([...FILE_TOOLS, ...PTC_TOOLS].map(publicToolName)) +const MAX_JSON_BYTES = 64 * 1024 +export const BUSINESS_OUTCOMES = Object.freeze(['completed', 'planned', 'rejected', 'error', 'partial', 'unknown', 'unconfirmed'] as const) +export type BusinessOutcome = typeof BUSINESS_OUTCOMES[number] +interface ResultSummary { + done?: number; total?: number; failed?: number; unknown?: number; omitted?: number; nextOffset?: number + complete?: boolean; dryRun?: boolean; errorCode?: string +} +export type ResultDetails = Readonly +const UNCONFIRMED: ResultDetails = Object.freeze({ outcome: 'unconfirmed' }) +const integer = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +/** Event payloads are observed defensively, never retained as activity metadata. */ +export function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export function resultDetails(tool: string, data: unknown, nested: boolean): ResultDetails { + if (!JSON_TOOLS.has(tool) || !record(data)) return UNCONFIRMED + const message = record(data.message) ? data.message : undefined + const content = nested ? data.content : message?.content + // Multiple blocks can be a spill preview plus locator, not the original JSON envelope. + if (!Array.isArray(content) || content.length !== 1 || !record(content[0]) || content[0].type !== 'text') return UNCONFIRMED + const text = content[0].text + if (typeof text !== 'string' || text.length > MAX_JSON_BYTES || Buffer.byteLength(text, 'utf8') > MAX_JSON_BYTES) return UNCONFIRMED + let value: unknown + try { value = JSON.parse(text) } catch { return UNCONFIRMED } + if (!record(value)) return UNCONFIRMED + const summary: ResultSummary = {} + for (const key of ['done', 'total', 'failed', 'unknown', 'omitted', 'nextOffset'] as const) { + if (integer(value[key])) summary[key] = value[key] + } + for (const key of ['complete', 'dryRun'] as const) if (typeof value[key] === 'boolean') summary[key] = value[key] + const error = record(value.error) ? value.error : undefined + const code = error?.code + if (typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(code)) summary.errorCode = code + const outcome: BusinessOutcome = error?.effectState === 'unknown' || (summary.unknown ?? 0) > 0 || value.status === 'unknown' ? 'unknown' + : value.status === 'rejected' ? 'rejected' + : value.status === 'error' || value.status === 'failed' ? 'error' + : (summary.failed ?? 0) > 0 || value.status === 'partial' ? 'partial' + : value.status === 'planned' || (value.status === 'completed' && value.dryRun === true) ? 'planned' + : value.status === 'completed' ? 'completed' : 'unconfirmed' + const hostFailed = data.error !== undefined || (nested ? data.isError === true : message?.isError === true) + return Object.freeze({ outcome: hostFailed && (outcome === 'completed' || outcome === 'planned') ? 'unconfirmed' : outcome, ...summary }) +} diff --git a/test/tui-result.test.mjs b/test/tui-result.test.mjs index b0872115..5700b954 100644 --- a/test/tui-result.test.mjs +++ b/test/tui-result.test.mjs @@ -72,4 +72,9 @@ await test('only bounded codes, booleans and safe nonnegative counters survive p assert.ok(Object.isFrozen(detail)) assert.equal(inspect({ error: { code: 'bad\ncode' } }).errorCode, undefined) }) +await test('untrusted result payload shapes remain unconfirmed without retaining or throwing on them', () => { + for (const data of [null, undefined, false, 1, 'PRIVATE_BODY', [], { message: null }, { message: { content: [null] } }]) { + assert.deepEqual(resultDetails('ops_grep', data, false), { outcome: 'unconfirmed' }) + } +}) report('TUI result diagnostics') diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json index 9f3a6cec..b9bb3e92 100644 --- a/test/types/tsconfig.json +++ b/test/types/tsconfig.json @@ -7,6 +7,7 @@ "include": [ "kernel.ts", "ptc-sdk.ts", - "worker-runtime.ts" + "worker-runtime.ts", + "tui.ts" ] } diff --git a/test/types/tui.ts b/test/types/tui.ts new file mode 100644 index 00000000..f49e2f8d --- /dev/null +++ b/test/types/tui.ts @@ -0,0 +1,56 @@ +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { BusinessOutcome } from '../../src/tui/result.js' +import type { ActivityRow, ActivitySnapshot } from '../../src/tui/activity.js' +import type { OpsPanelProps, PanelKeyEvent } from '../../src/tui/panel.js' +import { createActivityStore, resolveActivityConfig } from '../../src/tui/activity.js' +import { createAttentionStore } from '../../src/tui/attention.js' +import { resultDetails } from '../../src/tui/result.js' +import { activityView, createOpsPanel } from '../../src/tui/panel.js' +import { renderPanelHtml } from '../../src/panel/render.js' + +declare const event: SessionEvent +const config = resolveActivityConfig({ recentLimit: 4 }) +const store = createActivityStore(config), attention = createAttentionStore() +const accepted: boolean = store.observe('session', event) +const snapshot: ActivitySnapshot = store.snapshot('session') +const outcome: BusinessOutcome = resultDetails('tool', null, false).outcome +const unsubscribe: () => void = store.subscribe(() => {}) +const html: string = renderPanelHtml() +// @ts-expect-error Retention settings must be numeric, even though raw config parsing accepts unknown. +void createActivityStore({ maxSessions: '32' }) +// @ts-expect-error An immutable snapshot cannot modify observer counters. +snapshot.total++ +// @ts-expect-error Business counters are immutable as well. +snapshot.business.completed = 0 +// @ts-expect-error Caller code cannot append retained rows. +snapshot.recent.push(snapshot.recent[0]) +// @ts-expect-error Raw arguments are not part of retained metadata. +void snapshot.recent[0].arguments +// @ts-expect-error Raw content is not part of retained metadata. +void snapshot.recent[0].content +// @ts-expect-error Attention is acknowledged through the store, not by rewriting a snapshot. +attention.snapshot().unread = 0 +// @ts-expect-error The typed renderer only selects declared activity filters. +void activityView(snapshot, 'en', 24, { filter: 'partial' }) +declare const row: ActivityRow +if (row.status === 'running') { + // @ts-expect-error A running call has no settled duration. + void row.durationMs + // @ts-expect-error A running call cannot claim business completion. + void row.outcome +} else { + const duration: number = row.durationMs + const settled: BusinessOutcome = row.outcome + void [duration, settled] +} +type Rendered = { readonly rendered: true } +declare const props: OpsPanelProps +const panel = createOpsPanel(store, attention) +const element: Rendered = panel(props) +// @ts-expect-error The renderer's size comes from numeric host dimensions. +void panel({ ...props, width: '28' }) +// @ts-expect-error The return node preserves the injected renderer's type. +const invalid: boolean = panel(props) +props.React.useSyncExternalStore(store.subscribe, () => store.snapshot()) +props.host.onKey((key: PanelKeyEvent) => { if (key.input === 'n') key.preventDefault() }) +void [accepted, outcome, unsubscribe, html, element, invalid] From f1005143a0390b850e39892db4856fee90825497 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:45:21 +0800 Subject: [PATCH 071/118] feat(settings): route runtime actions through the host gateway --- docs/CHANGELOG.md | 4 + docs/configuration-reference.md | 9 + ...15\347\275\256\345\217\202\350\200\203.md" | 7 + package-lock.json | 97 +++++++++ package.json | 3 + src/components/shell/index.ts | 6 +- src/settings/runtime-actions.ts | 136 +++++++++++++ src/settings/runtime-contract.ts | 55 ++++++ src/settings/runtime-host.ts | 34 ++++ test/settings-runtime.test.mjs | 187 ++++++++++++++++++ 10 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 src/settings/runtime-actions.ts create mode 100644 src/settings/runtime-contract.ts create mode 100644 src/settings/runtime-host.ts create mode 100644 test/settings-runtime.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 74b29dc1..a408d081 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Connect runtime settings actions to the host gateway (#261) + +- Add typed, package-owned gateway descriptors for runtime inventory, Bash selection and explicit install/remove/PowerShell-upgrade previews. Execute only current one-use plans through host subprocess governance; preserve cancellation and partial-effect facts. +- 通过宿主网关提供运行时清单、Bash 选择和显式安装/删除/PowerShell 升级预览;仅执行有效的一次性计划,并保留取消和部分副作用信息。 ### Validate and persist host settings forms (#261) - Expose shell executor, discovery pool/always-loaded and per-tool replacement fields through detached settings descriptors and existing validators. Accepted changes persist through the host profile writer and reload the row; stale/invalid writes preserve the profile. diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 7df07e71..b7d5d8f2 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -162,3 +162,12 @@ settings service. Saving uses the profile revision the form read; a stale revisi selection is rejected before writing. The host persists accepted changes and reloads the affected row, so the running configuration follows the saved selection. Ordinary row fields are preserved. A disabled row has no editable form; re-enable it with the host's built-in row switch first. + +Runtime actions use the host's authenticated gateway and managed subprocess service. Installation, +removal and upgrade require a preview followed by execution of that current preview; expired, +changed or reused plans are refused. Only one action runs at a time. Closing/unloading the owning +shell component cancels outstanding work and waits for settlement; cancellation after launch may +leave changes and reports an unknown effect. Bash install refuses a store containing unowned +version directories. The PowerShell action targets the standard WinGet installation and requires +a DSH restart; an explicit host `pwshPath` remains authoritative. Installer output stays in host +logs; use the previewed command in a terminal if WinGet needs interactive input. diff --git "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" index 5270d254..60b64222 100644 --- "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" +++ "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" @@ -154,3 +154,10 @@ Cordis 通过 `StandardSchemaV1` 校验配置。在固定版本 DSH 0.2.0-rc.2 保存使用表单读取时的配置版本;版本过期或选项无效时,在写入前拒绝更改。 宿主持久化成功后重新加载对应行,使运行中的设置与已保存选择一致,同时保留其他行字段。 禁用的行没有可编辑表单;请先使用宿主内置的行开关重新启用。 + +运行时操作使用宿主已认证的网关及受管理的子进程服务。安装、删除和升级必须先预览, +再执行仍然有效的预览;过期、内容变化或已使用的计划会被拒绝。同一时间只执行一个操作。 +卸载对应 shell 组件会取消尚未完成的操作并等待结束;启动后取消可能已造成更改,副作用标为未知。 +Bash 安装拒绝包含无归属版本目录的存储。PowerShell 操作更新标准 WinGet 安装,随后需要重启 +DSH;宿主显式设置的 `pwshPath` 仍然优先。安装器输出留在宿主日志中;如果 WinGet 需要交互输入, +请在终端运行预览中显示的命令。 diff --git a/package-lock.json b/package-lock.json index 64cf5a64..92d8b1aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@deepseek-ai/cordis-plugin-loader": "1.0.5", "@deepseek-ai/cosmokit": "1.8.5", "@deepseek-ai/dsh-agent-loop": "0.2.0-rc.2", + "@deepseek-ai/dsh-api-gateway": "0.2.0-rc.2", "@deepseek-ai/dsh-app-boot": "0.2.0-rc.2", "@deepseek-ai/dsh-config-editor": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-local": "0.2.0-rc.2", @@ -41,6 +42,8 @@ "@deepseek-ai/dsh-tool-fs": "0.2.0-rc.2", "@deepseek-ai/dsh-tool-fs-search": "0.2.0-rc.2", "@deepseek-ai/dsh-tools": "0.2.0-rc.2", + "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2", + "@deepseek-ai/dsh-typert-registry": "0.2.0-rc.2", "@deepseek-ai/schemastery": "3.18.4", "@dsh-std/manifest": "0.1.1-rc.3", "@types/node": "22.20.5", @@ -196,6 +199,24 @@ "@deepseek-ai/dsh-tools": "0.2.0-rc.2" } }, + "node_modules/@deepseek-ai/dsh-api-gateway": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-api-gateway/-/dsh-api-gateway-0.2.0-rc.2.tgz", + "integrity": "sha512-syeclzuoVpGor6JyDHcxg2Iwy9oeM76y4dK8abY19x1s/eMgJMCO3kL/IeKPg+d0Go7aHPTEyWRvCmfkA35U1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-deque": "0.2.0-rc.2", + "@deepseek-ai/dsh-timeout": "0.2.0-rc.2", + "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2", + "@deepseek-ai/schemastery": "~3.18.4", + "ws": "^8.21.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-client-connection": "0.2.0-rc.2" + } + }, "node_modules/@deepseek-ai/dsh-app-boot": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-app-boot/-/dsh-app-boot-0.2.0-rc.2.tgz", @@ -255,6 +276,23 @@ "@deepseek-ai/cordis": "~4.0.4" } }, + "node_modules/@deepseek-ai/dsh-client-connection": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-client-connection/-/dsh-client-connection-0.2.0-rc.2.tgz", + "integrity": "sha512-eL66YSZ0+HlWRzAPGU8jtCLd322WXBoMGN1g1OsM5T7Skqbbo9hSwV1FO3Lm0IcMZR3Y07sGPQBwZwf/l66T3w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/dsh-credentials": "0.2.0-rc.2", + "@deepseek-ai/schemastery": "~3.18.4", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-scope": "0.2.0-rc.2" + } + }, "node_modules/@deepseek-ai/dsh-config-editor": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-config-editor/-/dsh-config-editor-0.2.0-rc.2.tgz", @@ -279,6 +317,30 @@ } } }, + "node_modules/@deepseek-ai/dsh-credentials": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-credentials/-/dsh-credentials-0.2.0-rc.2.tgz", + "integrity": "sha512-TQlvP0jxXYGy1ZNzh4nVsiCzb8FR9iGarD6BfIZ3LwJC8lDtjSxQHWE8//NYjpn10TIMxmaR4s6niHuJ3JkTOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-brand": "0.2.0-rc.2" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-invariants": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-deque": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-deque/-/dsh-deque-0.2.0-rc.2.tgz", + "integrity": "sha512-XwZvafZAxyDm2TRnQetEizuczZ1UG7RhO8vmmIUbAdjbRbMA15NOTlJ3d24uw+I8gcSKKkmWl/jwoKYKl/qo/A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, "node_modules/@deepseek-ai/dsh-fs": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs/-/dsh-fs-0.2.0-rc.2.tgz", @@ -783,6 +845,19 @@ "@deepseek-ai/cordis": "~4.0.4" } }, + "node_modules/@deepseek-ai/dsh-typert-registry": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-typert-registry/-/dsh-typert-registry-0.2.0-rc.2.tgz", + "integrity": "sha512-Hjepcu0cDTH4rz/SB121SVozg9ix79NBB+HNr0jW0GSHG9O24kNDyufAJD2/S4B97KVN+1nM0T8k1Dx2JSOEWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, "node_modules/@deepseek-ai/dsh-user-approval": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-user-approval/-/dsh-user-approval-0.2.0-rc.2.tgz", @@ -1912,6 +1987,28 @@ "dev": true, "license": "MIT" }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yaml": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", diff --git a/package.json b/package.json index 40733d1e..4f1e33c1 100644 --- a/package.json +++ b/package.json @@ -161,6 +161,7 @@ "@deepseek-ai/cordis-plugin-loader": "1.0.5", "@deepseek-ai/cosmokit": "1.8.5", "@deepseek-ai/dsh-agent-loop": "0.2.0-rc.2", + "@deepseek-ai/dsh-api-gateway": "0.2.0-rc.2", "@deepseek-ai/dsh-app-boot": "0.2.0-rc.2", "@deepseek-ai/dsh-config-editor": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-local": "0.2.0-rc.2", @@ -175,6 +176,8 @@ "@deepseek-ai/dsh-tool-fs": "0.2.0-rc.2", "@deepseek-ai/dsh-tool-fs-search": "0.2.0-rc.2", "@deepseek-ai/dsh-tools": "0.2.0-rc.2", + "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2", + "@deepseek-ai/dsh-typert-registry": "0.2.0-rc.2", "@deepseek-ai/schemastery": "3.18.4", "@dsh-std/manifest": "0.1.1-rc.3", "@types/node": "22.20.5", diff --git a/src/components/shell/index.ts b/src/components/shell/index.ts index f60d6aff..af7c482a 100644 --- a/src/components/shell/index.ts +++ b/src/components/shell/index.ts @@ -1,5 +1,6 @@ import type { Context } from '@deepseek-ai/cordis' import { applyComponent } from '../../index.js' +import { mountRuntimeSettings } from '../../settings/runtime-host.js' import { resolveConfig } from '../../config.js' import { editable, settingsConfig, configObject } from '../../settings/schema.js' export const name = 'dsh-ops-bash & host PowerShell' @@ -11,4 +12,7 @@ export const Config = settingsConfig(value => { bashPath: editable('string', 'Absolute Bash executor path. Remove the override to select the optional managed installation.'), publishBashTool: editable('boolean', 'Publish Bash for authorized sessions.'), }) -export function apply(ctx: Context, config: unknown): void { applyComponent(ctx, config, 'shell') } +export function apply(ctx: Context, config: unknown): void { + applyComponent(ctx, config, 'shell') + mountRuntimeSettings(ctx) +} diff --git a/src/settings/runtime-actions.ts b/src/settings/runtime-actions.ts new file mode 100644 index 00000000..6d695422 --- /dev/null +++ b/src/settings/runtime-actions.ts @@ -0,0 +1,136 @@ +/** Operator runtime plans: fixed actions, one-use previews, serialized execution. */ +import { randomUUID, createHash } from 'node:crypto' +import path from 'node:path' +import type { TypertGatewayBinding } from '@deepseek-ai/dsh-typert-protocol' +import { PACKAGE_ROOT } from '../paths.js' +import { gitBashCandidates, managedBashInventory, removeManagedBash, selectBashRuntime } from '../shells/manager.js' +import { shellUpstreamPin, provisionedShellVersionDir } from '../shells/pins.js' +import { hostPwshUpgradePlan, upgradeHostPwsh } from '../shells/pwsh-upgrade.js' +import type { UpgradeProcessResult } from '../shells/pwsh-upgrade.js' +import { parseAction, parseSource } from './runtime-contract.js' +import type { RuntimeAction, RuntimePlan, RuntimeResult, RuntimeSnapshot, RuntimeSelection, BashSource } from './runtime-contract.js' + +export type RuntimeProcessRunner = (argv: readonly string[], signal: AbortSignal) => Promise +export interface RuntimeActionOptions { + run: RuntimeProcessRunner + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + arch?: string + now?: () => number +} +type Preview = Omit +const failure = (status: RuntimeResult['status'], message: string, effectState: RuntimeResult['effectState'] = 'none'): RuntimeResult => ({ status, message, effectState }) +const digest = (value: unknown): string => createHash('sha256').update(JSON.stringify(value)).digest('hex') + +export class RuntimeActions { + readonly typertRemote: TypertGatewayBinding = Object.freeze({ service: this, serviceKey: 'opsSettings', namespace: 'opsSettings' }) + private readonly options: Required> & Pick + private readonly plans = new Map() + private readonly lifetime = new AbortController() + private busy = false + private readonly active = new Set>() + constructor(options: RuntimeActionOptions) { + this.options = { env: process.env, platform: process.platform, arch: process.arch, now: Date.now, ...options } + } + snapshot(): RuntimeSnapshot { + const powerShell = hostPwshUpgradePlan(this.options) + return { platform: this.options.platform, managed: managedBashInventory(this.options), git: gitBashCandidates(this.options), + powerShell: { supported: powerShell.supported, guidance: powerShell.guidance, + ...(powerShell.unavailable ? { unavailable: powerShell.unavailable } : {}) } } + } + select(source: BashSource): RuntimeSelection { + const selected = selectBashRuntime(parseSource(source), this.options) + return { bashPath: selected.file, restriction: selected.restricted.compatible + ? 'This Bash has verified restricted-mode IPC compatibility.' + : String(selected.restricted.reason ?? 'Restricted modes cannot use this Bash. A supported sandbox/executor remains required.') } + } + private preview(action: RuntimeAction): { public: Preview; fingerprint: string } { + let preview: Preview, facts: unknown + if (action === 'upgrade-pwsh') { + const plan = hostPwshUpgradePlan(this.options) + facts = plan + preview = { action, available: plan.supported, details: [plan.guidance, + ...(plan.file ? [[plan.file, ...plan.args].join(' ')] : [])], ...(plan.unavailable ? { reason: plan.unavailable } : {}) } + } else if (action === 'remove-bash') { + const inventory = removeManagedBash({ env: this.options.env }) + facts = inventory + preview = { action, available: inventory.versions.length > 0, + details: ['Stop running Bash jobs before removal. Only the listed receipt-owned directories will be deleted.', + ...inventory.versions.map(entry => entry.directory), ...inventory.kept.map(entry => `Keep unowned directory: ${entry.directory}`)], + ...(!inventory.versions.length ? { reason: 'No receipt-owned managed Bash installation to remove.' } : {}) } + } else { + const rawPin = shellUpstreamPin({ kind: 'bash', ...this.options }) + const pin = rawPin && 'url' in rawPin && typeof rawPin.url === 'string' + && 'version' in rawPin && typeof rawPin.version === 'string' + && 'bytes' in rawPin && typeof rawPin.bytes === 'number' + && 'sha256' in rawPin && typeof rawPin.sha256 === 'string' + ? { url: rawPin.url, version: rawPin.version, bytes: rawPin.bytes, sha256: rawPin.sha256 } : undefined + const inventory = managedBashInventory(this.options) + const unowned = inventory.filter(entry => !entry.owned) + facts = { pin: pin ?? null, inventory } + preview = { action, available: Boolean(pin) && !unowned.length, details: pin ? [ + `Download ${pin.url}`, `Version: ${pin.version}; bytes: ${pin.bytes}; SHA-256: ${pin.sha256}`, + `Install into ${provisionedShellVersionDir({ kind: 'bash', version: pin.version, env: this.options.env })}`, + 'The pinned archive and executable are verified. No profile setting is changed. Reload the shell row after selecting the executor.', + ...inventory.map(entry => 'Old managed version may be replaced: ' + entry.directory), + ] : [], ...(!pin ? { reason: 'Managed Bash installation is available only for supported Windows builds.' } : {}), + ...(unowned.length ? { reason: 'Unowned directories exist in the managed Bash store. Resolve them manually before installing.' } : {}) } + } + return { public: preview, fingerprint: digest(facts) } + } + plan(action: RuntimeAction): RuntimePlan { + const checked = parseAction(action), value = this.preview(checked), id = value.public.available ? randomUUID() : '' + const now = this.options.now() + for (const [key, item] of this.plans) if (item.expires <= now) this.plans.delete(key) + if (this.plans.size >= 16) this.plans.delete(this.plans.keys().next().value!) + if (id) this.plans.set(id, { action: checked, fingerprint: value.fingerprint, expires: now + 600_000 }) + return { id, ...value.public } + } + execute(id: string, signal: AbortSignal): Promise { + const operation = this.executePlan(id, signal).finally(() => this.active.delete(operation)) + this.active.add(operation) + return operation + } + private async executePlan(id: string, signal: AbortSignal): Promise { + if (this.lifetime.signal.aborted || signal.aborted) return failure('cancelled', 'Action cancelled before execution.') + if (this.busy) return failure('busy', 'Another runtime action is running. Wait for it to settle before planning again.') + const plan = this.plans.get(id) + this.plans.delete(id) + if (!plan || plan.expires <= this.options.now()) return failure('stale', 'This preview expired or was already used. Preview the action again.') + const current = this.preview(plan.action) + if (!current.public.available || current.fingerprint !== plan.fingerprint) return failure('stale', 'The runtime plan changed. Preview it again before executing.') + this.busy = true + this.plans.clear() + const combined = AbortSignal.any([signal, this.lifetime.signal]) + let attempted = false + try { + combined.throwIfAborted() + if (plan.action === 'remove-bash') { + attempted = true + removeManagedBash({ env: this.options.env, yes: true }) + return { status: 'completed', effectState: 'applied', message: 'Removed the reviewed managed Bash directories. Other installations were preserved.' } + } + if (plan.action === 'upgrade-pwsh') { + const result = await upgradeHostPwsh({ ...this.options, execute: true, signal: combined, + runner: (value, active) => this.options.run([value.file, ...value.args], active ?? combined) }) + return { status: result.status === 'preview' ? 'unavailable' : result.status, effectState: result.effectState, + message: [result.error, result.plan.guidance, result.status === 'failed' ? 'If WinGet requires interactive input, run the displayed command in your terminal.' : ''].filter(Boolean).join(' '), + ...(result.exitCode !== undefined ? { exitCode: result.exitCode } : {}) } + } + attempted = true + const result = await this.options.run([process.execPath, path.join(PACKAGE_ROOT, 'bin/dsh-ops.mjs'), 'bash-runtime', 'install'], combined) + if (combined.aborted || result.signal) return failure('cancelled', 'Installation was cancelled. Inspect managed Bash status before trying again.', result.started ? 'unknown' : 'none') + return { status: result.started && result.exitCode === 0 && !result.error ? 'completed' : 'failed', + effectState: result.started && result.exitCode === 0 && !result.error ? 'applied' : result.started ? 'unknown' : 'none', + message: result.started && result.exitCode === 0 && !result.error ? 'Managed Bash installation completed. Select it and save the shell settings to use it.' + : `${result.error ?? 'Installer failed.'} Inspect managed Bash status; diagnostic details remain on the host.`, exitCode: result.exitCode } + } catch (error) { + return failure(combined.aborted ? 'cancelled' : 'failed', error instanceof Error ? error.message : String(error), attempted ? 'unknown' : 'none') + } finally { this.busy = false } + } + async dispose(): Promise { + this.lifetime.abort(new Error('Shell settings component unloaded')) + this.plans.clear() + await Promise.allSettled([...this.active]) + } +} diff --git a/src/settings/runtime-contract.ts b/src/settings/runtime-contract.ts new file mode 100644 index 00000000..24c0cb72 --- /dev/null +++ b/src/settings/runtime-contract.ts @@ -0,0 +1,55 @@ +/** Shared strict descriptors for operator-only settings actions; no host value imports. */ +import type { InvocationDescriptor, RemoteResult, TypertCodec } from '@deepseek-ai/dsh-typert-protocol' +export const RUNTIME_ACTIONS = ['install-bash', 'remove-bash', 'upgrade-pwsh'] as const +export type RuntimeAction = typeof RUNTIME_ACTIONS[number] +export type BashSource = 'managed' | 'git' +export interface RuntimePlan { id: string; action: RuntimeAction; available: boolean; details: string[]; reason?: string } +export interface RuntimeSnapshot { + platform: string + managed: { version: string; directory: string; owned: boolean }[] + git: string[] + powerShell: { supported: boolean; guidance: string; unavailable?: string } +} +export interface RuntimeSelection { bashPath: string; restriction: string } +export interface RuntimeResult { + status: 'completed' | 'no-update' | 'failed' | 'cancelled' | 'stale' | 'unavailable' | 'busy' + effectState: 'none' | 'applied' | 'unknown' + message: string + exitCode?: number | null +} +export interface RuntimeRemote { + snapshot(): Promise> + plan(action: RuntimeAction): Promise> + select(source: BashSource): Promise> + execute(id: string, signal?: AbortSignal): Promise> +} +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespaceMap { opsSettings: RuntimeRemote } +} +const codec = (name: string, parse: (value: unknown) => unknown): TypertCodec => ({ + mode: 'strict', typeSymbol: `dsh-ops#${name}`, create: () => ({ parse }), +}) +function enumValue(value: unknown, choices: T): T[number] { + if (typeof value !== 'string' || !choices.includes(value)) throw new Error(`Expected one of: ${choices.join(', ')}`) + return value +} +export const parseAction = (value: unknown): RuntimeAction => enumValue(value, RUNTIME_ACTIONS) +export const parseSource = (value: unknown): BashSource => enumValue(value, ['managed', 'git'] as const) +function planId(value: unknown): string { + if (typeof value !== 'string' || !/^[a-f0-9-]{36}$/.test(value)) throw new Error('Expected a current runtime plan identifier') + return value +} +const jsonResult = codec('RuntimeResult', value => value) +function method(name: string, parameter?: { wire: string; codec: TypertCodec }, cancellable = false): InvocationDescriptor { + return { id: `dsh-ops#opsSettings/${name}`, service: 'opsSettings', namespace: 'opsSettings', method: name, + invocation: { kind: 'direct' }, result: jsonResult, + parameters: parameter ? [{ name: parameter.wire, wire: parameter.wire, source: 'json', codec: parameter.codec }] : [], + ...(cancellable ? { cancellation: { parameter: 'signal' as const } } : {}), + } +} +export const RUNTIME_DESCRIPTORS: readonly InvocationDescriptor[] = Object.freeze([ + method('snapshot'), method('plan', { wire: 'action', codec: codec('RuntimeAction', parseAction) }), + method('select', { wire: 'source', codec: codec('BashSource', parseSource) }), + method('execute', { wire: 'id', codec: codec('RuntimePlanId', planId) }, true), +]) +export const RUNTIME_REMOTE = Object.freeze({ package: 'dsh-ops', descriptors: RUNTIME_DESCRIPTORS }) diff --git a/src/settings/runtime-host.ts b/src/settings/runtime-host.ts new file mode 100644 index 00000000..f936ab2a --- /dev/null +++ b/src/settings/runtime-host.ts @@ -0,0 +1,34 @@ +/** Mount only package-owned descriptors; the host gateway supplies operator admission. */ +import path from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-typert-registry' +import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import { dshHome, PACKAGE_ROOT } from '../paths.js' +import { RuntimeActions } from './runtime-actions.js' +import type { RuntimeProcessRunner } from './runtime-actions.js' +import { RUNTIME_DESCRIPTORS } from './runtime-contract.js' + +declare module '@deepseek-ai/cordis' { interface Context { opsSettings: RuntimeActions } } +export function runtimeProcessRunner(subprocess: Pick, env: NodeJS.ProcessEnv = process.env): RuntimeProcessRunner { + return async (argv, signal) => { + let started = false + try { + signal.throwIfAborted() + if (!path.isAbsolute(argv[0] ?? '')) throw new Error('Runtime action requires an absolute executable') + const child = subprocess.spawn({ argv, cwd: PACKAGE_ROOT, signal, graceMs: 2000, + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, env: { DSH_HOME: dshHome(env) } }) + started = true + const result = await child.done + return { started, exitCode: result.exitCode, signal: result.signal } + } catch (error) { return { started, exitCode: null, signal: signal.aborted ? 'aborted' : null, + error: error instanceof Error ? error.message : String(error) } } + } +} +export function mountRuntimeSettings(root: Context): void { + root.inject(['typert', 'subprocess'], ctx => { + const service = new RuntimeActions({ run: runtimeProcessRunner(ctx.subprocess) }) + ctx.provide('opsSettings', service) + ctx.typert.register({ package: 'dsh-ops', face: 'host', schemas: [], model: { services: [], events: [], objects: [] }, invocations: RUNTIME_DESCRIPTORS }) + ctx.effect(() => () => service.dispose(), 'dsh-ops: runtime settings actions') + }) +} diff --git a/test/settings-runtime.test.mjs b/test/settings-runtime.test.mjs new file mode 100644 index 00000000..a4cff0cb --- /dev/null +++ b/test/settings-runtime.test.mjs @@ -0,0 +1,187 @@ +/** Runtime controls cross the real host gateway; installers are never invoked by this suite. */ +import fs from 'node:fs' +import path from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import Registry from '@deepseek-ai/dsh-typert-registry' +import Gateway from '@deepseek-ai/dsh-api-gateway' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import { RuntimeActions } from '../lib/settings/runtime-actions.js' +import { RUNTIME_DESCRIPTORS } from '../lib/settings/runtime-contract.js' +import { runtimeProcessRunner, mountRuntimeSettings } from '../lib/settings/runtime-host.js' +import { assert, test, report, TEST_HOME } from './lib/harness.mjs' +const active = () => new AbortController().signal +const fixture = label => { const home = path.join(TEST_HOME, label); fs.mkdirSync(home, { recursive: true }); return { DSH_HOME: home } } +const success = async () => ({ started: true, exitCode: 0, signal: null }) +function managed(env, version, owned = true) { + const directory = path.join(env.DSH_HOME, 'dsh-ops/shells/bash', version) + fs.mkdirSync(path.join(directory, 'bin'), { recursive: true }) + fs.writeFileSync(path.join(directory, 'bin/bash.exe'), 'fixture') + if (owned) fs.writeFileSync(path.join(directory, '.provisioned.json'), JSON.stringify({ schemaVersion: 1, name: 'bash', version, sha256: 'a'.repeat(64) })) + return directory +} +function options(label, extra = {}) { return { env: fixture(label), platform: 'win32', arch: 'x64', run: success, ...extra } } + +await test('runtime previews never start a process and unknown/unavailable actions stay refused', async () => { + let calls = 0 + const service = new RuntimeActions(options('previews', { run: async () => { calls++; return success() } })) + const plan = service.plan('install-bash') + assert.equal(plan.available, true) + assert.match(plan.details.join(' '), /SHA-256/) + assert.equal(calls, 0) + assert.equal(service.plan('remove-bash').available, false) + assert.throws(() => service.plan('command'), /Expected one of/) + assert.throws(() => service.select('system'), /Expected one of/) + const unsupported = new RuntimeActions(options('posix', { platform: 'linux' })) + assert.equal(unsupported.plan('install-bash').available, false) + assert.equal(unsupported.plan('upgrade-pwsh').available, false) + assert.equal((await service.execute('unknown', active())).effectState, 'none') + assert.equal(calls, 0) +}) +await test('a reviewed install uses the fixed absolute CLI and a one-use plan', async () => { + const calls = [] + const service = new RuntimeActions(options('fixed-install', { run: async (argv, signal) => { calls.push({ argv, signal }); return success() } })) + const plan = service.plan('install-bash') + const result = await service.execute(plan.id, active()) + assert.equal(result.status, 'completed') + assert.equal(result.effectState, 'applied') + assert.equal(calls.length, 1) + assert.equal(calls[0].argv[0], process.execPath) + assert.ok(path.isAbsolute(calls[0].argv[1])) + assert.deepEqual(calls[0].argv.slice(2), ['bash-runtime', 'install']) + assert.equal((await service.execute(plan.id, active())).status, 'stale') + assert.equal(calls.length, 1) +}) +await test('changed inventory and expired previews cannot install or remove anything', async () => { + let now = 10, calls = 0 + const opts = options('stale-plan', { now: () => now, run: async () => { calls++; return success() } }) + const service = new RuntimeActions(opts) + const install = service.plan('install-bash') + const custom = managed(opts.env, 'custom', false) + assert.equal((await service.execute(install.id, active())).status, 'stale') + assert.equal(service.plan('install-bash').available, false) + assert.match(service.plan('install-bash').reason, /Unowned/) + const old = managed(opts.env, '1.0.0') + const removal = service.plan('remove-bash') + now += 600_001 + assert.equal((await service.execute(removal.id, active())).status, 'stale') + assert.ok(fs.existsSync(old)); assert.ok(fs.existsSync(custom)); assert.equal(calls, 0) +}) +await test('reviewed removal deletes only managed receipt-owned directories', async () => { + const opts = options('remove-reviewed'), service = new RuntimeActions(opts) + const owned = managed(opts.env, '1.0.0'), unowned = managed(opts.env, 'user-copy', false) + const plan = service.plan('remove-bash') + assert.ok(plan.details.some(line => line.includes(owned))) + assert.ok(plan.details.some(line => line.includes('Keep unowned directory'))) + assert.ok(fs.existsSync(owned)) + const result = await service.execute(plan.id, active()) + assert.equal(result.status, 'completed') + assert.equal(fs.existsSync(owned), false) + assert.ok(fs.existsSync(path.join(unowned, 'bin/bash.exe'))) +}) +await test('pre-cancel does not spawn; component disposal cancels and awaits running work', async () => { + let started = 0, settled = false + const service = new RuntimeActions(options('cancel', { run: async (_argv, signal) => { + started++ + await new Promise(resolve => signal.addEventListener('abort', resolve, { once: true })) + settled = true + return { started: true, exitCode: null, signal: 'aborted' } + } })) + const controller = new AbortController(); controller.abort() + assert.equal((await service.execute(service.plan('install-bash').id, controller.signal)).status, 'cancelled') + assert.equal(started, 0) + const pending = service.execute(service.plan('install-bash').id, active()) + assert.equal(started, 1) + assert.equal((await service.execute(service.plan('install-bash').id, active())).status, 'busy') + await service.dispose() + assert.equal(settled, true) + const result = await pending + assert.equal(result.status, 'cancelled'); assert.equal(result.effectState, 'unknown') +}) +await test('PowerShell upgrade uses the standard fixed WinGet plan and preserves failure facts', async () => { + const opts = options('pwsh'), binary = path.join(opts.env.DSH_HOME, 'Microsoft/WindowsApps/winget.exe') + fs.mkdirSync(path.dirname(binary), { recursive: true }); fs.writeFileSync(binary, 'not executed') + opts.env.LOCALAPPDATA = opts.env.DSH_HOME + let argv + opts.run = async value => { argv = value; return { started: true, exitCode: -1978335189, signal: null } } + const service = new RuntimeActions(opts), plan = service.plan('upgrade-pwsh') + assert.match(plan.details.join(' '), /Restart DSH/) + assert.match(plan.details.join(' '), /pwshPath/) + const result = await service.execute(plan.id, active()) + assert.deepEqual(argv, [binary, 'install', '--id', 'Microsoft.PowerShell', '--exact', '--source', 'winget', '--installer-type', 'wix']) + assert.equal(result.status, 'no-update'); assert.equal(result.effectState, 'none') +}) +await test('real gateway validates exact named inputs before action and withdraws the scoped definitions', async () => { + const ctx = new Context(); ctx.logger.level = 0 + let calls = 0 + try { + await ctx.plugin(Registry); await ctx.plugin(Gateway, {}) + const service = new RuntimeActions(options('gateway', { run: async () => { calls++; return success() } })) + const fiber = await ctx.plugin({ inject: ['typert'], apply(inner) { + inner.provide('opsSettings', service) + inner.typert.register({ package: 'dsh-ops', face: 'host', schemas: [], model: { services: [], events: [], objects: [] }, invocations: RUNTIME_DESCRIPTORS }) + inner.effect(() => () => service.dispose()) + } }) + const invoke = (method, args) => ctx.typertGateway.invoke({ namespace: 'opsSettings', method, args }) + await assert.rejects(invoke('plan', { action: 'command' }), /failed boundary validation/) + await assert.rejects(invoke('plan', { action: 'install-bash', command: 'arbitrary' }), /unexpected/) + await assert.rejects(invoke('execute', { id: 'not-a-plan' }), /failed boundary validation/) + const plan = await invoke('plan', { action: 'install-bash' }) + assert.equal(calls, 0) + assert.equal((await invoke('execute', { id: plan.id })).status, 'completed') + assert.equal(calls, 1) + await fiber.dispose() + await assert.rejects(invoke('snapshot', {}), /withdrawn/) + } finally { await ctx.fiber.dispose() } +}) +await test('actual host subprocess runs an absolute child and scrubs ambient DSH values', async () => { + const ctx = new Context(); ctx.logger.level = 0 + try { + await ctx.plugin(Subprocess, {}) + const env = fixture('real-subprocess'), runner = runtimeProcessRunner(ctx.subprocess, env) + const script = path.join(env.DSH_HOME, 'child.cjs'), output = path.join(env.DSH_HOME, 'child.json') + fs.writeFileSync(script, `require('node:fs').writeFileSync(process.argv[2], JSON.stringify({home:process.env.DSH_HOME, secret:process.env.DSH_OPS_SETTINGS_TEST_SECRET}));`) + const before = process.env.DSH_OPS_SETTINGS_TEST_SECRET + process.env.DSH_OPS_SETTINGS_TEST_SECRET = 'must-not-forward' + try { + const result = await runner([process.execPath, script, output], active()) + assert.equal(result.exitCode, 0) + assert.deepEqual(JSON.parse(fs.readFileSync(output, 'utf8')), { home: env.DSH_HOME }) + assert.match((await runner(['relative.exe'], active())).error, /absolute executable/) + } finally { if (before === undefined) delete process.env.DSH_OPS_SETTINGS_TEST_SECRET; else process.env.DSH_OPS_SETTINGS_TEST_SECRET = before } + } finally { await ctx.fiber.dispose() } +}) +await test('the shell settings seam waits for host services and owns its teardown', async () => { + const ctx = new Context(); ctx.logger.level = 0 + try { + const mounted = await ctx.plugin({ apply: mountRuntimeSettings }) + assert.equal(ctx.get('opsSettings'), undefined) + await ctx.plugin(Registry); await ctx.plugin(Subprocess, {}); await ctx.plugin(Gateway, {}) + await new Promise(resolve => setImmediate(resolve)) + assert.ok(ctx.get('opsSettings')) + assert.ok(ctx.typert.local.get('opsSettings/plan')) + await mounted.dispose() + assert.equal(ctx.get('opsSettings'), undefined) + assert.equal(ctx.typert.local.get('opsSettings/plan'), undefined) + } finally { await ctx.fiber.dispose() } +}) +await test('managed selection returns an absolute setting and reports restricted-mode incompatibility', () => { + const opts = options('select-managed'), service = new RuntimeActions(opts) + const directory = managed(opts.env, '2.56.0.2') + const selected = service.select('managed') + assert.equal(selected.bashPath, path.join(directory, 'bin/bash.exe')) + assert.ok(path.isAbsolute(selected.bashPath)) + assert.match(selected.restriction, /verified|restricted|restricted-mode|MSYS|compatib/i) + assert.deepEqual(service.snapshot().managed.map(entry => entry.version), ['2.56.0.2']) +}) +await test('installer failures never report applied effects without successful startup', async () => { + for (const [label, facts, effect] of [ + ['missing', { started: false, exitCode: 0, signal: null, error: 'could not spawn' }, 'none'], + ['partial', { started: true, exitCode: 2, signal: null }, 'unknown'], + ]) { + const service = new RuntimeActions(options(label, { run: async () => facts })) + const result = await service.execute(service.plan('install-bash').id, active()) + assert.equal(result.status, 'failed') + assert.equal(result.effectState, effect) + } +}) +report('settings-runtime') From c099bb528c121b3e52ed4c229c990e07b2e411db Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:35:27 +0800 Subject: [PATCH 072/118] fix(panel): stop queued collection when disabled --- docs/CHANGELOG.md | 6 + docs/configuration-reference.md | 2 + ...15\347\275\256\345\217\202\350\200\203.md" | 2 + src/components/panel/index.js | 57 --------- src/components/panel/index.ts | 32 +++++ src/panel/activity.ts | 13 ++ src/panel/collect.js | 25 +++- test/panel-stop.test.mjs | 115 ++++++++++++++++++ 8 files changed, 191 insertions(+), 61 deletions(-) delete mode 100644 src/components/panel/index.js create mode 100644 src/components/panel/index.ts create mode 100644 src/panel/activity.ts create mode 100644 test/panel-stop.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a408d081..866bbdca 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +- Stop panel collection and queued filesystem work when its row or capture switch is disabled; wait for issued I/O without scheduling a final fold. Expose validated capture settings through the host editor. +- 关闭 panel 行或采集开关时停止采集及排队文件操作,等待已开始的 I/O,但不触发最终归并;宿主编辑器提供严格校验的采集设置。 + +- Preserve raw shell configuration through settings validation so ordinary startup and profile reload do not interpret normalized defaults as explicit inputs. +- 设置校验保留原始 shell 配置,避免启动和配置重载将规范化默认值误认为显式输入。 ### Connect runtime settings actions to the host gateway (#261) - Add typed, package-owned gateway descriptors for runtime inventory, Bash selection and explicit install/remove/PowerShell-upgrade previews. Execute only current one-use plans through host subprocess governance; preserve cancellation and partial-effect facts. diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index b7d5d8f2..cf2d4d38 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -106,6 +106,8 @@ config: - `bashPath` **(Windows) must match the pinned MSYS for restricted execution.** In read-only and workspace-write, `ops_bash` runs only the pinned MSYS whose IPC compatibility proxies were verified (`dsh-ops bash-runtime install`, or a compatible existing copy); any other `msys-2.0.dll`, including your own Git for Windows, returns `EXECUTOR_UNAVAILABLE` by design and is never run unconfined. Do not point `bashPath` at your own Git Bash to work around a restricted-mode problem. Check a selection with `dsh-ops ladder --config `, which reports `restricted: yes/no` for the resolved bash. See [Troubleshooting](troubleshooting.md#ops_bash-cannot-run-a-custom-bashpath-or-git-for-windows-in-restricted-modes). - `deniedHostTools`: the host tool names refused by the `deny-host-shell` policy. - `configUpdateVersion`: completion marker for the configuration migrations (currently `2`: update 1 retires the old implicit system-bash opt-in, update 2 removes the deprecated FastCtx keys). Do not remove it, or the self-check runs again. +Disabling the row or saving `capture: false` stops event collection immediately, cancels queued filesystem work, and waits for already-started I/O before the host finishes the change. It does not start a final fold: existing NDJSON remains recoverable, and a temporary file from an interrupted fold may remain. Re-enabling captures only subsequent events; disabled events are not replayed. + - `capture` (「启动数据检测」): defaults to `false`, and **off means nothing happens** — the row registers no listener and creates no directory. When on, every *new* conversation writes into its own working directory: - `/.dsh-ops/panel/.json` — the only data source: conversation text, the tool-call/result/milestone trace, usage buckets, and trimmings/limits; - `/.dsh-ops/panel/panel.html` — a static viewer that reads that JSON in the browser and inlines no data (drag the JSON onto it, or pass `?data=.json` under a static server). diff --git "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" index 60b64222..6afc66b1 100644 --- "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" +++ "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" @@ -99,6 +99,8 @@ config: - `bashPath` **(Windows)在受限模式下必须匹配固定 MSYS。** 只读与工作区内修改模式下,`ops_bash` 只运行已校验 IPC 兼容代理的固定摘要 MSYS(`dsh-ops bash-runtime install` 或兼容的已有副本);其它任何 `msys-2.0.dll`(包括你自己的 Git for Windows)都会按设计返回 `EXECUTOR_UNAVAILABLE`,且绝不裸执行。不要把 `bashPath` 指向自带 Git Bash 来规避受限模式问题。可用 `dsh-ops ladder --config <文件>` 检查所选 bash,报告中的 `restricted: yes/no` 即受限模式能否运行。见 [常见问题](常见问题.md#受限模式下-ops_bash-无法运行自定义-bashpath-或-git-for-windows)。 - `deniedHostTools`:`deny-host-shell` 策略下拒绝的宿主工具名列表。 - `configUpdateVersion`:配置迁移的完成标记(当前为 `2`:更新 1 撤销旧的隐式系统 bash 选择,更新 2 删除已弃用的 FastCtx 键)。不要删除迁移标记,否则会重新自检。 +关闭组件行或保存 `capture: false` 会立即停止事件采集、取消尚未开始的文件操作,并在宿主完成变更前等待已开始的 I/O。停用不会触发最终归并;已有 NDJSON 可恢复,中断归并留下的临时文件可能保留。重新开启只采集后续事件,不补录关闭期间的事件。 + - `capture`(「启动数据检测」):默认 `false`,**关就是什么都不做**——该行不注册监听、不建目录。打开后,每个**新对话**写入它自己的工作目录: - `/.dsh-ops/panel/.json` —— 唯一数据源:对话文本、tool_call/tool_result/里程碑轨迹、usage 分桶,以及截断与上限记录; - `/.dsh-ops/panel/panel.html` —— 静态查看器,在浏览器里读取该 JSON,**不内联任何数据**(把 JSON 拖到页面上,或经静态服务用 `?data=.json`)。 diff --git a/src/components/panel/index.js b/src/components/panel/index.js deleted file mode 100644 index 0b48e8bc..00000000 --- a/src/components/panel/index.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * `dsh-ops/panel`: opt-in, static-file capture of new conversations into the - * session's own working directory (issue #205 §五). - * - * The row is inserted enabled so its config is addressable in the settings - * editor, and the switch inside it (`capture`, the settings item 「启动数据检测」) - * is **off by default**. Off means exactly nothing: no listener, no effect, no - * directory, no file, no timer — `apply` returns after validating its own config, - * which is what makes "default off ⇒ zero overhead" checkable rather than a - * claim. The component registers no UI panel, opens no port, and reads and writes - * nothing outside `/.dsh-ops/panel/`. - * - * @module dsh-ops/components/panel - */ - -import { readFileSync } from 'node:fs' -import { createPanelCollector, resolvePanelConfig } from '../../panel/collect.js' - -export const name = 'dsh-ops-panel' - -/** Validator the bundle gate and this component share. */ -export { resolvePanelConfig } - -/** Report the way the rest of the plugin does, without inventing a second logger. */ -function report(ctx, message, level = 'error') { - const logger = ctx?.logger - if (typeof logger?.[level] === 'function') logger[level](message) - else if (level === 'warn') console.warn(message) - else console.error(message) -} - -/** The shipped package version, for the provenance block; absent is acceptable. */ -function pluginVersion() { - try { return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version } catch { return undefined } -} - -/** - * Mount the capture sink when the row asks for it. - * @param {object} ctx - the host plugin context. - * @param {unknown} rawConfig - the row config. - * @returns {void} - */ -export function apply(ctx, rawConfig) { - // Malformed config fails the row loud, exactly like the other components. - const config = resolvePanelConfig(rawConfig) - if (!config.capture) return - const collector = createPanelCollector({ - config, - report: (message, level) => report(ctx, message, level), - meta: { pluginVersion: pluginVersion(), argv: process.argv.slice(2), platform: process.platform, node: process.version }, - }) - ctx.on('session/event', (session, event) => { collector.observe(session, event) }) - ctx.on('session/disposed', (session) => { void collector.close(session?.id) }) - // Sealing is asynchronous and best effort; `dispose` never rejects, and the - // teardown must not wait on it (a slow disk cannot hold a shutdown open). - ctx.effect(() => () => { void collector.dispose() }, 'dsh-ops: panel capture') -} diff --git a/src/components/panel/index.ts b/src/components/panel/index.ts new file mode 100644 index 00000000..d2f01195 --- /dev/null +++ b/src/components/panel/index.ts @@ -0,0 +1,32 @@ +/** Opt-in capture. A disabled row registers nothing and starts no filesystem work. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-session' +import { readFileSync } from 'node:fs' +import { createPanelCollector, resolvePanelConfig } from '../../panel/collect.js' +import { configObject, editable, settingsConfig } from '../../settings/schema.js' + +export const name = 'dsh-ops-panel' +export { resolvePanelConfig } +export const Config = settingsConfig(value => resolvePanelConfig(configObject(value)), { + capture: editable('boolean', 'Capture new session events into the session workspace. Off stops collection and queued writes.'), + textLimit: editable('number', 'Maximum characters retained per text field (64–100000).'), + maxSessionBytes: editable('number', 'Maximum bytes per session document (65536–268435456).'), +}) +function pluginVersion(): string | undefined { + try { + const value: unknown = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) + return value && typeof value === 'object' && 'version' in value && typeof value.version === 'string' ? value.version : undefined + } catch { return undefined } +} +export function apply(ctx: Context, rawConfig: unknown): void { + const config = resolvePanelConfig(configObject(rawConfig)) + if (!config.capture) return + const collector = createPanelCollector({ + config, + report: (message: string) => ctx.logger.warn(message), + meta: { pluginVersion: pluginVersion(), argv: process.argv.slice(2), platform: process.platform, node: process.version }, + }) + ctx.on('session/event', (session, event) => { collector.observe(session, event) }) + ctx.on('session/disposed', session => { void collector.close(session?.id) }) + ctx.effect(() => () => collector.stop(), 'dsh-ops: panel capture') +} diff --git a/src/panel/activity.ts b/src/panel/activity.ts new file mode 100644 index 00000000..a32f30fe --- /dev/null +++ b/src/panel/activity.ts @@ -0,0 +1,13 @@ +/** Fence queued work before shutdown, while callers await operations already running. */ +export function activityGate() { + let stopped = false + const cancelled = new Error('Panel collection stopped') + return { + async run(operation: () => T | Promise): Promise { + if (stopped) throw cancelled + return operation() + }, + stop(): void { stopped = true }, + cancelled(error: unknown): boolean { return error === cancelled }, + } +} diff --git a/src/panel/collect.js b/src/panel/collect.js index 84b4ddd0..ab17b1f7 100644 --- a/src/panel/collect.js +++ b/src/panel/collect.js @@ -5,7 +5,7 @@ * One projected record per `session/event` is appended to * `/.dsh-ops/panel/.ndjson` while a session is live, and folded * into the single JSON document `.json` at every turn end, on session - * disposal, and at teardown. The JSON is the only data source; `panel.html` is a + * disposal. Disabling the component stops queued work and retains the append log. The JSON is the only data source; `panel.html` is a * viewer that reads it. Three contracts shape the code: * * - **Capture can never break a session.** Every entry point is wrapped, every @@ -29,6 +29,7 @@ import path from 'node:path' import { createHash } from 'node:crypto' import { dshHome } from '../paths.js' import { renderPanelHtml } from './render.js' +import { activityGate } from './activity.js' /** Document format tag; a reader must reject a tag it does not know. */ export const PANEL_FORMAT = 'dsh-ops/panel-capture@1' @@ -438,11 +439,16 @@ export function fitDocument(document, cap) { * nothing; persistence is per session and lazy, so no directory exists until the * first event of the first captured session arrives. * @param {object} [options] - `config`, `io`, `env`, `now`, `report` and `meta` seams. - * @returns {object} the collector. + * Returns the collector for this activation. */ export function createPanelCollector(options = {}) { const config = options.config ?? PANEL_DEFAULTS - const io = options.io ?? fsp + const storage = options.io ?? fsp + const activity = activityGate() + // Fence each filesystem operation, including continuations after an awaited + // read/write. Queued folds cannot start fresh writes once the row is stopped. + const io = Object.fromEntries(['readFile', 'mkdir', 'appendFile', 'writeFile', 'rename', 'stat', 'rm'] + .map(method => [method, (...args) => activity.run(() => storage[method](...args))])) const env = options.env ?? process.env const now = options.now ?? (() => Date.now()) const report = typeof options.report === 'function' ? options.report : () => {} @@ -450,6 +456,7 @@ export function createPanelCollector(options = {}) { const sessions = new Map() const noted = new Set() let disposed = false + let stopping // Say something at most once per kind; reporting must never escape either. function note(key, message, level = 'warn') { @@ -458,6 +465,7 @@ export function createPanelCollector(options = {}) { try { report(message, level) } catch { /* a logger failure is not the session's problem */ } } function fail(key, error) { + if (activity.cancelled(error)) return const reason = error instanceof Error ? error.message : String(error) note(key, `dsh-ops panel: capture failed (${key}): ${reason}; this session continues uncaptured.`, 'warn') } @@ -631,7 +639,16 @@ export function createPanelCollector(options = {}) { state.closed = true return merge(sessionId, true).then(() => { sessions.delete(sessionId) }) }, - /** Seal every session; called from the activation teardown. Never rejects. */ + /** Stop collection without starting a final fold. Await all issued I/O before + * the loader reports this row disabled; retained append logs remain recoverable. */ + stop() { + if (stopping) return stopping + disposed = true + activity.stop() + stopping = collector.flush().then(() => { sessions.clear() }) + return stopping + }, + /** Explicitly seal every session. Component teardown uses stop() instead. */ dispose() { if (disposed) return Promise.resolve(undefined) disposed = true diff --git a/test/panel-stop.test.mjs b/test/panel-stop.test.mjs new file mode 100644 index 00000000..ba5988ab --- /dev/null +++ b/test/panel-stop.test.mjs @@ -0,0 +1,115 @@ +import fs from 'node:fs' +import path from 'node:path' +import { tmpdir } from 'node:os' +import { parse } from 'yaml' +import { reconcileProfilePatches } from '@deepseek-ai/dsh-app-boot' +import { createPanelCollector, resolvePanelConfig } from '../lib/panel/collect.js' +import * as Panel from '../lib/components/panel/index.js' +import { profileFixture } from './lib/settings-profile.mjs' +import { shutdown } from './lib/host.mjs' +import { assert, test, report, waitFor } from './lib/harness.mjs' + +const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done }); return { promise, resolve } } +const event = (text, seq = 0) => ({ type: 'user/message', seq, time: 1700000000000 + seq, + data: { source: { kind: 'user' }, content: [{ type: 'text', text }] } }) +const session = (cwd, id) => ({ id, header: { cwd } }) +const config = resolvePanelConfig({ capture: true }) +const env = { DSH_HOME: path.join(tmpdir(), 'panel-stop-unrelated-home') } + +await test('stopping during initial I/O cancels all queued writes and waits for that I/O', async () => { + const entered = deferred(), release = deferred(), writes = [], warnings = [] + const io = Object.fromEntries(['mkdir', 'appendFile', 'writeFile', 'rename', 'rm', 'stat'].map(name => [name, async () => { writes.push(name) }])) + io.readFile = async () => { entered.resolve(); await release.promise; throw Object.assign(new Error('absent'), { code: 'ENOENT' }) } + const collector = createPanelCollector({ config, env, io, report: value => warnings.push(value) }) + const captured = session(tmpdir(), 'queued') + collector.observe(captured, event('must not land')) + collector.observe(captured, { type: 'turn/end', seq: 1, time: 1700000000001, data: {} }) + await entered.promise + let settled = false + const stopping = collector.stop().then(() => { settled = true }) + assert.equal(collector.stop(), collector.stop(), 'stop is idempotent') + await Promise.resolve() + assert.equal(settled, false, 'in-flight I/O is still owned by the component') + collector.observe(captured, event('after stop', 2)) + release.resolve() + await stopping + assert.deepEqual(writes, []) + assert.deepEqual(warnings, [], 'shutdown is not a capture failure') + assert.equal(collector.size, 0) +}) + +await test('stop waits for an issued append but never starts the queued append or final fold', async () => { + const cwd = fs.mkdtempSync(path.join(tmpdir(), 'dsh-ops-panel-stop-')) + try { + const entered = deferred(), release = deferred() + let block = false, writes = 0 + const io = { ...fs.promises, appendFile: async (...args) => { + writes++ + if (block) { entered.resolve(); await release.promise } + return fs.promises.appendFile(...args) + } } + const collector = createPanelCollector({ config, env, io }) + const captured = session(cwd, 'inflight') + collector.observe(captured, event('initial')) + await collector.flush() + block = true + collector.observe(captured, event('issued', 1)) + await entered.promise + collector.observe(captured, event('queued', 2)) + void collector.close(captured.id) + let finished = false + const stopping = collector.stop().then(() => { finished = true }) + await Promise.resolve() + assert.equal(finished, false) + release.resolve() + await stopping + const dir = path.join(cwd, '.dsh-ops/panel') + assert.equal(writes, 3, 'header, initial event and the already-issued event only') + assert.deepEqual(fs.readdirSync(dir), ['inflight.ndjson'], 'no final materialisation or log removal') + assert.doesNotMatch(fs.readFileSync(path.join(dir, 'inflight.ndjson'), 'utf8'), /queued/) + } finally { fs.rmSync(cwd, { recursive: true, force: true }) } +}) + +await test('real settings capture toggle and Loader row disable physically stop writes, then re-enable cleanly', async () => { + const cwd = fs.mkdtempSync(path.join(tmpdir(), 'dsh-ops-panel-toggle-')) + const fixture = await profileFixture('panel-toggle', Panel, { capture: true }) + const { ctx, patchPath, snapshot, mutate } = fixture + const dir = path.join(cwd, '.dsh-ops/panel') + const emit = (id, text, seq = 0) => ctx.emit('session/event', session(cwd, id), event(text, seq)) + const contents = () => Object.fromEntries(fs.readdirSync(dir).map(file => [file, fs.readFileSync(path.join(dir, file), 'utf8')])) + try { + emit('first', 'captured') + await waitFor(() => fs.existsSync(path.join(dir, 'first.ndjson')) && fs.readFileSync(path.join(dir, 'first.ndjson'), 'utf8').includes('captured')) + await mutate([{ op: 'set', path: ['capture'], value: false }], snapshot().revision) + await ctx.loader.await() + const stopped = contents() + assert.match(fs.readFileSync(patchPath, 'utf8'), /capture: false/) + emit('first', 'off', 1); emit('off', 'new while off') + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(contents(), stopped) + await mutate([{ op: 'set', path: ['capture'], value: true }], snapshot().revision) + await ctx.loader.await() + emit('second', 're-enabled') + await waitFor(() => fs.existsSync(path.join(dir, 'second.ndjson'))) + const patches = parse(fs.readFileSync(patchPath, 'utf8')) + patches.push({ id: 'panel-toggle', disabled: true }) + fs.writeFileSync(patchPath, JSON.stringify(patches)) + await reconcileProfilePatches(ctx, patches, 'dsh', ['panel-toggle']) + await ctx.loader.await() + const disabled = contents() + emit('disabled', 'must not collect') + ctx.emit('session/disposed', session(cwd, 'second')) + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(contents(), disabled) + assert.equal(snapshot(), undefined) + patches.at(-1).disabled = false + fs.writeFileSync(patchPath, JSON.stringify(patches)) + await reconcileProfilePatches(ctx, patches, 'dsh', ['panel-toggle']) + await ctx.loader.await() + emit('third', 'row re-enabled') + await waitFor(() => fs.existsSync(path.join(dir, 'third.ndjson'))) + assert.equal(snapshot().value.capture, true) + assert.equal(fs.existsSync(path.join(dir, 'disabled.ndjson')), false) + } finally { await shutdown(ctx); fs.rmSync(cwd, { recursive: true, force: true }) } +}) +report('panel-stop') From d538f737bb7b4e3d30296404ee31c46455473f2c Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:22:51 +0800 Subject: [PATCH 073/118] refactor(shell): type Windows runtime and workspace preparation --- docs/CHANGELOG.md | 2 + src/{bash-ipc.js => bash-ipc.ts} | 64 ++++++++++--------- ...reparation.js => workspace-preparation.ts} | 29 +++++---- test/bash-ipc.test.mjs | 23 +++++++ 4 files changed, 76 insertions(+), 42 deletions(-) rename src/{bash-ipc.js => bash-ipc.ts} (81%) rename src/{workspace-preparation.js => workspace-preparation.ts} (84%) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 43ce4f4b..d1942111 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,8 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Type the confined Bash and workspace-preparation boundaries; reject malformed IPC proxy receipts before staging a runtime. 严格类型化受限 Bash 与工作区准备边界;代理收据格式错误时,在暂存运行时前明确拒绝。 + ### Leave PowerShell with its host (#261) - Remove the PowerShell npm payload dependency, shell payload release assembly and automatic diff --git a/src/bash-ipc.js b/src/bash-ipc.ts similarity index 81% rename from src/bash-ipc.js rename to src/bash-ipc.ts index 1293fbe0..315b591b 100644 --- a/src/bash-ipc.js +++ b/src/bash-ipc.ts @@ -6,21 +6,26 @@ import path from 'node:path' import os from 'node:os' import { createHash } from 'node:crypto' import { fileURLToPath } from 'node:url' +import { errorMessage } from './shells/types.js' import { patchMsys } from './bash-pe.js' const packageRoot=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..') -const hash=bytes=>createHash('sha256').update(bytes).digest('hex') -const cache=new Map() +const hash=(bytes: Uint8Array)=>createHash('sha256').update(bytes).digest('hex') +interface Runtime { file: string; env?: NodeJS.ProcessEnv; runtime?: string } +interface TreeEntry { size: number; mtimeMs: number; sha256?: string } +interface Receipt { platform?: unknown; msysSha256?: unknown; files?: unknown } +const record = (value: unknown): value is Record => typeof value === 'object' && value !== null +const cache=new Map() const proxies=path.join(packageRoot,'runtime/win32-x64/bash-ipc') -const SOURCES={config:'configured bashPath',path:'system bash on PATH','well-known':'system Git for Windows install',bundled:'bundled bash',provisioned:'provisioned bash'} +const SOURCES: Record={config:'configured bashPath',path:'system bash on PATH','well-known':'system Git for Windows install',bundled:'bundled bash',provisioned:'provisioned bash'} // Resolve both PortableGit's bin wrapper and its real usr/bin executable. -function msysRoot(file) { +function msysRoot(file: string) { const directory=path.dirname(file) return path.basename(directory).toLowerCase()==='bin' && path.basename(path.dirname(directory)).toLowerCase()==='usr' ? path.resolve(directory,'../..') : path.resolve(directory,'..') } /** The one actionable explanation for a bash that restricted modes cannot run (#42). */ -export function restrictedRemedy(file, source, why) { - const label=SOURCES[source] ? `${SOURCES[source]} ` : '' +export function restrictedRemedy(file: string, source: string | undefined, why: string) { + const label=source !== undefined && SOURCES[source] ? `${SOURCES[source]} ` : '' return `EXECUTOR_UNAVAILABLE: ops_bash cannot run the ${label}${file} in restricted modes (read-only / workspace-write): ${why} ` +'By design, restricted modes run only the pinned MSYS whose DSH IPC compatibility proxies were verified; this is not a user authorization denial. ' +'A custom bashPath or system Git for Windows works only in danger-full-access. ' @@ -28,12 +33,12 @@ export function restrictedRemedy(file, source, why) { +'or ask the user to switch the session to danger-full-access. Approvals never repair an incompatible confined runtime; do not bypass confinement.' } /** Whether restricted modes can run this bash, without copying or building anything. */ -export function restrictedCompatibility(file, {platform=process.platform}={}) { +export function restrictedCompatibility(file: string, {platform=process.platform}: { platform?: NodeJS.Platform }={}) { if(platform!=='win32')return {compatible:true,reason:'no MSYS IPC compatibility layer is needed on this platform'} const dll=path.join(msysRoot(file),'usr/bin/msys-2.0.dll') if(!fs.existsSync(dll))return {compatible:false,reason:`it has no usr/bin/msys-2.0.dll beside it (${dll}), which is not the supported MSYS payload layout.`} - let receipt - try { receipt=JSON.parse(fs.readFileSync(path.join(proxies,'provenance.json'),'utf8')) } + let receipt: Receipt + try { receipt=JSON.parse(fs.readFileSync(path.join(proxies,'provenance.json'),'utf8')) as Receipt } catch { return {compatible:false,reason:'this dsh-ops installation carries no Bash IPC compatibility receipt (runtime/win32-x64/bash-ipc/provenance.json).'} } const msysSha256=hash(fs.readFileSync(dll)) if(receipt.platform==='win32-x64' && receipt.msysSha256===msysSha256)return {compatible:true,reason:'its msys-2.0.dll matches the pinned MSYS build',msysSha256,expectedSha256:receipt.msysSha256} @@ -48,9 +53,9 @@ const PATH_DIRS=['usr/bin','ucrt64/bin','cmd'] * resolve — is caught too, not only the two or three files a receipt names. * Records size/mtime as well as the digest, so {@link treeMatches} can re-check the whole tree * with one `stat` per file instead of re-reading ~174 MB on every command. */ -function treeDigest(base,withHash=true){ - const out={} - const walk=rel=>{ +function treeDigest(base: string,withHash=true){ + const out: Record={} + const walk=(rel: string)=>{ let entries try{entries=fs.readdirSync(path.join(base,rel),{withFileTypes:true})}catch{return} for(const e of entries.sort((a,b)=>a.name{ const want=expected[name],got=actual[name] - return got!==undefined&&want!==null&&typeof want==='object'&&want.size===got.size&&want.mtimeMs===got.mtimeMs + return got!==undefined&&record(want)&&want.size===got.size&&want.mtimeMs===got.mtimeMs }) } /** Per-source-tree memo. Everything derived from the **source** payload — the MSYS bytes, their @@ -95,10 +100,11 @@ function treeMatches(base,expected){ * ⚠️ Consequence, stated rather than implied: if a source file is rewritten with its size and mtime * preserved, this memo will not re-check it. That path was never a boundary either — the source * tree lives under the profile's `node_modules`, with the same owner as the cache. */ -const sourceMemo=new Map() +interface SourceState { key: string; bytes: Buffer; receipt: Receipt & { files: Record }; proxyBytes: Record; patched: Buffer; patchedSha: string; identity: string } +const sourceMemo=new Map() /** `size:mtimeMs` for a file, or `absent` — absence is part of the key, not an error. */ -const statKey = file => { try { const s=fs.statSync(file); return `${s.size}:${s.mtimeMs}` } catch { return 'absent' } } -function sourceState(root, dll){ +const statKey = (file: string) => { try { const s=fs.statSync(file); return `${s.size}:${s.mtimeMs}` } catch { return 'absent' } } +function sourceState(root: string, dll: string): SourceState{ let st try{st=fs.statSync(dll)}catch{ throw Error('EXECUTOR_UNAVAILABLE: Windows confined Bash requires the supported MSYS payload layout; no unconfined fallback.') @@ -107,15 +113,15 @@ function sourceState(root, dll){ const cached=sourceMemo.get(root) if(cached!==undefined&&cached.key===key)return cached const bytes=fs.readFileSync(dll) - const receipt=JSON.parse(fs.readFileSync(path.join(proxies,'provenance.json'),'utf8')) - if(receipt.platform!=='win32-x64'||receipt.msysSha256!==hash(bytes))throw Error('EXECUTOR_UNAVAILABLE: This MSYS version has no verified DSH IPC compatibility build. Rebuild the matching proxies; do not bypass confinement.') - const proxyBytes={} + const receipt=JSON.parse(fs.readFileSync(path.join(proxies,'provenance.json'),'utf8')) as Receipt as Receipt + if(receipt.platform!=='win32-x64'||receipt.msysSha256!==hash(bytes)||!record(receipt.files))throw Error('EXECUTOR_UNAVAILABLE: This MSYS version has no verified DSH IPC compatibility build. Rebuild the matching proxies; do not bypass confinement.') + const proxyBytes: Record={} for(const name of ['opsipc.dll','opsnt.dll']){ proxyBytes[name]=fs.readFileSync(path.join(proxies,name)) if(hash(proxyBytes[name])!==receipt.files[name])throw Error(`Bash IPC provenance mismatch: ${name}`) } const patched=patchMsys(bytes).bytes - const state={key,bytes,receipt,proxyBytes,patched, + const state: SourceState={key,bytes,receipt: { ...receipt, files: receipt.files },proxyBytes,patched, patchedSha:hash(patched), identity:hash(Buffer.from(JSON.stringify({root,msys:receipt.msysSha256,files:receipt.files}))).slice(0,24)} sourceMemo.set(root,state) @@ -123,7 +129,7 @@ function sourceState(root, dll){ } /** @param {string} file * @param {{home?: string, platform?: NodeJS.Platform, source?: string}} options */ -export function confinedBash(file, {home=process.env.DSH_HOME ?? path.join(os.homedir(),'.dsh'), platform=process.platform, source}={}) { +export function confinedBash(file: string, {home=process.env.DSH_HOME ?? path.join(os.homedir(),'.dsh'), platform=process.platform, source}: { home?: string; platform?: NodeJS.Platform; source?: string }={}): Runtime { if(platform!=='win32')return {file} const root=msysRoot(file) const check=restrictedCompatibility(file,{platform}) @@ -133,7 +139,7 @@ export function confinedBash(file, {home=process.env.DSH_HOME ?? path.join(os.ho const selected=path.join(target,'usr/bin/bash.exe') const key=path.resolve(target) const marker=path.join(target,'.ops-ipc.json') - const readMarker = () => { try { return JSON.parse(fs.readFileSync(marker,'utf8')) } catch { return null } } + const readMarker = (): unknown => { try { return JSON.parse(fs.readFileSync(marker,'utf8')) } catch { return null } } /** * One judgement, used by **both** the cached and the cold path. * @@ -157,7 +163,7 @@ export function confinedBash(file, {home=process.env.DSH_HOME ?? path.join(os.ho * timestamp drift passed cold and failed hot, landing in the recovery branch with no attacker * involved. Both now run this same function. */ - const generationIntact = (m) => { + const generationIntact = (m: unknown) => { let msysOk try { msysOk = hash(fs.readFileSync(path.join(target,'usr/bin/msys-2.0.dll'))) === patchedSha } catch { return false } if(!msysOk) return false @@ -167,11 +173,11 @@ export function confinedBash(file, {home=process.env.DSH_HOME ?? path.join(os.ho if(hash(fs.readFileSync(path.join(target,'usr/bin',name)))!==receipt.files[name]) return false } } catch { return false } - return treeMatches(target, m?.tree) + return treeMatches(target, record(m) ? m.tree : undefined) } let knownBad=false if(cache.has(key)) { - if(generationIntact(readMarker())) return cache.get(key) + if(generationIntact(readMarker())) return cache.get(key)! // Not a throw. A cached-but-mismatched generation takes the same quarantine + staging path as a // cold miss; throwing here bricked bash for the lifetime of a long-lived host process. knownBad=true @@ -180,7 +186,7 @@ export function confinedBash(file, {home=process.env.DSH_HOME ?? path.join(os.ho // One marker read and one judgement per call: a failed cache check already proved the generation // bad, so re-running the (expensive) judgement on the way to quarantine just doubles the work. const marker0=readMarker() - const valid = !knownBad && fs.existsSync(selected) && marker0?.identity===identity && generationIntact(marker0) + const valid = !knownBad && fs.existsSync(selected) && record(marker0) && marker0.identity===identity && generationIntact(marker0) if(!valid){ // A generation that exists but does not verify is **quarantined, not overwritten**: it may // belong to a process that is still running. Renaming keeps that guarantee while giving the next @@ -201,7 +207,7 @@ export function confinedBash(file, {home=process.env.DSH_HOME ?? path.join(os.ho }catch{ /* best effort — cleanup must never block recovery */ } console.error(`dsh-ops: quarantined an unverifiable bash runtime generation at ${quarantine}; staging a fresh one. Delete it once no confined shell is running.`) }catch(error){ - throw Error(`Bash IPC runtime at ${target} failed verification and could not be quarantined (${error.message}). ` + throw Error(`Bash IPC runtime at ${target} failed verification and could not be quarantined (${errorMessage(error)}). ` +'Stop any confined shell, then delete that directory and retry.') } } diff --git a/src/workspace-preparation.js b/src/workspace-preparation.ts similarity index 84% rename from src/workspace-preparation.js rename to src/workspace-preparation.ts index fd8097ba..dfe3f5ff 100644 --- a/src/workspace-preparation.js +++ b/src/workspace-preparation.ts @@ -3,27 +3,30 @@ * directory-only WRITE_OWNER. The DSH provider still owns confinement/grants. */ import fs from 'node:fs' +import type { Context } from '@deepseek-ai/cordis' +import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import type { SpawnOptionsWithStdioTuple, StdioNull, StdioPipe } from 'node:child_process' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' import { spawn } from 'node:child_process' const script = fileURLToPath(new URL('../bin/prepare-workspace.ps1', import.meta.url)) -const pending = new Map() -const prepared = new Set() +const pending = new Map>() +const prepared = new Set() /** Whether the given path, or any ancestor of it, is a reparse point (junction or symlink). * Judged on the path as given, never on its resolved form: resolving first hides a link above the * root, and an 8.3 short name resolves to its long form without any link being involved. */ -function traversesReparsePoint(target) { +function traversesReparsePoint(target: string) { for (let current = path.resolve(target);; current = path.dirname(current)) { if (fs.lstatSync(current).isSymbolicLink()) return true const parent = path.dirname(current) if (parent === current) return false } } -export function prepareWorkspace(policy, { platform = process.platform, run = runPreparation } = {}) { +export function prepareWorkspace(policy: Pick | undefined, { platform = process.platform, run = runPreparation }: { platform?: NodeJS.Platform; run?: (root: string) => void | Promise } = {}): Promise { if (platform !== 'win32' || policy?.mode !== 'workspace-write') return Promise.resolve() - if (!path.isAbsolute(policy.workspaceRoot ?? '')) return Promise.reject(new Error('Invalid authoritative workspace root')) + if (typeof policy.workspaceRoot !== 'string' || !path.isAbsolute(policy.workspaceRoot)) return Promise.reject(new Error('Invalid authoritative workspace root')) const root = fs.realpathSync.native(policy.workspaceRoot) // Do not silently resolve a link selected as the authority root: the authority would be a // directory the caller did not name. Ask that question directly instead of comparing the given @@ -37,7 +40,7 @@ export function prepareWorkspace(policy, { platform = process.platform, run = ru const work = Promise.resolve().then(() => run(root)).then(() => { prepared.add(root) }).finally(() => { pending.delete(root) }) pending.set(root, work) } - return pending.get(root) + return pending.get(root)! } /** Safety net only: the root DACL write no longer scales with workspace size (#43). */ export const PREPARATION_TIMEOUT_MS = 120_000 @@ -47,7 +50,7 @@ const EXECUTION_POLICY = /about_Execution_Policies|UnauthorizedAccess|PSSecurity // bin/prepare-workspace.ps1's own refusals, plus a denied DACL write: the folder lacks the prerequisite. const PREREQUISITE = /not owned by the current user|reparse point|Explicit effective denial of WRITE_OWNER|is not a directory|WRITE_OWNER verification failed|Access is denied|0x80070005/i /** Name the cause of a failed preparation run, so a policy, a folder and an executor problem read differently (#66). */ -export function classifyPreparationFailure(output, { root, script: file = script } = {}) { +export function classifyPreparationFailure(output: string, { root, script: file = script }: { root?: string; script?: string } = {}) { const tail = output.trim() || '(no output)' if (EXECUTION_POLICY.test(output)) { return `PowerShell execution policy blocked dsh-ops's own preparation script ${file} even with -ExecutionPolicy Bypass, so the policy comes from Group Policy (MachinePolicy/UserPolicy). Ask an administrator, check \`powershell.exe -NoProfile -Command Get-ExecutionPolicy -List\`, or use danger-full-access for this session (it skips preparation). Output: ${tail}` @@ -77,7 +80,7 @@ export function classifyPreparationFailure(output, { root, script: file = script * @param {NodeJS.ProcessEnv} [source] - the environment to derive from. * @returns {NodeJS.ProcessEnv} a copy without the inherited module search path. */ -export function preparationEnvironment(source = process.env) { +export function preparationEnvironment(source: NodeJS.ProcessEnv = process.env) { const env = { ...source } delete env.PSModulePath return env @@ -92,7 +95,7 @@ export function preparationEnvironment(source = process.env) { * @param {string} root - the authoritative workspace root. * @returns {{executable: string, args: string[], options: object}} */ -export function preparationSpawnSpec(root) { +export function preparationSpawnSpec(root: string): { executable: string; args: string[]; options: SpawnOptionsWithStdioTuple } { const windows = process.env.SystemRoot ?? 'C:\\Windows' const home = process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh') const backups = path.join(home, 'dsh-ops/acl-backups') @@ -104,13 +107,13 @@ export function preparationSpawnSpec(root) { options: { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: preparationEnvironment() }, } } -export function runPreparation(root, { executable, args, timeoutMs = PREPARATION_TIMEOUT_MS } = {}) { +export function runPreparation(root: string, { executable, args, timeoutMs = PREPARATION_TIMEOUT_MS }: { executable?: string; args?: string[]; timeoutMs?: number } = {}): Promise { const home = process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh') const backups = path.join(home, 'dsh-ops/acl-backups') const spec = preparationSpawnSpec(root) executable ??= spec.executable args ??= spec.args - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const started = Date.now() // `spec.options` carries the scrubbed `env`; callers may override executable/args (the suites // do), but the environment the interpreter inherits is this module's decision. @@ -132,14 +135,14 @@ export function runPreparation(root, { executable, args, timeoutMs = PREPARATION }) }) } -export function mountWorkspacePreparation(root) { +export function mountWorkspacePreparation(root: Context) { if (process.platform !== 'win32') return root.inject(['sandboxPolicy', 'tools'], ctx => { // Execute-stage hook: user approval/guards have already accepted the call. ctx.on('tools/execute', async (exec, next) => { const session = exec?.agent?.session if (session && ['run_code', 'pwsh', 'bash', 'pwsh_persistent', 'bash_persistent'].includes(exec.name)) { - await prepareWorkspace(ctx.get('sandboxPolicy').resolve({ session })) + await prepareWorkspace(ctx.get('sandboxPolicy')!.resolve({ session })) } return next() }) diff --git a/test/bash-ipc.test.mjs b/test/bash-ipc.test.mjs index 8935eb4e..9184d9fe 100644 --- a/test/bash-ipc.test.mjs +++ b/test/bash-ipc.test.mjs @@ -1,5 +1,6 @@ import fs from 'node:fs' import path from 'node:path' +import { pathToFileURL } from 'node:url' import { createHash } from 'node:crypto' import { pinnedBashExecutable } from '../lib/shells/pinned.js' import { patchMsys, proxyDefinition } from '../lib/bash-pe.js' @@ -111,4 +112,26 @@ await test('a tampered cached generation is rejected, quarantined and rebuilt', assert.equal(bads()<=2,true) }) }) +await test('malformed IPC proxy receipts fail before staging a runtime', async () => { + await withTempDir('bash-ipc-receipt', async root => { + const plugin = path.join(root, 'plugin'), payload = path.join(root, 'payload'), home = path.join(root, 'home') + fs.mkdirSync(path.join(plugin, 'lib/shells'), { recursive: true }) + fs.mkdirSync(path.join(plugin, 'runtime/win32-x64/bash-ipc'), { recursive: true }) + fs.mkdirSync(path.join(payload, 'usr/bin'), { recursive: true }) + fs.writeFileSync(path.join(plugin, 'package.json'), JSON.stringify({ type: 'module' })) + for (const file of ['bash-ipc.js', 'bash-pe.js', 'shells/types.js']) { + fs.copyFileSync(path.resolve('lib', file), path.join(plugin, 'lib', file)) + } + const dll = Buffer.from('receipt validation must precede PE parsing') + fs.writeFileSync(path.join(payload, 'usr/bin/msys-2.0.dll'), dll) + fs.writeFileSync(path.join(plugin, 'runtime/win32-x64/bash-ipc/provenance.json'), JSON.stringify({ + platform: 'win32-x64', msysSha256: createHash('sha256').update(dll).digest('hex'), files: null, + })) + const isolated = await import(pathToFileURL(path.join(plugin, 'lib/bash-ipc.js')).href) + assert.throws(() => isolated.confinedBash(path.join(payload, 'usr/bin/bash.exe'), { home, platform: 'win32' }), + /^Error: EXECUTOR_UNAVAILABLE: This MSYS version has no verified DSH IPC compatibility build/) + assert.equal(fs.existsSync(home), false, 'invalid receipts cannot create a private runtime') + assert.deepEqual(fs.readFileSync(path.join(payload, 'usr/bin/msys-2.0.dll')), dll) + }) +}) report('bash-ipc') From d415a7a243c11bf6a0a076647f3015cff19a8ff7 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:08:13 +0800 Subject: [PATCH 074/118] refactor(authority): migrate security helpers to strict TypeScript --- docs/CHANGELOG.md | 11 ++++ docs/CONTRIBUTING.md | 2 + docs/configuration-reference.md | 2 + ...41\347\214\256\346\214\207\345\215\227.md" | 2 + ...15\347\275\256\345\217\202\350\200\203.md" | 2 + ...{authority-paths.js => authority-paths.ts} | 9 +-- ...dential-policy.js => credential-policy.ts} | 34 ++++++----- src/permissions.js | 42 -------------- src/permissions.ts | 55 ++++++++++++++++++ test/authority.test.mjs | 18 ++++++ test/credential-denial.test.mjs | 58 +++++++++++++++++++ test/types/authority.ts | 41 +++++++++++++ test/types/tsconfig.json | 3 +- 13 files changed, 216 insertions(+), 63 deletions(-) rename src/{authority-paths.js => authority-paths.ts} (69%) rename src/{credential-policy.js => credential-policy.ts} (81%) delete mode 100644 src/permissions.js create mode 100644 src/permissions.ts create mode 100644 test/credential-denial.test.mjs create mode 100644 test/types/authority.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 986941ee..bd8fe656 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,17 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Strict authority types ([16/16]) + +- Migrate credential fences, trusted path anchors and permission failures to strict TypeScript without changing emitted entrypoints or authority decisions. Preserve worker effect facts during denial normalization and assert typed host modes/unknown-input narrowing. +- 将凭据围栏、可信路径锚点和权限错误迁移为严格 TypeScript,保持生成入口和权限决策不变;拒绝归一化保留 worker 效果事实,并验证宿主模式及未知输入的类型缩窄。 + +### Keep credential denial paths private (#261, BM-05) + +- Omit denied paths and matching policy roots from shared preflight refusal messages while retaining rejection codes, effect state and no-retry guidance for Native and PTC. +- 凭据预检拒绝不再返回被拒路径与匹配规则;保留 Native/PTC 的拒绝代码、无副作用状态及禁止重试指引,权限边界不变。 + + - Check collected subprocess readers explicitly; absent output after execution remains unknown and retains PTC audit identity. 显式检查子进程输出读取器;运行后缺失输出时保留未知副作用及 PTC 审计标识。 - Type the confined Bash and workspace-preparation boundaries; reject malformed IPC proxy receipts before staging a runtime. 严格类型化受限 Bash 与工作区准备边界;代理收据格式错误时,在暂存运行时前明确拒绝。 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 34f25728..7013142a 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -192,3 +192,5 @@ reproduced in `NOTICE` and the READMEs **must stay verbatim** — do not "polish Native build and acceptance details: [native-ptc.md](native-ptc.md) and [manual-validation.md](manual-validation.md). + +The credential fence, trusted path anchors and permission helpers are strict TypeScript modules (`src/credential-policy.ts`, `src/authority-paths.ts`, `src/permissions.ts`). Their emitted `.js` imports and wire failures stay stable. Authority policies use type-only host definitions; unknown worker failures must be narrowed before use. `node test/run.mjs kernel-types authority credential-fence credential-denial safety-atomicity permissions` covers the typed boundary and existing authority behavior. diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 02224b50..5e913b7e 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -44,6 +44,8 @@ A `dsh-ops/background` row left over from an earlier version loads as a no-op st ### The credential fence (`denyCredentialReads`) +Preflight credential denials retain `rejected`, `CREDENTIAL_DENIED`, no-effect and no-retry guidance. Their Native text and PTC values omit the denied path, resolved parents and matching policy rules. + The key name is historical; the fence itself is on the **path**, not on one direction of access. A credential location is refused as a read (`inspect_local_file`, and `grep`/`glob` never report or list one), as a write (`write`, `edit`, `replace`, `remove`), and as the destination of `copy`/`move` — diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 827c6db1..9f17ed59 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -126,3 +126,5 @@ npm run build:bash-ipc # 仅 Windows:Bash IPC 除 `licenses/fastctx/` 外均为 MIT;FastCtx 为 Apache-2.0。`NOTICE` 与 README 中上游要求逐字转载的致谢**必须原样保留**,不要「顺手润色」。 原生组件的编译与验收细节见 [native-ptc.md](native-ptc.md) 与 [manual-validation.md](manual-validation.md)。 + +凭据围栏、可信路径锚点和权限辅助函数已迁移为严格 TypeScript 模块(`src/credential-policy.ts`、`src/authority-paths.ts`、`src/permissions.ts`),生成的 `.js` 导入路径及线上错误结构保持不变。权限策略仅以类型方式引用宿主定义;未知 worker 错误必须先缩窄类型才能使用。运行 `node test/run.mjs kernel-types authority credential-fence credential-denial safety-atomicity permissions` 验证类型边界与既有权限行为。 diff --git "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" index ba954397..7ea28a6f 100644 --- "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" +++ "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" @@ -44,6 +44,8 @@ Bundle 声明六个组件,分别启停;首次安装时 shell、file 与 pane ### 凭据围栏(`denyCredentialReads`) +凭据预检拒绝仍返回 `rejected`、`CREDENTIAL_DENIED`、无副作用及禁止重试的指引。Native 文本和 PTC 返回值不披露被拒路径、解析后的父目录或匹配规则。 + 键名是历史遗留;围栏本身拦的是**路径**,不是某一个方向。凭据位置在**读**(`inspect_local_file`,且 `grep`/`glob` 从不报告或列出它)、**写及删除**(`write`、`edit`、`replace`、`remove`)以及作为 `copy`/`move` 的**目标**时都会被拒绝,覆盖单个路径、batch 的操作根以及递归的每个子项,`danger-full-access` 下也一样。被拒的操作返回 `CREDENTIAL_DENIED`、`phase: preflight`、`effectState: none`,并且发生在 native worker 被拉起之前。`denyCredentialReads: false` 只对该行关闭围栏,`DSH_OPS_DENY_READ` 只能扩大清单。**新建** `.env` 或 `~/.ssh/config` 也无法通过这些工具完成,详见[常见问题](常见问题.md)。 **已弃用的键。** `serverName`、`enableShellTools`、`toolCallTimeoutMs` 只被已移除的 FastCtx 后台组件读取。它们仍被接受(并仍做类型校验),以便既有配置行照常加载——早期 bundle 会把 `enableShellTools: false` 和 `toolCallTimeoutMs: 300000` 写进每个 file 行——但已不起作用,配置更新 2 会在下次启动时把它们从已保存的行中删除。 diff --git a/src/authority-paths.js b/src/authority-paths.ts similarity index 69% rename from src/authority-paths.js rename to src/authority-paths.ts index 26c2ef38..2bea8c0f 100644 --- a/src/authority-paths.js +++ b/src/authority-paths.ts @@ -1,23 +1,24 @@ /** Resolve only host-owned anchors; caller-selected descendants retain native no-follow checks. */ +import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import fs from 'node:fs' import path from 'node:path' import { tmpdir } from 'node:os' -export function inside(root, target) { +export function inside(root: string, target: string): boolean { const relative = path.relative(path.resolve(root), path.resolve(target)) return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) } -export function canonicalAnchor(root) { +export function canonicalAnchor(root: string): string { try { return fs.realpathSync.native(root) } catch { return path.resolve(root) } } /** Never realpath a model-selected parent or leaf, even if it already exists. */ -export function anchoredPath(target, anchors) { +export function anchoredPath(target: string, anchors: readonly string[]): string { const anchor = anchors.filter(root => inside(root, target)).sort((a, b) => b.length - a.length)[0] return anchor ? path.join(canonicalAnchor(anchor), path.relative(anchor, target)) : target } -export function sessionAnchors(policy, cwd) { +export function sessionAnchors(policy: Pick, cwd?: string): string[] { return [policy.workspaceRoot, ...(cwd ? [cwd] : []), tmpdir(), ...(process.platform === 'win32' ? [] : ['/tmp'])] } diff --git a/src/credential-policy.js b/src/credential-policy.ts similarity index 81% rename from src/credential-policy.js rename to src/credential-policy.ts index da5b1b15..1b7e3e0a 100644 --- a/src/credential-policy.js +++ b/src/credential-policy.ts @@ -20,6 +20,9 @@ import path from 'node:path' /** Environment variable naming extra denied paths, separated by the platform delimiter. */ export const DENY_ENV = 'DSH_OPS_DENY_READ' +export interface CredentialOptions { home?: string; env?: NodeJS.ProcessEnv } +export type CredentialVerdict = { denied: true; matched: string } | { denied: false } + /** * Credential files and directories, as path suffixes below the user's home. * @@ -62,7 +65,7 @@ const CREDENTIAL_NAMES = Object.freeze([ ]) /** Extra suffixes from the environment, resolved once per call so a deployment can add its own. */ -function configuredSuffixes(env) { +function configuredSuffixes(env: NodeJS.ProcessEnv): string[] { const raw = env?.[DENY_ENV] if (typeof raw !== 'string' || raw.trim() === '') return [] return raw.split(path.delimiter).map(entry => entry.trim()).filter(entry => entry !== '') @@ -71,16 +74,14 @@ function configuredSuffixes(env) { /** * The extra denied locations from the environment, as `[comparisonForm, label]` pairs. * - * An **absolute** entry is used as written; a relative one is resolved below the home. The refusal - * message prints the absolute path it refused, so the natural thing for an operator to paste back - * into `DSH_OPS_DENY_READ` is that absolute path — joining it onto the home instead would make the - * documented way out silently do nothing. An absolute entry also works with no home at all. + * An absolute entry is used as written; a relative one is resolved below the home. + * Absolute entries work without a home. Labels stay internal and are never included in refusals. * @param {NodeJS.ProcessEnv} env - the environment to read. * @param {string|undefined} resolvedHome - the home directory for relative entries. * @returns {Array<[string, string]>} the comparison form and the entry as written. */ -function configuredRoots(env, resolvedHome) { - const roots = [] +function configuredRoots(env: NodeJS.ProcessEnv, resolvedHome: string | undefined): Array<[string, string]> { + const roots: Array<[string, string]> = [] for (const entry of configuredSuffixes(env)) { if (path.isAbsolute(entry)) { roots.push([canonical(entry), entry]); continue } if (typeof resolvedHome === 'string' && resolvedHome !== '') roots.push([canonical(path.join(resolvedHome, entry)), entry]) @@ -95,7 +96,7 @@ function configuredRoots(env, resolvedHome) { * @param {string} value - the path. * @returns {string} the comparison form. */ -function canonical(value) { +function canonical(value: string): string { let resolved = path.resolve(value) try { resolved = fs.realpathSync.native(resolved) } catch { // Resolve the nearest existing parent too: a missing leaf below an 8.3 @@ -115,10 +116,10 @@ function canonical(value) { * @param {NodeJS.ProcessEnv} [options.env] - the environment to read extra entries from. * @returns {{denied: true, matched: string}|{denied: false}} the verdict and the suffix that matched. */ -export function credentialPath(candidate, { home, env = process.env } = {}) { +export function credentialPath(candidate: unknown, { home, env = process.env }: CredentialOptions = {}): CredentialVerdict { if (typeof candidate !== 'string' || candidate === '') return { denied: false } const target = canonical(candidate) - const roots = [] + const roots: Array<[string, string]> = [] const resolvedHome = home ?? env?.HOME ?? env?.USERPROFILE if (typeof resolvedHome === 'string' && resolvedHome !== '') { for (const suffix of CREDENTIAL_SUFFIXES) roots.push([canonical(path.join(resolvedHome, suffix)), suffix]) @@ -145,9 +146,9 @@ export function credentialPath(candidate, { home, env = process.env } = {}) { * @param {object} [options] - the same inputs as {@link credentialPath}. * @returns {string[]} the absolute directories, deduplicated. */ -export function credentialRoots({ home, env = process.env } = {}) { +export function credentialRoots({ home, env = process.env }: CredentialOptions = {}): string[] { const resolvedHome = home ?? env?.HOME ?? env?.USERPROFILE - const roots = new Set() + const roots = new Set() if (typeof resolvedHome === 'string' && resolvedHome !== '') { for (const suffix of CREDENTIAL_SUFFIXES) roots.add(canonical(path.join(resolvedHome, suffix))) } @@ -160,14 +161,15 @@ export function credentialRoots({ home, env = process.env } = {}) { * out, because "denied" without either invites a retry loop, and it names both * directions: a caller stopped from writing must not read the message as "reads * are fenced" (#204 P4). - * @param {string} candidate - the denied path. - * @param {string} matched - the suffix or pattern that matched. + * Denied paths and matching rules are intentionally absent: either can reveal a real parent path. + * @param {string} _candidate - ignored; retained for internal call compatibility. + * @param {string} _matched - ignored; the refusal does not disclose the matching rule. * @returns {{code: string, message: string, nextAction: string}} the refusal. */ -export function credentialRefusal(candidate, matched) { +export function credentialRefusal(_candidate: string, _matched: string) { return { code: 'POLICY_DENIED', - message: `${candidate} is inside a credential location (${matched}); the dsh-ops file tools neither read nor write credentials: reads, writes, copy/move destinations, replace targets and removal are refused.`, + message: 'The requested path is inside a credential location; the dsh-ops file tools neither read nor write credentials: reads, writes, copy/move destinations, replace targets and removal are refused.', nextAction: 'Use a path inside the workspace. An operator who really needs this path can add it to ' + `${DENY_ENV} (which only widens the list) or disable the fence with denyCredentialReads: false.`, } diff --git a/src/permissions.js b/src/permissions.js deleted file mode 100644 index da15800d..00000000 --- a/src/permissions.js +++ /dev/null @@ -1,42 +0,0 @@ -/** Shared DSH permission vocabulary. Model inputs never select an authority mode. */ -export const MODES = ['read-only', 'workspace-write', 'danger-full-access'] -export function permissionError(policy, operation, required = 'workspace-write', message = '', nextAction) { - return { - code: 'POLICY_DENIED', phase: 'preflight', retryable: false, effectState: 'none', - permission: { current: policy.mode, required, operation, workspaceRoot: policy.workspaceRoot }, - message: `User authorization does not permit ${operation} in DSH mode ${policy.mode}. ${message}`.trim(), - nextAction: nextAction ?? `Ask the user to change the DSH permission selector to ${required}. Do not retry, change directories, or bypass the sandbox.`, - } -} -export function denied(policy, operation, required, message, nextAction) { - return { status: 'rejected', error: permissionError(policy, operation, required, message, nextAction), sandbox: { mode: policy.mode } } -} - -/** A changed snapshot is not proof that the current authorization is insufficient. */ -export function authorityChanged(policy, operation) { - return { status: 'error', error: { - code: 'AUTHORITY_CHANGED', phase: 'preflight', retryable: false, effectState: 'none', - message: `DSH authority changed while preparing ${operation}; no operation was started.`, - nextAction: 'Confirm the current session authority before making a new request. Do not blindly retry or bypass confinement.', - }, sandbox: { mode: policy?.mode } } -} - -export function executionRestriction(operation) { - return { code: 'EXECUTION_RESTRICTED', phase: 'execute', retryable: false, effectState: 'unknown', - message: `${operation} encountered an execution restriction; stderr alone does not establish a DSH user authorization denial.`, - nextAction: 'Inspect postconditions and host path, platform and OS permissions. Do not blindly retry or bypass confinement; changing the DSH selector may not resolve this restriction.' } -} - -/** Only additive, recognized native reasons prove a selector denial; legacy codes alone do not. */ -export function normalizeNativeDenial(value, policy, operation, required) { - if (value?.error?.code !== 'POLICY_DENIED' || !['read-only', 'write-roots'].includes(value.error.reason)) return value - const permission = permissionError(policy, operation, value.error.reason === 'write-roots' ? 'danger-full-access' : required) - return { ...value, status: 'rejected', error: { ...value.error, ...permission, - phase: value.error.phase ?? permission.phase, effectState: value.error.effectState ?? permission.effectState, - nextAction: `${permission.nextAction} Inspect any reported effects before a new attempt.` } } -} - -export function credentialDenied(message) { - return { status: 'rejected', error: { code: 'CREDENTIAL_DENIED', phase: 'preflight', retryable: false, effectState: 'none', message, - nextAction: 'Choose a non-credential path. Do not retry or bypass the credential fence; changing the DSH permission selector does not remove it.' } } -} diff --git a/src/permissions.ts b/src/permissions.ts new file mode 100644 index 00000000..f9c6f22b --- /dev/null +++ b/src/permissions.ts @@ -0,0 +1,55 @@ +/** Shared DSH permission vocabulary. Model inputs never select an authority mode. */ +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' + +export type AuthorityPolicy = Pick +export const MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access'] +export function permissionError(policy: AuthorityPolicy, operation: string, required: SandboxMode = 'workspace-write', message = '', nextAction?: string) { + return { + code: 'POLICY_DENIED' as const, phase: 'preflight' as const, retryable: false as const, effectState: 'none' as const, + permission: { current: policy.mode, required, operation, workspaceRoot: policy.workspaceRoot }, + message: `User authorization does not permit ${operation} in DSH mode ${policy.mode}. ${message}`.trim(), + nextAction: nextAction ?? `Ask the user to change the DSH permission selector to ${required}. Do not retry, change directories, or bypass the sandbox.`, + } +} +export function denied(policy: AuthorityPolicy, operation: string, required?: SandboxMode, message?: string, nextAction?: string) { + return { status: 'rejected' as const, error: permissionError(policy, operation, required, message, nextAction), sandbox: { mode: policy.mode } } +} + +/** A changed snapshot is not proof that the current authorization is insufficient. */ +export function authorityChanged(policy: Pick | undefined, operation: string) { + return { status: 'error' as const, error: { + code: 'AUTHORITY_CHANGED' as const, phase: 'preflight' as const, retryable: false as const, effectState: 'none' as const, + message: `DSH authority changed while preparing ${operation}; no operation was started.`, + nextAction: 'Confirm the current session authority before making a new request. Do not blindly retry or bypass confinement.', + }, sandbox: { mode: policy?.mode } } +} + +export function executionRestriction(operation: string) { + return { code: 'EXECUTION_RESTRICTED' as const, phase: 'execute' as const, retryable: false as const, effectState: 'unknown' as const, + message: `${operation} encountered an execution restriction; stderr alone does not establish a DSH user authorization denial.`, + nextAction: 'Inspect postconditions and host path, platform and OS permissions. Do not blindly retry or bypass confinement; changing the DSH selector may not resolve this restriction.' } +} + +type NormalizedDenial = Omit & { + status: 'rejected' + error: Omit, 'phase' | 'effectState'> & Record & { phase: unknown; effectState: unknown } +} +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} +/** Only additive, recognized native reasons prove a selector denial; legacy codes alone do not. */ +export function normalizeNativeDenial>(value: T, policy: AuthorityPolicy, operation: string, required?: SandboxMode): T | NormalizedDenial +export function normalizeNativeDenial(value: unknown, policy: AuthorityPolicy, operation: string, required?: SandboxMode): unknown +export function normalizeNativeDenial(value: unknown, policy: AuthorityPolicy, operation: string, required?: SandboxMode): unknown { + if (!record(value) || !record(value.error) || value.error.code !== 'POLICY_DENIED' + || (value.error.reason !== 'read-only' && value.error.reason !== 'write-roots')) return value + const permission = permissionError(policy, operation, value.error.reason === 'write-roots' ? 'danger-full-access' : required) + return { ...value, status: 'rejected' as const, error: { ...value.error, ...permission, + phase: value.error.phase ?? permission.phase, effectState: value.error.effectState ?? permission.effectState, + nextAction: `${permission.nextAction} Inspect any reported effects before a new attempt.` } } +} + +export function credentialDenied(message: string) { + return { status: 'rejected' as const, error: { code: 'CREDENTIAL_DENIED' as const, phase: 'preflight' as const, retryable: false as const, effectState: 'none' as const, message, + nextAction: 'Choose a non-credential path. Do not retry or bypass the credential fence; changing the DSH permission selector does not remove it.' } } +} diff --git a/test/authority.test.mjs b/test/authority.test.mjs index 98be3105..ad4599a7 100644 --- a/test/authority.test.mjs +++ b/test/authority.test.mjs @@ -47,6 +47,24 @@ await test('native normalization requires a recognized stable reason and preserv } }) +await test('untrusted native reason shapes never invent permission advice or lose execution facts', () => { + const policy = { mode: 'workspace-write', workspaceRoot: process.cwd() } + for (const value of [undefined, null, 0, 'error', [], {}, { error: null }, { error: [] }, + { error: { code: 'POLICY_DENIED', reason: ['write-roots'] } }, + { error: { code: 'POLICY_DENIED', reason: { toString: () => 'write-roots' } } }]) { + assert.equal(normalizeNativeDenial(value, policy, 'copy'), value) + } + const value = { status: 'error', changed: 1, receipt: { complete: false }, error: { + code: 'POLICY_DENIED', reason: 'write-roots', phase: 'publish', effectState: 'partial', customReceipt: { written: 1 }, + } } + const normalized = normalizeNativeDenial(value, policy, 'copy') + assert.equal(normalized.receipt, value.receipt) + assert.equal(normalized.error.customReceipt, value.error.customReceipt) + assert.equal(normalized.error.phase, 'publish'); assert.equal(normalized.error.effectState, 'partial') + assert.equal(normalized.error.permission.required, 'danger-full-access') + assert.equal(value.status, 'error', 'normalization never mutates the original worker result') +}) + await test('missing sessions and credentials are not selector authorization failures', async () => { for (const call of [fileCall, ptcCall]) { const value = await call({}, 'unused', 'stat', {}, {}, signal()) diff --git a/test/credential-denial.test.mjs b/test/credential-denial.test.mjs new file mode 100644 index 00000000..e509ccc3 --- /dev/null +++ b/test/credential-denial.test.mjs @@ -0,0 +1,58 @@ +import fs from 'node:fs' +import path from 'node:path' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { requireWorker } from './lib/boundary.mjs' +import { bootLoop } from './lib/agent-loop.mjs' +import { credentialRefusal, credentialPath, DENY_ENV } from '../lib/credential-policy.js' +import { publicToolName } from '../lib/policy.js' +requireWorker('credential-denial') +function privateRefusal(value, forbidden) { + assert.equal(value.status, 'rejected', JSON.stringify(value)) + assert.equal(value.error.code, 'CREDENTIAL_DENIED') + assert.equal(value.error.effectState, 'none') + assert.match(value.error.nextAction, /Do not retry or bypass/) + for (const item of forbidden) assert.equal(JSON.stringify(value).includes(JSON.stringify(item).slice(1, -1)), false, 'denial must not disclose paths, rules or contents') +} +await test('credential refusal omits both a resolved path and a configured absolute matching rule', () => { + const root = path.resolve('private-parent', 'configured-secret-root'), target = path.join(root, 'token.json') + const refusal = credentialRefusal(target, root) + assert.match(refusal.message, /neither read nor write/) + assert.equal(JSON.stringify(refusal).includes(JSON.stringify(root).slice(1, -1)), false) + assert.equal(JSON.stringify(refusal).includes('token.json'), false) +}) +for (const ptcMode of [false, true]) await test(`real ${ptcMode ? 'PTC' : 'Native'} denials preserve authority without disclosing external credential paths`, () => withTempDir('credential-private', async temporary => { + const root = path.join(temporary, 'workspace'), outside = path.join(temporary, 'private-store'), target = path.join(outside, 'config') + fs.mkdirSync(root); fs.mkdirSync(outside); fs.writeFileSync(target, 'PRIVATE_FIXTURE_SECRET') + const previous = process.env[DENY_ENV] + process.env[DENY_ENV] = [previous, outside].filter(Boolean).join(path.delimiter) + let host + try { + assert.equal(credentialPath(target).denied, true) + host = await bootLoop(root, { ptcMode, discoveryConfig: { alwaysLoaded: ['write_file'] } }) + const forbidden = [temporary, path.dirname(temporary), outside, target, 'PRIVATE_FIXTURE_SECRET'] + const scripts = ptcMode ? [[{ name: 'run_code', args: { description: 'Verify private credential denials', code: + `return { read: await tools.${publicToolName('inspect_local_file')}({path:${JSON.stringify(target)}}), store: await tools.${publicToolName('result')}({action:'put',root:${JSON.stringify(outside)},data:'MUST_NOT_WRITE'}) };` } }], []] + : [[{ name: publicToolName('read_many'), args: { files: [{ path: target }] } }, + { name: publicToolName('write_file'), args: { path: target, content: 'MUST_NOT_WRITE', expectedRevision: 'absent' } }], []] + const run = await host.run(await host.agent(), scripts) + if (ptcMode) { + const result = run.results.find(item => item.exec.name === 'run_code').result + assert.equal(result.isError, false, JSON.stringify(result)) + privateRefusal(result.value.result.read, forbidden); privateRefusal(result.value.result.store, forbidden) + } else { + assert.equal(run.results.length, 2) + for (const { result } of run.results) { + assert.equal(result.isError, false); privateRefusal(result.value, forbidden) + const text = result.content.map(block => block.text ?? '').join('\n') + for (const item of forbidden) assert.equal(text.includes(item), false) + } + } + assert.equal(fs.readFileSync(target, 'utf8'), 'PRIVATE_FIXTURE_SECRET') + assert.deepEqual(fs.readdirSync(outside), ['config'], 'denials create no store or business files') + } finally { + await host?.close() + if (previous === undefined) delete process.env[DENY_ENV] + else process.env[DENY_ENV] = previous + } +})) +report('credential-denial') diff --git a/test/types/authority.ts b/test/types/authority.ts new file mode 100644 index 00000000..9266397e --- /dev/null +++ b/test/types/authority.ts @@ -0,0 +1,41 @@ +import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import { credentialPath, credentialRoots } from '../../src/credential-policy.js' +import { anchoredPath, sessionAnchors } from '../../src/authority-paths.js' +import { authorityChanged, credentialDenied, denied, executionRestriction, normalizeNativeDenial, permissionError } from '../../src/permissions.js' + +const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: '/work' } +const error = permissionError(policy, 'copy') +const rejected: 'rejected' = denied(policy, 'copy').status +const code: 'POLICY_DENIED' = error.code +const effect: 'none' = error.effectState +const credentialCode: 'CREDENTIAL_DENIED' = credentialDenied('denied').error.code +const restrictionEffect: 'unknown' = executionRestriction('copy').effectState +const changedCode: 'AUTHORITY_CHANGED' = authorityChanged(undefined, 'copy').error.code +const verdict = credentialPath('/work/file', { env: {} }) +if (verdict.denied) { const matched: string = verdict.matched; void matched } +else { + // @ts-expect-error A permitted path has no matching credential rule. + void verdict.matched +} +const roots: string[] = credentialRoots({ home: '/home', env: {} }) +const absolute: string = anchoredPath('/work/file', roots) +const anchors: string[] = sessionAnchors(policy) +const original = { changed: 1, receipt: { complete: false }, status: 'error', error: { code: 'POLICY_DENIED', reason: 'write-roots' } } +const normalized = normalizeNativeDenial(original, policy, 'copy') +const count: number = normalized.changed +const complete: boolean = normalized.receipt.complete +const untrusted: unknown = { error: null } +const unchecked = normalizeNativeDenial(untrusted, policy, 'copy') +// @ts-expect-error Unknown worker data must be narrowed before its properties are consumed. +void unchecked.status +// @ts-expect-error Policy mode must use the host selector vocabulary. +permissionError({ mode: 'allow', workspaceRoot: '/work' }, 'copy') +// @ts-expect-error A required mode is not model-selected free text. +permissionError(policy, 'copy', 'retry') +// @ts-expect-error A policy without an authoritative workspace root is incomplete. +denied({ mode: 'workspace-write' }, 'copy') +// @ts-expect-error Trusted anchors must contain paths, never coerced scalars. +anchoredPath('/work/file', [42]) +// @ts-expect-error Credential environments contain string values, not booleans. +credentialRoots({ env: { DSH_OPS_DENY_READ: true } }) +void [rejected, code, effect, credentialCode, restrictionEffect, changedCode, absolute, anchors, count, complete] diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json index b9bb3e92..d4fa4ee1 100644 --- a/test/types/tsconfig.json +++ b/test/types/tsconfig.json @@ -8,6 +8,7 @@ "kernel.ts", "ptc-sdk.ts", "worker-runtime.ts", - "tui.ts" + "tui.ts", + "authority.ts" ] } From 876e95ba7c249fbae5430be826072d9836f68cf6 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:58:45 +0800 Subject: [PATCH 075/118] feat(config): preserve profiles across seven-row defaults --- cordis.patch.yml | 29 +++++--- docs/CHANGELOG.md | 3 + docs/component-reference.md | 9 +-- docs/configuration-reference.md | 29 +++++--- docs/discovery.md | 2 +- ...45\345\205\267\345\217\221\347\216\260.md" | 2 +- ...04\344\273\266\345\217\202\350\200\203.md" | 9 +-- ...15\347\275\256\345\217\202\350\200\203.md" | 27 ++++--- dsh-plugin.json | 15 +++- scripts/validate-manifest.mjs | 13 ++-- src/components/defaults.ts | 8 +++ src/components/file/index.js | 3 - src/components/file/index.ts | 5 ++ src/components/panel/index.ts | 7 +- src/components/ptc/index.js | 3 - src/components/ptc/index.ts | 12 ++++ src/components/shell/index.ts | 2 +- src/components/tui/index.js | 1 + src/config-updates.js | 5 ++ src/config.js | 2 + src/index.js | 1 + src/official-tool-catalog.ts | 3 +- src/panel/collect.js | 2 +- src/settings/file-config.ts | 11 +++ src/settings/tui-config.ts | 7 ++ test/bundle-startup.test.mjs | 14 ++-- test/component-migration.test.mjs | 71 +++++++++++++++++++ test/lib/component-profile.mjs | 37 ++++++++++ test/manifest.test.mjs | 8 +-- test/official-tool-hiding.test.mjs | 2 +- test/ops-panel-collect.test.mjs | 7 +- test/platform-defaults.test.mjs | 45 +++++++----- test/settings-host.test.mjs | 38 +++++++++- test/shells-patch.test.mjs | 15 ++-- 34 files changed, 344 insertions(+), 103 deletions(-) create mode 100644 src/components/defaults.ts delete mode 100644 src/components/file/index.js create mode 100644 src/components/file/index.ts delete mode 100644 src/components/ptc/index.js create mode 100644 src/components/ptc/index.ts create mode 100644 src/settings/file-config.ts create mode 100644 src/settings/tui-config.ts create mode 100644 test/component-migration.test.mjs create mode 100644 test/lib/component-profile.mjs diff --git a/cordis.patch.yml b/cordis.patch.yml index 2498035d..aba166ca 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -1,15 +1,13 @@ - -# Six independent components. PTC enhancement and shell are enabled by default; -# PTC capabilities are still published only to authoritative PTC-only sessions. -# The read-only TUI panel and per-tool official replacements remain opt-in. -# The panel row is inserted enabled so its config is addressable in the settings -# editor, and its own switch (`capture`, the settings item 「启动数据检测」) is off -# by default: an untouched row registers nothing and writes nothing. -# Explicit existing profile choices are not migrated or overwritten. -# The former dsh-ops/background row is no longer inserted; existing rows load as a no-op stub. +# Seven stable component IDs. Saved profile row overrides take precedence. +# Fresh shell defaults on Windows only; the Loader evaluates this public expression. +# File, PTC and discovery start enabled. Panel, TUI and official replacements are opt-in. +# Keep the legacy file specifier so name-qualified saved overrides still match. +# The removed background row remains a no-op compatibility export. - insert: - id: dsh-ops-shell name: dsh-ops/shell + disabled: + __jsExpr: "process.platform !== 'win32'" config: promptPolicy: true publishBashTool: true @@ -26,11 +24,20 @@ required: false allowedTools: [grep, glob, replace, write_file, edit_file, diff, stat, copy, move, remove, read_many, edit_many, batch] alwaysLoaded: [] + - id: dsh-ops-discovery + name: dsh-ops/discovery + config: + enabled: true + maxResults: 3 + alwaysLoaded: [] + required: false + denyCredentialReads: true - id: dsh-ops-panel name: dsh-ops/panel + disabled: true config: - # 「启动数据检测」: off by default; on writes /.dsh-ops/panel/. - capture: false + # Row enablement starts capture; explicit legacy capture:false stays inert. + capture: true textLimit: 2000 maxSessionBytes: 8388608 - id: dsh-ops-tui diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 866bbdca..b6f2beee 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,9 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Add discovery as the seventh bundle row; preserve existing named row overrides. Fresh defaults enable shell only on Windows and leave panel/TUI/replacements off. Enabling a fresh panel row starts capture; explicit legacy capture:false stays inert. Provide builtin validated file/PTC/TUI configuration. +- bundle 新增第七行 discovery,保留已有带名称行覆盖。首次安装仅 Windows 默认启用 shell,panel/TUI/替换默认关闭;启用新 panel 行即采集,旧配置 capture:false 保持不采集。补充文件/PTC/TUI 内置校验配置。 + - Stop panel collection and queued filesystem work when its row or capture switch is disabled; wait for issued I/O without scheduling a final fold. Expose validated capture settings through the host editor. - 关闭 panel 行或采集开关时停止采集及排队文件操作,等待已开始的 I/O,但不触发最终归并;宿主编辑器提供严格校验的采集设置。 diff --git a/docs/component-reference.md b/docs/component-reference.md index 7fc21b39..c60676ea 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -1,15 +1,16 @@ # Component reference -What each of the six components publishes, with parameters and permission requirements. +What each of the seven components publishes, with parameters and permission requirements. --- ## Components at a glance | Row `name` | Description | Default | Published tools | | --- | --- | --- | --- | -| `dsh-ops/shell` | Prefer bash as the terminal, with host PowerShell for operations bash cannot cover | On | `ops_bash`; host `pwsh` keeps its own executor | +| `dsh-ops/shell` | Prefer bash as the terminal, with host PowerShell for operations bash cannot cover | Windows on; Linux/macOS off | `ops_bash`; host `pwsh` keeps its own executor | | `dsh-ops/file` | Faster, higher-performance Rust file retrieval with more concise, token-efficient output | On | `ops_read_many`; advanced tools load through discovery | -| `dsh-ops/ptc` | Independent Rust safe batches and bounded result references, only in pure PTC sessions | Off | `ops_batch`, `ops_result` | -| `dsh-ops/panel` | Data capture, off at the switch: new conversations write `/.dsh-ops/panel/.json` plus the viewer `panel.html` | Row on, `capture: false` | None; see the [configuration reference](configuration-reference.md) | +| `dsh-ops/ptc` | Typed file APIs and progressive SDK in PTC sessions | On | `ops_inspect_local_file`, `ops_result`, `ops_sdk`; advanced APIs load through the SDK | +| `dsh-ops/discovery` | Advanced Native tools on demand | On | `ops_discover` | +| `dsh-ops/panel` | Data capture, off at the switch: new conversations write `/.dsh-ops/panel/.json` plus the viewer `panel.html` | Row off; capture defaults true | None; see the [configuration reference](configuration-reference.md) | | `dsh-ops/tui` | Read-only current-session OPS sidebar | Off | None; see [sidebar guide](tui-panel.md) | | `dsh-ops/hide-official` | Select read/glob/grep alternatives; unselected official tools remain visible | Off | None | diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index cf2d4d38..6a6ee9ec 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -2,18 +2,25 @@ Every configuration key, and **which component row owns it**. +## Built-in row configuration + +Active rows expose validated settings through the host Settings/ConfigEditor. Accepted changes persist through the profile writer and reload the owning row; stale or invalid changes preserve saved values. Disabled rows must first be re-enabled with the host row switch, and pending services must become available before editing. + +The default bundle retains `dsh-ops/file` and stable ID `dsh-ops-file`, so saved name-qualified overrides continue to match. It does not activate custom browser pages on the pinned host. + --- ## Where configuration lives -In DSH's configuration editor, find the corresponding `dsh-ops/shell`, `dsh-ops/file`, `dsh-ops/ptc` or `dsh-ops/panel` row, adjust `config`, then save; unknown configuration keys cause errors naming the key. -The bundle declares six independently toggled components. On first installation shell, file and the panel row are enabled, and the panel row's own switch (`capture`) is **off**: +In DSH's configuration editor, find the corresponding `dsh-ops/shell`, `dsh-ops/file` (`dsh-ops-file`), `dsh-ops/ptc` or `dsh-ops/panel` row, adjust `config`, then save; unknown configuration keys cause errors naming the key. +The bundle declares seven independently toggled components. Fresh defaults follow the table. Saved profile switches and raw configuration override bundle defaults; adding discovery does not rewrite them. Profiles without explicit switch overrides inherit updated defaults. | Row `name` | Component | Default | | --- | --- | --- | -| `dsh-ops/shell` | Terminal and shell | On | -| `dsh-ops/file` | Rust file tools | On | -| `dsh-ops/ptc` | Native PTC batches | Off | -| `dsh-ops/panel` | Data capture, off at the switch | Row on, `capture: false` | +| `dsh-ops/shell` | Terminal and shell | Windows on; Linux/macOS off | +| `dsh-ops/file` (`dsh-ops-file`) | Rust file tools | On | +| `dsh-ops/ptc` | Native PTC batches | On | +| `dsh-ops/discovery` | Advanced Native tool discovery | On | +| `dsh-ops/panel` | Capture subsequent events | Row off; enabling starts capture | | `dsh-ops/tui` | Read-only sidebar, separate config | Off | | `dsh-ops/hide-official` | Per-tool official replacements, separate config | Off | @@ -38,7 +45,7 @@ A `dsh-ops/background` row left over from an earlier version loads as a no-op st | `deniedHostTools` | string array | `[pwsh, bash, pwsh_persistent]` | shell | | `allowSystemShellFallback` | boolean | `false` | shell | | `denyCredentialReads` | boolean | `true` | file / ptc | -| `capture` | boolean | `false` | panel | +| `capture` | boolean | `true` | panel (row defaults off) | | `textLimit` | integer | `2000` | panel | | `maxSessionBytes` | integer | `8388608` | panel | @@ -81,11 +88,11 @@ config: # configUpdateVersion: 2 ``` -The `dsh-ops/panel` row — the settings item 「启动数据检测」 (`capture`) is **off by default**: +The `dsh-ops/panel` row is **disabled by default**. Enabling a fresh row starts capture; explicit legacy `capture: false` remains off until the operator changes it: ```yaml config: - capture: false # 「启动数据检测」: on captures NEW conversations only + capture: true # The row switch controls capture; legacy false stays inert textLimit: 2000 # characters kept per text field, marked when clamped maxSessionBytes: 8388608 ``` @@ -108,7 +115,7 @@ config: - `configUpdateVersion`: completion marker for the configuration migrations (currently `2`: update 1 retires the old implicit system-bash opt-in, update 2 removes the deprecated FastCtx keys). Do not remove it, or the self-check runs again. Disabling the row or saving `capture: false` stops event collection immediately, cancels queued filesystem work, and waits for already-started I/O before the host finishes the change. It does not start a final fold: existing NDJSON remains recoverable, and a temporary file from an interrupted fold may remain. Re-enabling captures only subsequent events; disabled events are not replayed. -- `capture` (「启动数据检测」): defaults to `false`, and **off means nothing happens** — the row registers no listener and creates no directory. When on, every *new* conversation writes into its own working directory: +- The panel row defaults disabled and registers no listener or file. Once enabled, `capture` defaults to `true`; an explicit legacy `false` still registers nothing and is never silently changed. The host cannot atomically clear that old field while disabling its row, so the compatibility setting remains visible for deliberate edits. Captured events write into the session working directory: - `/.dsh-ops/panel/.json` — the only data source: conversation text, the tool-call/result/milestone trace, usage buckets, and trimmings/limits; - `/.dsh-ops/panel/panel.html` — a static viewer that reads that JSON in the browser and inlines no data (drag the JSON onto it, or pass `?data=.json` under a static server). @@ -140,7 +147,7 @@ With the slim Native defaults, configure discovery `alwaysLoaded: [inspect_local **Migration from 0.3:** `strict`, `scope`, and `additionalOfficialTools` are accepted only for compatibility and validated with their old types/values. They grant no hiding authority, including `scope: extended` and aliases. An existing row containing only those keys becomes inert. Explicit `replaceTools` is the sole source of selections even if legacy keys remain. No saved profile is rewritten by this component. -Configuration is validated by Cordis through `StandardSchemaV1`. In the pinned DSH 0.2.0-rc.2 host, this does not automatically create a settings selector: a browser component registered in `plugins.row.config` is required. This slice supplies configuration-array selection; the browser multi-select remains separate UI work. +Cordis validates configuration through `StandardSchemaV1`; detached public settings descriptors expose editable arrays in the built-in Settings/ConfigEditor. Accepted changes persist and reload the row. The custom browser multi-select remains separate UI work. --- diff --git a/docs/discovery.md b/docs/discovery.md index eb3bde69..eac7bb1c 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -4,7 +4,7 @@ `dsh-ops/discovery` publishes `ops_discover` in each eligible Native scope. Describe a capability (`metadata revision hash`) or select an exact public name (`select:ops_stat`). Discovery only chooses tools; it performs no file action. A successful selection remains pending through the current tool round. Its owning session's committed `step/end` publishes it for the next provider request. The pinned host assembles before `agent/pre-step`; queued publication is therefore drained and the public prompt service reassembles a stale snapshot. Diagnostic assemblies before that committed boundary cannot activate pending names. Newly published tools remain scoped to their owner; parents, children and siblings must discover independently. -The component is exported now. The seven-row bundle installation and 0.3 profile migration are a separate release slice. A manually added row can use: +The seven-row bundle enables this component by default and preserves existing named row overrides. Its configuration is: ```yaml - name: dsh-ops/discovery diff --git "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" index f7a6d326..ba824bc0 100644 --- "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" +++ "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" @@ -4,7 +4,7 @@ English: [Advanced tools on demand](discovery.md). `dsh-ops/discovery` 在每个符合条件的 Native 作用域中发布 `ops_discover`。可描述所需能力(`metadata revision hash`),或指定完整公开名称(`select:ops_stat`)。发现仅选择工具,不执行文件操作。成功选择在当前工具轮次中保持待激活状态;所属会话提交 `step/end` 后,为紧接着的供应商请求发布。固定版本宿主先组装提示再触发 `agent/pre-step`,因此组装时等待本插件发布队列完成,并通过公开提示服务重新组装过期快照。提交边界前的诊断组装不会激活排队名称。注册只属于调用者;父、子和同级作用域都需独立发现。 -当前已导出组件。七行 bundle 安装和 0.3 profile 迁移属于后续发布切片。手动增加的行可使用: +七行 bundle 默认启用该组件,并保留已有带名称行覆盖。其配置如下: ```yaml - name: dsh-ops/discovery diff --git "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" index 71634315..cc08330c 100644 --- "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" +++ "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" @@ -1,15 +1,16 @@ # 组件参考 -六个组件分别发布什么工具、参数是什么、需要什么权限。 +七个组件分别发布什么工具、参数是什么、需要什么权限。 --- ## 组件总览 | 配置行 `name` | 介绍 | 默认 | 发布的工具 | | --- | --- | --- | --- | -| `dsh-ops/shell` | 优先使用 bash,无法覆盖时使用宿主 PowerShell | 开 | `ops_bash`;宿主 `pwsh` 保持其原执行器 | +| `dsh-ops/shell` | 优先使用 bash,无法覆盖时使用宿主 PowerShell | Windows 开;Linux/macOS 关 | `ops_bash`;宿主 `pwsh` 保持其原执行器 | | `dsh-ops/file` | 更快、更高性能、输出更精简更省 token 的 Rust 文件检索 | 开 | `ops_read_many`;高级工具通过发现加载 | -| `dsh-ops/ptc` | 独立 Rust 安全批处理和有界结果引用,仅纯 PTC 会话加载 | 关 | `ops_batch`、`ops_result` | -| `dsh-ops/panel` | 数据采集,开关默认关:新对话写入 `/.dsh-ops/panel/.json` 与读取它的 `panel.html` | 行开,`capture: false` | 无;见[配置参考](配置参考.md) | +| `dsh-ops/ptc` | PTC 会话的类型化文件 API 与渐进式 SDK | 开 | `ops_inspect_local_file`、`ops_result`、`ops_sdk`;高级 API 通过 SDK 加载 | +| `dsh-ops/discovery` | 按需加载 Native 高级工具 | 开 | `ops_discover` | +| `dsh-ops/panel` | 数据采集,开关默认关:新对话写入 `/.dsh-ops/panel/.json` 与读取它的 `panel.html` | 行关;capture 默认 true | 无;见[配置参考](配置参考.md) | | `dsh-ops/tui` | 当前会话的只读 OPS 侧栏 | 关 | 无;见 [侧栏说明](tui-panel.zh.md) | | `dsh-ops/hide-official` | 逐项选择 read/glob/grep 替代流程;未选官方工具保持可见 | 关 | 无 | diff --git "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" index 6afc66b1..3e22b71e 100644 --- "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" +++ "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" @@ -2,18 +2,25 @@ 按组件说明每个配置键,以及**哪个键属于哪个组件行**。 +## 内置行配置 + +活动行通过宿主 Settings/ConfigEditor 提供严格校验的设置。接受的更改通过 profile 写入器持久化并重载所属行;版本过期或无效更改不会修改已保存的值。关闭的行须先用宿主行开关重新启用,等待中的服务须先就绪,之后才能编辑。 + +默认 bundle 保留 `dsh-ops/file` 及稳定 ID `dsh-ops-file`,已有带名称覆盖继续匹配。固定宿主下,此默认配置不会启用自定义浏览器页面。 + --- ## 配置的位置 在 DSH 的配置编辑器中找到对应的 `dsh-ops/shell`、`dsh-ops/file`、`dsh-ops/ptc` 或 `dsh-ops/panel` 行,调整 `config` 后保存;未知配置键会点名报错。 -Bundle 声明六个组件,分别启停;首次安装时 shell、file 与 panel 行开启,而 panel 行自己的开关(`capture`)**默认关闭**: +Bundle 声明七个独立启停组件,首次安装默认值如下。已有 profile 的显式开关和原始配置覆盖 bundle 默认值;添加 discovery 不重写这些数据。未保存显式开关的行继承新版默认值。 | 配置行 `name` | 组件 | 默认 | | --- | --- | --- | -| `dsh-ops/shell` | 终端与 shell | 开 | -| `dsh-ops/file` | Rust 文件工具 | 开 | -| `dsh-ops/ptc` | 原生 PTC 批处理 | 关 | -| `dsh-ops/panel` | 数据采集,开关本身默认关 | 行开,`capture: false` | +| `dsh-ops/shell` | 终端与 shell | Windows 开;Linux/macOS 关 | +| `dsh-ops/file` (`dsh-ops-file`) | Rust 文件工具 | 开 | +| `dsh-ops/ptc` | 原生 PTC 批处理 | 开 | +| `dsh-ops/discovery` | Native 高级工具发现 | 开 | +| `dsh-ops/panel` | 采集后续事件 | 行关;启用即开始采集 | | `dsh-ops/tui` | 只读侧栏,独立配置 | 关 | | `dsh-ops/hide-official` | 逐项替换官方工具,独立配置 | 关 | @@ -38,7 +45,7 @@ Bundle 声明六个组件,分别启停;首次安装时 shell、file 与 pane | `deniedHostTools` | 字符串数组 | `[pwsh, bash, pwsh_persistent]` | shell | | `allowSystemShellFallback` | 布尔 | `false` | shell | | `denyCredentialReads` | 布尔 | `true` | file / ptc | -| `capture` | 布尔 | `false` | panel | +| `capture` | 布尔 | `true` | panel(行默认关) | | `textLimit` | 整数 | `2000` | panel | | `maxSessionBytes` | 整数 | `8388608` | panel | @@ -74,11 +81,11 @@ config: # configUpdateVersion: 2 ``` -`dsh-ops/panel` 行 —— 设置项「**启动数据检测**」(`capture`) **默认关闭**: +`dsh-ops/panel` 行**默认关闭**。启用新行即开始采集;旧配置显式 `capture: false` 保持关闭,需操作者自行更改: ```yaml config: - capture: false # 「启动数据检测」:打开后只采集之后的新对话 + capture: true # 行开关控制采集;旧配置 false 继续不采集 textLimit: 2000 # 每个文本字段保留的字符数,被截断时会标记 maxSessionBytes: 8388608 ``` @@ -101,7 +108,7 @@ config: - `configUpdateVersion`:配置迁移的完成标记(当前为 `2`:更新 1 撤销旧的隐式系统 bash 选择,更新 2 删除已弃用的 FastCtx 键)。不要删除迁移标记,否则会重新自检。 关闭组件行或保存 `capture: false` 会立即停止事件采集、取消尚未开始的文件操作,并在宿主完成变更前等待已开始的 I/O。停用不会触发最终归并;已有 NDJSON 可恢复,中断归并留下的临时文件可能保留。重新开启只采集后续事件,不补录关闭期间的事件。 -- `capture`(「启动数据检测」):默认 `false`,**关就是什么都不做**——该行不注册监听、不建目录。打开后,每个**新对话**写入它自己的工作目录: +- panel 行默认关闭,不注册监听、不写文件。启用后 `capture` 默认为 `true`;旧配置显式 `false` 仍不采集,不会被静默更改。宿主无法原子地清除旧字段并同时停用行,因此保留兼容设置,供操作者显式编辑。采集到的事件写入会话工作目录: - `/.dsh-ops/panel/.json` —— 唯一数据源:对话文本、tool_call/tool_result/里程碑轨迹、usage 分桶,以及截断与上限记录; - `/.dsh-ops/panel/panel.html` —— 静态查看器,在浏览器里读取该 JSON,**不内联任何数据**(把 JSON 拖到页面上,或经静态服务用 `?data=.json`)。 @@ -133,7 +140,7 @@ Native 默认只保留精简核心工具。选择上例前,应先为发现组 **从 0.3 迁移:** 仅为兼容旧配置,接受并按旧类型和取值校验 `strict`、`scope`、`additionalOfficialTools`;它们均不授予隐藏权限,包括 `scope: extended` 和别名列表。只有旧键的既有行会变为无操作。即使保留旧键,`replaceTools` 也是唯一选择来源。本组件不改写已保存的 profile。 -Cordis 通过 `StandardSchemaV1` 校验配置。在固定版本 DSH 0.2.0-rc.2 中,这不会自动生成设置选择器:必须由浏览器组件注册 `plugins.row.config`。本切片提供配置数组选择,浏览器多选界面仍需单独实现。 +Cordis 通过 `StandardSchemaV1` 校验配置;独立的公共设置描述在内置 Settings/ConfigEditor 中提供可编辑数组。成功保存后持久化并重载对应行。自定义浏览器多选界面仍需单独实现。 --- diff --git a/dsh-plugin.json b/dsh-plugin.json index b00fbc32..891dfa3d 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -116,7 +116,7 @@ { "id": "dsh-ops-shell", "entry": "dsh-ops/shell", - "defaultEnabled": true, + "defaultEnabled": { "win32": true, "linux": false, "darwin": false }, "platforms": ["win32", "linux", "darwin"], "targets": ["win32-x64", "linux-x64", "darwin-arm64"] }, @@ -127,11 +127,20 @@ "platforms": ["win32", "linux", "darwin"], "targets": ["win32-x64", "linux-x64", "darwin-arm64"] }, + { + "id": "dsh-ops-discovery", + "entry": "dsh-ops/discovery", + "defaultEnabled": true, + "platforms": ["win32", "linux", "darwin"], + "targets": ["win32-x64", "linux-x64", "darwin-arm64"], + "presentation": "Native scopes; activation on the next admitted model step", + "tools": ["ops_discover"] + }, { "id": "dsh-ops-panel", "entry": "dsh-ops/panel", - "defaultEnabled": true, - "presentation": "Opt-in static-file capture of new conversations: the row is addressable, its switch 「启动数据检测」(capture) is off by default and an untouched row registers nothing", + "defaultEnabled": false, + "presentation": "Disabled by default; enabling the row captures subsequent events. Explicit legacy capture:false stays off until changed.", "platforms": ["win32", "linux", "darwin"], "targets": ["win32-x64", "linux-x64", "darwin-arm64"], "tools": [] diff --git a/scripts/validate-manifest.mjs b/scripts/validate-manifest.mjs index a02c1f46..429273ff 100644 --- a/scripts/validate-manifest.mjs +++ b/scripts/validate-manifest.mjs @@ -31,7 +31,8 @@ import { resolvePtcConfig } from '../lib/ptc/index.js' import { resolveTuiConfig } from '../lib/components/tui/index.js' import { resolvePanelConfig } from '../lib/components/panel/index.js' import { PTC_API_TOOLS, definition } from '../lib/ptc/contract.js' -import { resolveConfig } from '../lib/config.js' +import { resolveConfig, resolveDiscoveryConfig } from '../lib/config.js' +import { COMPONENTS, componentEntry, componentEnabled, SHELL_DISABLED_EXPRESSION } from '../lib/components/defaults.js' import { resolveHidingConfig } from '../lib/official-tool-hiding.js' /** The repository root (`scripts/`'s parent). */ @@ -226,14 +227,14 @@ if (typeof patch === 'string' && fs.existsSync(path.join(PACKAGE_ROOT, patch))) const rows = Array.isArray(document) ? document.flatMap((entry) => (Array.isArray(entry?.insert) ? entry.insert : [])) : [] - check(rows.length === 6, 'the bundle declares exactly six components') + check(rows.length === 7, 'the bundle declares exactly seven components') check(!rows.some(candidate => candidate?.name === `${pkg.name}/background`), 'the bundle no longer inserts the removed background component') - for (const component of ['shell', 'file', 'ptc', 'panel', 'tui', 'hide-official']) { - const row = rows.find(candidate => candidate?.name === `${pkg.name}/${component}`) + for (const component of COMPONENTS) { + const row = rows.find(candidate => candidate?.name === componentEntry(component)) check(row?.id === `${pkg.name}-${component}`, `the ${component} component has a stable row id`) if (!row) continue - check((row.disabled === true) === ['tui', 'hide-official'].includes(component), `${component} has its documented enablement default`) - const resolve = component === 'tui' ? resolveTuiConfig + check(component === 'shell' ? row.disabled?.__jsExpr === SHELL_DISABLED_EXPRESSION : (row.disabled === true) === !componentEnabled(component, process.platform), `${component} has its documented enablement default`) + const resolve = component === 'discovery' ? resolveDiscoveryConfig : component === 'tui' ? resolveTuiConfig : component === 'ptc' ? resolvePtcConfig : component === 'hide-official' ? resolveHidingConfig : component === 'panel' ? resolvePanelConfig diff --git a/src/components/defaults.ts b/src/components/defaults.ts new file mode 100644 index 00000000..45a4da83 --- /dev/null +++ b/src/components/defaults.ts @@ -0,0 +1,8 @@ +/** Package defaults only. Saved Loader overrides take precedence over this layer. */ +export const COMPONENTS = ['shell', 'file', 'ptc', 'discovery', 'panel', 'tui', 'hide-official'] as const +export type Component = typeof COMPONENTS[number] +export const SHELL_DISABLED_EXPRESSION = "process.platform !== 'win32'" +export function componentEnabled(component: Component, platform: string): boolean { + return component === 'shell' ? platform === 'win32' : ['file', 'ptc', 'discovery'].includes(component) +} +export function componentEntry(component: Component): string { return `dsh-ops/${component}` } diff --git a/src/components/file/index.js b/src/components/file/index.js deleted file mode 100644 index ef3a6443..00000000 --- a/src/components/file/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { applyComponent } from "../../index.js" -export const name = "dsh-ops-file" -export function apply(ctx, config) { return applyComponent(ctx, config, "file") } diff --git a/src/components/file/index.ts b/src/components/file/index.ts new file mode 100644 index 00000000..c8c73008 --- /dev/null +++ b/src/components/file/index.ts @@ -0,0 +1,5 @@ +import type { Context } from '@deepseek-ai/cordis' +import { applyComponent } from '../../index.js' +export { Config } from '../../settings/file-config.js' +export const name = 'dsh-ops-file' +export function apply(ctx: Context, config: unknown): void { applyComponent(ctx, config, 'file') } diff --git a/src/components/panel/index.ts b/src/components/panel/index.ts index d2f01195..6745a4bb 100644 --- a/src/components/panel/index.ts +++ b/src/components/panel/index.ts @@ -8,7 +8,7 @@ import { configObject, editable, settingsConfig } from '../../settings/schema.js export const name = 'dsh-ops-panel' export { resolvePanelConfig } export const Config = settingsConfig(value => resolvePanelConfig(configObject(value)), { - capture: editable('boolean', 'Capture new session events into the session workspace. Off stops collection and queued writes.'), + capture: editable('boolean', 'Capture when this row is enabled. Legacy false remains off until explicitly changed to true.'), textLimit: editable('number', 'Maximum characters retained per text field (64–100000).'), maxSessionBytes: editable('number', 'Maximum bytes per session document (65536–268435456).'), }) @@ -20,7 +20,10 @@ function pluginVersion(): string | undefined { } export function apply(ctx: Context, rawConfig: unknown): void { const config = resolvePanelConfig(configObject(rawConfig)) - if (!config.capture) return + if (!config.capture) { + ctx.logger.info('dsh-ops: legacy panel capture:false remains off. Set capture:true only when collection is wanted; the row switch stops collection.') + return + } const collector = createPanelCollector({ config, report: (message: string) => ctx.logger.warn(message), diff --git a/src/components/ptc/index.js b/src/components/ptc/index.js deleted file mode 100644 index 85ea6fc8..00000000 --- a/src/components/ptc/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { apply as mount } from '../../ptc/index.js' -export const name = 'dsh-ops-ptc' -export function apply(ctx, config) { return mount(ctx, config) } diff --git a/src/components/ptc/index.ts b/src/components/ptc/index.ts new file mode 100644 index 00000000..6d2b0c39 --- /dev/null +++ b/src/components/ptc/index.ts @@ -0,0 +1,12 @@ +import type { Context } from '@deepseek-ai/cordis' +import { apply as mount } from '../../ptc/index.js' +import { resolvePtcConfig } from '../../config.js' +import { editable, settingsConfig } from '../../settings/schema.js' +export const name = 'dsh-ops-ptc' +export const Config = settingsConfig(resolvePtcConfig, { + allowedTools: editable('array', 'PTC APIs available for SDK selection.', { type: 'string' }), + alwaysLoaded: editable('array', 'PTC APIs published before SDK selection.', { type: 'string' }), + required: editable('boolean', 'Fail startup if the native worker is unavailable.'), + binaryPath: editable('string', 'Optional explicit native worker path.'), +}) +export function apply(ctx: Context, config: unknown): void { mount(ctx, config) } diff --git a/src/components/shell/index.ts b/src/components/shell/index.ts index af7c482a..7d491752 100644 --- a/src/components/shell/index.ts +++ b/src/components/shell/index.ts @@ -5,7 +5,7 @@ import { resolveConfig } from '../../config.js' import { editable, settingsConfig, configObject } from '../../settings/schema.js' export const name = 'dsh-ops-bash & host PowerShell' export const Config = settingsConfig(value => { - const raw = configObject(value), validated = resolveConfig(raw) + const raw = configObject(value ?? {}), validated = resolveConfig(raw) // Keep raw ordinary keys: applyComponent owns normalization and migration. return { ...raw, publishBashTool: validated.publishBashTool } }, { diff --git a/src/components/tui/index.js b/src/components/tui/index.js index 82687b8f..ae65ad33 100644 --- a/src/components/tui/index.js +++ b/src/components/tui/index.js @@ -3,6 +3,7 @@ import { createActivityStore, resolveActivityConfig } from '../../tui/activity.j import { createOpsPanel } from '../../tui/panel.js' import { createAttentionStore } from '../../tui/attention.js' +export { Config } from '../../settings/tui-config.js' export const name = 'dsh-ops-tui' export const inject = ['tuiPanels'] export { resolveActivityConfig as resolveTuiConfig } diff --git a/src/config-updates.js b/src/config-updates.js index e0c6bc11..c10e3ce5 100644 --- a/src/config-updates.js +++ b/src/config-updates.js @@ -1,6 +1,11 @@ /** Update configuration modification area. * Append migrations here; never spread upgrade write-back across components. * Markers belong to each owning config row, not a machine-global sentinel. + * The 0.4 seven-row upgrade is additive bundle composition, not a config rewrite: + * stable IDs and names preserve saved on/off/config overrides, and the Loader + * adds discovery from cordis.patch.yml. Never copy the composed tree into a + * profile or rename dsh-ops/file: name-qualified overrides would be skipped. + * Explicit legacy panel capture:false is retained; no migration may start capture. */ import { LEGACY_FASTCTX_KEYS } from './config.js' diff --git a/src/config.js b/src/config.js index a4351888..db78dc20 100644 --- a/src/config.js +++ b/src/config.js @@ -101,6 +101,7 @@ function optionalStringList(raw, key, fallback) { * @property {number} configUpdateVersion - per-row completed configuration migration version. * @property {string|undefined} binaryPath - explicit native worker executable. * @property {boolean} required - fail activation when the native worker is unavailable. + * @property {boolean} denyCredentialReads - refuse reads of credential locations. * @property {'advise'|'deny-host-shell'} shellPolicy - treatment of host shell tools. * @property {string[]} deniedHostTools - tool names refused by `deny-host-shell`. * @property {boolean} promptPolicy - inject the repository-tooling prompt section. @@ -191,3 +192,4 @@ export { resolveHidingConfig } from './official-tool-config.js' /** Independent PTC row configuration uses the same central validation entry point. */ export { parsePtcConfig as resolvePtcConfig } from './ptc/config.js' export { resolveDiscoveryConfig } from './discovery/config.js' + diff --git a/src/index.js b/src/index.js index 4802a6f5..027457ef 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ import { mountWorkspacePreparation } from './workspace-preparation.js' import { safeDispose, safeDisposeAll } from './teardown.js' import { mountResultLifecycle } from './kernel/result-lifecycle.js' +export { Config } from './settings/file-config.js' export const name = 'dsh-ops' function report(ctx, message, level = 'error') { const logger = ctx?.logger diff --git a/src/official-tool-catalog.ts b/src/official-tool-catalog.ts index 067746d1..e5e05e1e 100644 --- a/src/official-tool-catalog.ts +++ b/src/official-tool-catalog.ts @@ -5,6 +5,7 @@ * See the paired component references before opting into a different workflow. */ import { publicToolName } from './policy.js' +import { componentEnabled } from './components/defaults.js' // Only reviewed, implemented replacements belong here. A similarly named ops_* // registration is NOT evidence of equivalent capability. In particular replace @@ -19,5 +20,5 @@ export type OfficialReplacement = keyof typeof OFFICIAL_REPLACEMENTS export function platformSurface(platform: string = process.platform) { if (!['win32', 'linux', 'darwin'].includes(platform)) throw new Error(`Unsupported official-tool hiding platform: ${platform}`) - return { retainedCommand: platform === 'win32' ? 'pwsh' : 'bash', shellDefaultEnabled: true } + return { retainedCommand: platform === 'win32' ? 'pwsh' : 'bash', shellDefaultEnabled: componentEnabled('shell', platform) } } diff --git a/src/panel/collect.js b/src/panel/collect.js index ab17b1f7..688e4094 100644 --- a/src/panel/collect.js +++ b/src/panel/collect.js @@ -38,7 +38,7 @@ export const PANEL_DIR = path.join('.dsh-ops', 'panel') /** The viewer file name inside {@link PANEL_DIR}. */ export const PANEL_HTML = 'panel.html' /** Recognised row keys and their defaults; `capture` is the user-facing switch. */ -export const PANEL_DEFAULTS = Object.freeze({ capture: false, textLimit: 2_000, maxSessionBytes: 8 * 1024 * 1024 }) +export const PANEL_DEFAULTS = Object.freeze({ capture: true, textLimit: 2_000, maxSessionBytes: 8 * 1024 * 1024 }) // View guards: a pathological session must not turn the trace into the document. const TRACE_LIMIT = 20_000 diff --git a/src/settings/file-config.ts b/src/settings/file-config.ts new file mode 100644 index 00000000..944dc4c8 --- /dev/null +++ b/src/settings/file-config.ts @@ -0,0 +1,11 @@ +import { resolveConfig } from '../config.js' +import { configObject, editable, settingsConfig } from './schema.js' +export const Config = settingsConfig(value => { + const raw = configObject(value ?? {}), config = resolveConfig(raw) + return { ...raw, promptPolicy: config.promptPolicy, required: config.required, denyCredentialReads: config.denyCredentialReads } +}, { + promptPolicy: editable('boolean', 'Publish guidance for the active file tools.'), + required: editable('boolean', 'Fail startup if the native worker is unavailable.'), + denyCredentialReads: editable('boolean', 'Deny reads of credential paths.'), + binaryPath: editable('string', 'Optional explicit native worker path.'), +}) diff --git a/src/settings/tui-config.ts b/src/settings/tui-config.ts new file mode 100644 index 00000000..2575af16 --- /dev/null +++ b/src/settings/tui-config.ts @@ -0,0 +1,7 @@ +import { resolveActivityConfig } from '../tui/activity.js' +import { configObject, editable, settingsConfig } from './schema.js' +export const Config = settingsConfig(value => resolveActivityConfig(configObject(value)), { + maxSessions: editable('number', 'Maximum retained sessions (1–256).'), + maxActive: editable('number', 'Maximum active records (1–1024).'), + recentLimit: editable('number', 'Maximum recent records per session (1–64).'), +}) diff --git a/test/bundle-startup.test.mjs b/test/bundle-startup.test.mjs index 3d2d02a1..d81c9c93 100644 --- a/test/bundle-startup.test.mjs +++ b/test/bundle-startup.test.mjs @@ -9,14 +9,16 @@ import * as FsTools from '@deepseek-ai/dsh-tool-fs' import * as SearchTools from '@deepseek-ai/dsh-tool-fs-search' import NodePtc from '@deepseek-ai/dsh-ptc-runtime-node' import { createScope } from '@deepseek-ai/dsh-scope' +import { evaluate, isJsExpr } from '@deepseek-ai/cordis-plugin-loader' import { bootHost, shutdown } from './lib/host.mjs' import { assert, test, report, waitFor, withTempDir, PACKAGE_ROOT } from './lib/harness.mjs' import { resolveWorker } from '../lib/ptc/runtime.js' const { parse } = YAML resolveWorker({}) -// Parse the shipped data-only bundle; executable YAML tags are not supported. +// The shipped data-only bundle uses the public serialized Loader expression for platform defaults. const rows = parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'), 'utf8'))[0].insert +const enabled = row => !(isJsExpr(row.disabled) ? evaluate({ process }, row.disabled.__jsExpr) : row.disabled) assert.equal(rows.find(row => row.id === 'dsh-ops-hide-official').disabled, true) assert.equal(rows.find(row => row.id === 'dsh-ops-tui').disabled, true) @@ -30,8 +32,8 @@ for (const mode of ['read-only', 'workspace-write', 'danger-full-access']) { await ctx.plugin(FileSystem, {}) await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: ({ session }) => ({ mode: session.mode, workspaceRoot: root, sessionId: session.id }) })) await ctx.plugin(NodePtc, {}) - for (const row of rows.filter(row => !row.disabled)) { - const component = row.name.split('/')[1] + for (const row of rows.filter(enabled)) { + const component = row.name === 'dsh-ops' ? 'file' : row.name.split('/')[1] const module = await import(`../lib/components/${component}/index.js`) const config = component === 'shell' && process.env.DSH_OPS_TEST_BASH ? { ...row.config, bashPath: process.env.DSH_OPS_TEST_BASH } : row.config @@ -49,7 +51,7 @@ for (const mode of ['read-only', 'workspace-write', 'danger-full-access']) { const names = () => ctx.get('tools').schemas(agent).map(tool => tool.name) await waitFor(() => names().includes(presentation === 'ptc' ? 'ops_inspect_local_file' : 'ops_read_many') && names().includes('read') && names().includes('grep') && names().includes('ops_sdk') === (presentation === 'ptc') && - names().includes('ops_bash'), { what: `${mode}/${presentation} default publication` }) + names().includes('ops_bash') === (process.platform === 'win32'), { what: `${mode}/${presentation} default publication` }) const assembled = await ctx.get('systemPrompt').assemble({ scope: agent, signal: new AbortController().signal }) assert.equal(assembled.tools.some(tool => tool.name === 'run_code'), presentation !== 'native') assert.ok(assembled.sections.length > 0) @@ -72,8 +74,8 @@ await test('a missing optional Bash leaves the file bundle available', async () await ctx.plugin(Subprocess) await ctx.plugin(Sandbox, {}) await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: ({ session }) => ({ mode: session.mode, workspaceRoot: root, sessionId: session.id }) })) - for (const row of rows.filter(row => !row.disabled)) { - const component = row.name.split('/')[1] + for (const row of rows.filter(enabled)) { + const component = row.name === 'dsh-ops' ? 'file' : row.name.split('/')[1] const module = await import('../lib/components/' + component + '/index.js') await ctx.plugin(module, component === 'shell' ? { ...row.config, bashPath: path.join(root, 'absent-bash.exe') } : row.config) } diff --git a/test/component-migration.test.mjs b/test/component-migration.test.mjs new file mode 100644 index 00000000..2b7d9f03 --- /dev/null +++ b/test/component-migration.test.mjs @@ -0,0 +1,71 @@ +import fs from 'node:fs' +import path from 'node:path' +import { tmpdir } from 'node:os' +import { parse } from 'yaml' +import { assert, test, report, PACKAGE_ROOT, waitFor } from './lib/harness.mjs' +import { componentProfile } from './lib/component-profile.mjs' +import { shutdown } from './lib/host.mjs' +const current = fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'), 'utf8') +const previousRows = parse(current)[0].insert.filter(row => row.id !== 'dsh-ops-discovery') +for (const row of previousRows) { + if (['dsh-ops-shell', 'dsh-ops-panel'].includes(row.id)) delete row.disabled + if (row.id === 'dsh-ops-panel') row.config.capture = false +} +const previous = JSON.stringify([{ insert: previousRows }]) +for (const reversed of [false, true]) await test(`six-to-seven bundle refresh and restart preserve named overrides (reverse: ${reversed})`, async () => { + const choices = previousRows.map((row, index) => ({ id: row.id, name: row.name, disabled: Boolean(index % 2) !== reversed, + config: { ...row.config, ...(row.id === 'dsh-ops-file' ? { extraGuidance: 'keep this exact file setting', configUpdateVersion: 2 } : {}), + ...(row.id === 'dsh-ops-shell' ? { promptPolicy: false, configUpdateVersion: 2 } : {}), + ...(row.id === 'dsh-ops-panel' ? { textLimit: 777, capture: false } : {}) } })) + const foreign = { insert: [{ id: 'theme-sentinel', name: 'cordis:fixtureTheme', config: { preference: 'dark', fontSize: 17 } }] } + const fixture = componentProfile(`migration-${reversed}`, previous, [...choices, foreign]) + const original = fs.readFileSync(fixture.profile.patchPath, 'utf8') + const check = host => { + for (const choice of choices) { + const entry = host.entries().find(entry => entry.options.id === choice.id) + assert.equal(entry.options.name, choice.name) + assert.equal(entry.disabled, choice.disabled, `${choice.id} on/off preserved`) + assert.deepEqual(entry.options.config, choice.config, `${choice.id} config preserved`) + } + const theme = [...host.ctx.loader.entries()].find(entry => entry.options.id === 'theme-sentinel') + assert.deepEqual(theme.options.config, foreign.insert[0].config) + assert.equal(fs.readFileSync(fixture.profile.patchPath, 'utf8'), original) + assert.equal(fs.readFileSync(fixture.baseFile, 'utf8'), '[]\n') + } + let host = await fixture.boot('linux') + try { + assert.equal(host.entries().length, 6); check(host) + fs.writeFileSync(fixture.bundlePath, current) + await host.refresh() + assert.equal(host.entries().length, 7); check(host) + assert.equal(host.entries().find(entry => entry.options.id === 'dsh-ops-discovery').disabled, false) + await host.refresh(); assert.equal(host.entries().length, 7); check(host) + await shutdown(host.ctx) + host = await fixture.boot('linux') + assert.equal(host.entries().length, 7); check(host) + } finally { await shutdown(host.ctx) } +}) +await test('fresh panel row enablement starts collection while explicit legacy false stays inert', async () => { + const cwd = fs.mkdtempSync(path.join(tmpdir(), 'dsh-ops-panel-default-')) + const fixture = componentProfile('panel-row-default', current) + const host = await fixture.boot() + const emit = id => host.ctx.emit('session/event', { id, header: { cwd } }, { type: 'user/message', seq: 0, time: 1, + data: { content: [{ type: 'text', text: 'fresh panel row' }] } }) + const dir = path.join(cwd, '.dsh-ops/panel') + try { + emit('default-off') + await new Promise(resolve => setImmediate(resolve)) + assert.equal(fs.existsSync(dir), false) + fs.writeFileSync(fixture.profile.patchPath, JSON.stringify([{ id: 'dsh-ops-panel', name: 'dsh-ops/panel', disabled: false }])) + await host.refresh(); emit('enabled') + await waitFor(() => fs.existsSync(path.join(dir, 'enabled.ndjson'))) + fs.writeFileSync(fixture.profile.patchPath, JSON.stringify([{ id: 'dsh-ops-panel', name: 'dsh-ops/panel', disabled: false, config: { capture: false } }])) + await host.refresh() + const before = fs.readdirSync(dir) + emit('legacy-off') + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(fs.readdirSync(dir), before) + assert.equal(host.entries().find(entry => entry.options.id === 'dsh-ops-panel').fiber.config.capture, false) + } finally { await shutdown(host.ctx); fs.rmSync(cwd, { recursive: true, force: true }) } +}) +report('component-migration') diff --git a/test/lib/component-profile.mjs b/test/lib/component-profile.mjs new file mode 100644 index 00000000..d4983781 --- /dev/null +++ b/test/lib/component-profile.mjs @@ -0,0 +1,37 @@ +/** Installed bundle/profile fixture using the pinned composition and Loader APIs. */ +import fs from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import { readProfilePatches, mountRootInclude, reconcileProfilePatches, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot' +import { bootHost } from './host.mjs' +import { TEST_HOME, PACKAGE_ROOT, assert } from './harness.mjs' +export function componentProfile(label, bundleText, patches = []) { + const home = path.join(TEST_HOME, label), dir = path.join(home, 'profiles/fixture') + const packageDir = path.join(dir, 'node_modules/dsh-ops'), install = path.join(home, 'installation') + fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(install, { recursive: true }) + fs.copyFileSync(path.join(PACKAGE_ROOT, 'package.json'), path.join(packageDir, 'package.json')) + fs.symlinkSync(path.join(PACKAGE_ROOT, 'lib'), path.join(packageDir, 'lib'), process.platform === 'win32' ? 'junction' : 'dir') + const bundlePath = path.join(packageDir, 'cordis.patch.yml') + fs.writeFileSync(bundlePath, bundleText) + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'fixture-profile', private: true, dsh: { profile: { bundles: ['dsh-ops'] } } })) + const patchPath = path.join(dir, 'cordis.patch.yml'), baseFile = path.join(dir, 'cordis.yml') + fs.writeFileSync(patchPath, JSON.stringify(patches)); fs.writeFileSync(baseFile, '[]\n') + const installAnchor = path.join(install, 'package.json'); fs.writeFileSync(installAnchor, '{"name":"fixture-installation"}') + const profile = { home, dir, patchPath, installAnchor, overlays: [], startedBundles: ['dsh-ops'] } + return { profile, bundlePath, baseFile, async boot(platform = process.platform) { + const { ctx } = await bootHost() + // A public Context value lets the real Loader evaluate the platform expression + // for all target defaults. This tests composition, not another OS's sandbox. + ctx.provide('process', { platform }) + await ctx.plugin(Loader, { baseUrl: pathToFileURL(baseFile).href }) + ctx.loader.builtins.fixtureTheme = { apply() {} } + ctx.provide('profileContext', profile) + const loaded = loadProfileDirectory('dsh', dir, installAnchor) + assert.deepEqual(loaded.skippedBundles, []) + await mountRootInclude(ctx, baseFile, readProfilePatches('dsh', profile)) + await ctx.loader.await() + return { ctx, entries: () => [...ctx.loader.entries()].filter(entry => entry.options.id.startsWith('dsh-ops-')), + async refresh() { await reconcileProfilePatches(ctx, readProfilePatches('dsh', profile), 'dsh'); await ctx.loader.await() } } + } } +} diff --git a/test/manifest.test.mjs b/test/manifest.test.mjs index 8101cf44..2dc0a4f4 100644 --- a/test/manifest.test.mjs +++ b/test/manifest.test.mjs @@ -133,7 +133,7 @@ await test('the bundle patch inserts no background row and writes no removed Fas const { LEGACY_FASTCTX_KEYS } = await import('../lib/config.js') const patch = parse(fs.readFileSync(path.join(PACKAGE_ROOT, pkg.dsh.bundle.patch), 'utf8')) const rows = patch.flatMap((entry) => entry.insert ?? []) - assert.deepEqual(rows.map((row) => row.name), ['dsh-ops/shell', 'dsh-ops/file', 'dsh-ops/ptc', 'dsh-ops/panel', 'dsh-ops/tui', 'dsh-ops/hide-official']) + assert.deepEqual(rows.map((row) => row.name), ['dsh-ops/shell', 'dsh-ops/file', 'dsh-ops/ptc', 'dsh-ops/discovery', 'dsh-ops/panel', 'dsh-ops/tui', 'dsh-ops/hide-official']) for (const row of rows) { for (const key of LEGACY_FASTCTX_KEYS) assert.equal(Object.hasOwn(row.config ?? {}, key), false, `${row.name} still writes ${key}`) } @@ -171,7 +171,7 @@ await test('the card icon is a self-contained PNG with bounded dimensions', () = await test('the icon and all component entries are shipped in the package whitelist', () => { assert.ok(pkg.files.includes('docs/assets/icon.png')) - for (const component of ['shell', 'file', 'background', 'ptc', 'panel', 'tui', 'hide-official']) { + for (const component of ['shell', 'file', 'background', 'ptc', 'discovery', 'panel', 'tui', 'hide-official']) { const directory = path.join(PACKAGE_ROOT, 'lib', 'components', component) assert.ok(fs.existsSync(path.join(directory, 'index.js'))) assert.ok(pkg.exports[`./${component}`]) @@ -184,9 +184,9 @@ await test('the icon and all component entries are shipped in the package whitel } }) -await test('strict hiding and TUI are opt-in while native file/PTC/panel rows default enabled', () => { +await test('hiding, panel and TUI are opt-in while file/PTC/discovery rows default enabled', () => { const components = raw.contributes['x-components'] - for (const [id, enabled] of [['file', true], ['ptc', true], ['panel', true], ['tui', false], ['hide-official', false]]) { + for (const [id, enabled] of [['file', true], ['ptc', true], ['discovery', true], ['panel', false], ['tui', false], ['hide-official', false]]) { assert.equal(components.find(component => component.id === `dsh-ops-${id}`).defaultEnabled, enabled) } }) diff --git a/test/official-tool-hiding.test.mjs b/test/official-tool-hiding.test.mjs index 1e1d3824..ad7e42f9 100644 --- a/test/official-tool-hiding.test.mjs +++ b/test/official-tool-hiding.test.mjs @@ -59,7 +59,7 @@ await test('the disabled component patch also defaults to an empty selection', ( assert.deepEqual(hiding.config, { replaceTools: [] }) assert.deepEqual(resolveHidingConfig(hiding.config), { replaceTools: [] }) for (const platform of ['win32', 'linux', 'darwin']) { - assert.deepEqual(platformSurface(platform), { retainedCommand: platform === 'win32' ? 'pwsh' : 'bash', shellDefaultEnabled: true }) + assert.deepEqual(platformSurface(platform), { retainedCommand: platform === 'win32' ? 'pwsh' : 'bash', shellDefaultEnabled: platform === 'win32' }) } }) await test('only reviewed read workflows have mappings; mutation and transport tools do not', () => { diff --git a/test/ops-panel-collect.test.mjs b/test/ops-panel-collect.test.mjs index aa9e7c5f..217a5241 100644 --- a/test/ops-panel-collect.test.mjs +++ b/test/ops-panel-collect.test.mjs @@ -53,9 +53,9 @@ function panelDir(cwd) { return path.join(cwd, '.dsh-ops', 'panel') } -await test('the default configuration captures nothing', () => { +await test('enabled rows capture by default; the bundle row itself is disabled', () => { const config = resolvePanelConfig() - assert.equal(config.capture, false) + assert.equal(config.capture, true) assert.equal(config.textLimit, 2_000) assert.equal(config.maxSessionBytes, 8 * 1024 * 1024) assert.ok(Object.isFrozen(config)) @@ -69,7 +69,6 @@ await test('the default configuration captures nothing', () => { await test('capture off registers no listener, no effect, and writes no file', async () => { await withTempDir('panel-off', async (cwd) => { const ctx = fakeContext() - apply(ctx, {}) apply(ctx, { capture: false, textLimit: 2000, maxSessionBytes: 8388608 }) assert.deepEqual(ctx.calls.on, [], 'the off row must register no event listener') assert.equal(ctx.calls.effect, 0, 'the off row must register no effect') @@ -80,7 +79,7 @@ await test('capture off registers no listener, no effect, and writes no file', a await test('capture on is lazy: listeners register, but no directory exists before an event', async () => { await withTempDir('panel-lazy', async (cwd) => { const ctx = fakeContext() - apply(ctx, { capture: true }) + apply(ctx, {}) assert.deepEqual(ctx.calls.on, ['session/event', 'session/disposed']) assert.equal(ctx.calls.effect, 1) assert.equal(fs.existsSync(path.join(cwd, '.dsh-ops')), false, 'mounting alone must not create files') diff --git a/test/platform-defaults.test.mjs b/test/platform-defaults.test.mjs index 67e401a5..2a2950d2 100644 --- a/test/platform-defaults.test.mjs +++ b/test/platform-defaults.test.mjs @@ -1,24 +1,37 @@ -// #170 operator decision; an enabled component does not force PTC presentation. import fs from 'node:fs' import path from 'node:path' import { parse } from 'yaml' import { assert, test, report, PACKAGE_ROOT } from './lib/harness.mjs' import { platformSurface } from '../lib/official-tool-catalog.js' - -await test('PTC enhancement and all-platform shell default on; strict hiding remains opt-in', () => { - const patch = fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'), 'utf8') - const rows = parse(patch).flatMap(entry => entry.insert ?? []) - const manifest = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'dsh-plugin.json'), 'utf8')) - const components = manifest.contributes['x-components'] - for (const [name, enabled] of [['shell', true], ['ptc', true], ['hide-official', false]]) { - const row = rows.find(row => row.id === `dsh-ops-${name}`) - const component = components.find(component => component.id === row.id) - assert.equal(row.disabled ?? false, !enabled, name) - assert.equal(component.defaultEnabled, enabled, name) - // Do not rely on an extension field that the pinned host has not proven it consumes. - assert.equal(Object.hasOwn(component, 'defaultEnabledPlatforms'), false) +import { COMPONENTS, componentEnabled, SHELL_DISABLED_EXPRESSION } from '../lib/components/defaults.js' +import { componentProfile } from './lib/component-profile.mjs' +import { shutdown } from './lib/host.mjs' +const patch = fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'), 'utf8') +const rows = parse(patch).flatMap(entry => entry.insert ?? []) +const manifest = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'dsh-plugin.json'), 'utf8')) +await test('seven stable row defaults and manifest agree, including the host-evaluated platform expression', () => { + assert.equal(rows.length, 7) + assert.deepEqual(rows.find(row => row.id === 'dsh-ops-shell').disabled, { __jsExpr: SHELL_DISABLED_EXPRESSION }) + for (const component of COMPONENTS) { + const row = rows.find(row => row.id === `dsh-ops-${component}`) + assert.ok(row) + assert.equal(row.name, `dsh-ops/${component}`, 'legacy file name-qualified overrides must keep matching') + const declared = manifest.contributes['x-components'].find(item => item.id === row.id) + for (const platform of ['win32', 'linux', 'darwin']) { + assert.equal(component === 'shell' ? declared.defaultEnabled[platform] : declared.defaultEnabled, componentEnabled(component, platform)) + if (component !== 'shell') assert.equal(row.disabled ?? false, !componentEnabled(component, platform)) + } } - for (const platform of ['win32', 'linux', 'darwin']) assert.equal(platformSurface(platform).shellDefaultEnabled, true) - assert.doesNotMatch(patch, /!!js/) + for (const platform of ['win32', 'linux', 'darwin']) assert.equal(platformSurface(platform).shellDefaultEnabled, platform === 'win32') +}) +for (const platform of ['win32', 'linux', 'darwin']) await test(`real Loader applies fresh seven-row ${platform} defaults`, async () => { + const fixture = componentProfile(`defaults-${platform}`, patch) + const { ctx, entries } = await fixture.boot(platform) + try { + assert.equal(entries().length, 7) + for (const entry of entries()) assert.equal(!entry.disabled, componentEnabled(entry.options.id.slice('dsh-ops-'.length), platform), entry.options.id) + assert.equal(entries().find(entry => entry.options.id === 'dsh-ops-panel').options.config.capture, true) + assert.equal(fs.readFileSync(fixture.profile.patchPath, 'utf8'), '[]', 'startup does not write a composed tree') + } finally { await shutdown(ctx) } }) report('platform-defaults') diff --git a/test/settings-host.test.mjs b/test/settings-host.test.mjs index b4d80a77..326b0739 100644 --- a/test/settings-host.test.mjs +++ b/test/settings-host.test.mjs @@ -10,7 +10,7 @@ import * as Shell from '../lib/components/shell/index.js' const ShellConfig = Shell.Config import { publicToolName } from '../lib/policy.js' import { registerNativeProvider } from '../lib/file/result-provider.js' -import { shutdown, scopedToolNames } from './lib/host.mjs' +import { bootHost, shutdown, scopedToolNames } from './lib/host.mjs' import { assert, test, report, TEST_HOME } from './lib/harness.mjs' @@ -122,4 +122,40 @@ await test('host descriptor projections cannot remove metadata from later descri } } }) +for (const [label, modulePath, config, change, invalid] of [ + ['file', '../lib/components/file/index.js', { extraGuidance: 'preserve ordinary config', promptPolicy: true }, ['promptPolicy', false], ['required', 'yes']], + ['ptc', '../lib/components/ptc/index.js', { required: false, allowedTools: ['glob', 'grep'], alwaysLoaded: [] }, ['alwaysLoaded', ['glob']], ['allowedTools', ['pdf']]], + ['tui', '../lib/components/tui/index.js', { maxSessions: 32, recentLimit: 8 }, ['recentLimit', 4], ['recentLimit', 100]], +]) await test(`builtin ${label} descriptor persists validated changes and reloads the owning configuration`, async () => { + const module = await import(modulePath) + let observed, mounts = 0 + const { ctx, snapshot, mutate, patchPath } = await profileFixture(`builtin-${label}`, { + Config: module.Config, apply(_ctx, config) { observed = config; mounts++ }, + }, config) + try { + const before = snapshot(), count = mounts + assert.ok(before && Object.hasOwn(before.value, change[0])) + await mutate([set(...change)], before.revision) + assert.equal(mounts, count + 1) + assert.deepEqual(observed[change[0]], change[1]) + if (label === 'file') assert.equal(observed.extraGuidance, 'preserve ordinary config') + const saved = fs.readFileSync(patchPath, 'utf8') + await assert.rejects(mutate([set(...invalid)], snapshot().revision)) + assert.equal(fs.readFileSync(patchPath, 'utf8'), saved) + assert.equal(mounts, count + 1) + } finally { await shutdown(ctx) } +}) + +await test('real Cordis adapters preserve legacy null file/root/shell configuration defaults', async () => { + for (const modulePath of ['../lib/index.js', '../lib/components/file/index.js', '../lib/components/shell/index.js']) { + const { Config } = await import(modulePath) + const { ctx } = await bootHost() + let observed + try { + await ctx.plugin({ Config, apply(_ctx, config) { observed = config } }, null) + assert.ok(observed && typeof observed === 'object', modulePath) + assert.equal(Config['~standard'].validate([]).issues.length, 1) + } finally { await shutdown(ctx) } + } +}) report('settings-host') diff --git a/test/shells-patch.test.mjs b/test/shells-patch.test.mjs index 92c9a328..0217a56b 100644 --- a/test/shells-patch.test.mjs +++ b/test/shells-patch.test.mjs @@ -31,15 +31,12 @@ await test('the manifest declares the plugin\'s own shell tools as their own nam await test('the bundle patch declares independent components, with no persistent pwsh override', () => { const entries = patchEntries() assert.equal(entries.length, 1) - assert.deepEqual(entries[0].insert.map(row => row.id), ['dsh-ops-shell', 'dsh-ops-file', 'dsh-ops-ptc', 'dsh-ops-panel', 'dsh-ops-tui', 'dsh-ops-hide-official']) - assert.equal(entries[0].insert[0].disabled, undefined) - assert.equal(entries[0].insert[1].disabled, undefined) - assert.equal(entries[0].insert[2].disabled, undefined) - // The panel row mounts so its switch is addressable; the switch itself is off. - assert.equal(entries[0].insert[3].disabled, undefined) - assert.equal(entries[0].insert[3].config.capture, false) - assert.equal(entries[0].insert[4].disabled, true) - assert.equal(entries[0].insert[5].disabled, true) + assert.deepEqual(entries[0].insert.map(row => row.id), ['dsh-ops-shell', 'dsh-ops-file', 'dsh-ops-ptc', 'dsh-ops-discovery', 'dsh-ops-panel', 'dsh-ops-tui', 'dsh-ops-hide-official']) + const row = id => entries[0].insert.find(item => item.id === `dsh-ops-${id}`) + assert.deepEqual(row('shell').disabled, { __jsExpr: "process.platform !== 'win32'" }) + for (const name of ['file', 'ptc', 'discovery']) assert.equal(row(name).disabled, undefined) + for (const name of ['panel', 'tui', 'hide-official']) assert.equal(row(name).disabled, true) + assert.equal(row('panel').config.capture, true, 'a fresh enabled row starts capture') }) await test('the shipped dsh-ops row states the documented shell defaults', () => { From 023e5fdb2247fee2c64554aee3d2b440ed3246c1 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:18:31 +0800 Subject: [PATCH 076/118] refactor(core): type profile delegation and session temp helpers --- docs/CHANGELOG.md | 6 ++ docs/CONTRIBUTING.md | 2 + ...41\347\214\256\346\214\207\345\215\227.md" | 2 + ...{profile-install.js => profile-install.ts} | 39 +++++++--- src/{session-temp.js => session-temp.ts} | 8 ++- test/profile-temp.test.mjs | 72 +++++++++++++++++++ test/types/profile-temp.ts | 19 +++++ test/types/tsconfig.json | 3 +- 8 files changed, 136 insertions(+), 15 deletions(-) rename src/{profile-install.js => profile-install.ts} (70%) rename src/{session-temp.js => session-temp.ts} (86%) create mode 100644 test/profile-temp.test.mjs create mode 100644 test/types/profile-temp.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bd8fe656..247d8f31 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,12 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Type profile delegation and session temporary directories (#261, 16/16) + +- Migrate profile commands and private temporary-directory helpers to strict TypeScript, preserving official CLI delegation and per-session path behavior; narrow malformed manifest data before use. +- 将配置档委托命令与会话临时目录辅助函数迁移为严格 TypeScript,保留官方 CLI 委托及会话路径行为,并在使用前验证 manifest 数据。 + + ### Strict authority types ([16/16]) - Migrate credential fences, trusted path anchors and permission failures to strict TypeScript without changing emitted entrypoints or authority decisions. Preserve worker effect facts during denial normalization and assert typed host modes/unknown-input narrowing. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 7013142a..1d311cf4 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -194,3 +194,5 @@ Native build and acceptance details: [native-ptc.md](native-ptc.md) and [manual-validation.md](manual-validation.md). The credential fence, trusted path anchors and permission helpers are strict TypeScript modules (`src/credential-policy.ts`, `src/authority-paths.ts`, `src/permissions.ts`). Their emitted `.js` imports and wire failures stay stable. Authority policies use type-only host definitions; unknown worker failures must be narrowed before use. `node test/run.mjs kernel-types authority credential-fence credential-denial safety-atomicity permissions` covers the typed boundary and existing authority behavior. + +`src/profile-install.ts` and `src/session-temp.ts` are strict TypeScript helpers with unchanged emitted paths. Profile mutations still delegate to the official CLI; manifest data is narrowed before reading it. Private temporary directories retain their session-derived names and refusal of link/file targets. `node test/run.mjs profile-temp kernel-types` checks delegation, dry-run, failures and typed outcomes. diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 9f17ed59..8bd17014 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -128,3 +128,5 @@ npm run build:bash-ipc # 仅 Windows:Bash IPC 原生组件的编译与验收细节见 [native-ptc.md](native-ptc.md) 与 [manual-validation.md](manual-validation.md)。 凭据围栏、可信路径锚点和权限辅助函数已迁移为严格 TypeScript 模块(`src/credential-policy.ts`、`src/authority-paths.ts`、`src/permissions.ts`),生成的 `.js` 导入路径及线上错误结构保持不变。权限策略仅以类型方式引用宿主定义;未知 worker 错误必须先缩窄类型才能使用。运行 `node test/run.mjs kernel-types authority credential-fence credential-denial safety-atomicity permissions` 验证类型边界与既有权限行为。 + +`src/profile-install.ts` 与 `src/session-temp.ts` 已迁移为严格 TypeScript,生成路径不变。配置档修改仍委托官方 CLI,manifest 数据先缩窄再读取;临时目录保持按会话派生命名并拒绝链接/文件目标。运行 `node test/run.mjs profile-temp kernel-types` 验证委托、预览、失败及类型化结果。 diff --git a/src/profile-install.js b/src/profile-install.ts similarity index 70% rename from src/profile-install.js rename to src/profile-install.ts index 940cbfa0..20f68b9a 100644 --- a/src/profile-install.js +++ b/src/profile-install.ts @@ -15,7 +15,7 @@ import { PLATFORMS } from './ptc/runtime.js' * the native payloads are a separate question with their own gate in * `lib/ptc/runtime.js`. */ -const SUPPORTED_TARGETS = Object.freeze({ win32: ['x64'], linux: ['x64'], darwin: ['arm64'] }) +const SUPPORTED_TARGETS: Readonly>> = Object.freeze({ win32: ['x64'], linux: ['x64'], darwin: ['arm64'] }) /** * Where each platform's desktop application keeps its own CLI. @@ -37,36 +37,53 @@ function desktopCliCandidates(platform = process.platform, env = process.env, ho return [] } -export function profileCommand(operation, { profile, dshCli, dryRun = false }) { +export type ProfileOperation = 'install' | 'uninstall' | 'status' +export interface ProfileCommandOptions { profile?: string; dshCli?: string; dryRun?: boolean } + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} +function readManifest(file: string): Record { + const value: unknown = JSON.parse(fs.readFileSync(file, 'utf8')) + if (!record(value)) throw new Error(`Invalid package manifest: ${file}`) + return value +} + +export function profileCommand(operation: ProfileOperation, { profile, dshCli, dryRun = false }: ProfileCommandOptions): number { if (!(SUPPORTED_TARGETS[process.platform] ?? []).includes(process.arch)) throw new Error(`Supported targets: ${PLATFORMS.join(', ')}; unsupported host ${process.platform}-${process.arch}.`) const aliases = { desktop: 'desktop', web: 'web', tui: 'dsh-tui', 'dsh-tui': 'dsh-tui' } - if (!Object.hasOwn(aliases, profile ?? '')) throw new Error('Explicit --profile desktop|web|tui is required (tui maps to dsh-tui).') + if (profile !== 'desktop' && profile !== 'web' && profile !== 'tui' && profile !== 'dsh-tui') throw new Error('Explicit --profile desktop|web|tui is required (tui maps to dsh-tui).') const target = aliases[profile] const directory = path.join(dshHome(), 'profiles', target) if (!fs.existsSync(path.join(directory, 'package.json'))) throw new Error(`Initialize the target DSH application/profile first: ${directory}`) - const version = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')).version + const version = readManifest(path.join(PACKAGE_ROOT, 'package.json')).version if (operation === 'status') { - const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8')) - console.log(JSON.stringify({ profile: target, directory, dependency: manifest.dependencies?.['dsh-ops'] ?? null, enabled: (manifest.dsh?.profile?.bundles ?? []).includes('dsh-ops') }, null, 2)) + const manifest = readManifest(path.join(directory, 'package.json')) + const dependencies = record(manifest.dependencies) ? manifest.dependencies : undefined + const dsh = record(manifest.dsh) ? manifest.dsh : undefined + const profileManifest = record(dsh?.profile) ? dsh.profile : undefined + const bundles = profileManifest?.bundles ?? [] + if (!Array.isArray(bundles) && typeof bundles !== 'string') throw new Error('Invalid profile bundles: expected an array.') + console.log(JSON.stringify({ profile: target, directory, dependency: dependencies?.['dsh-ops'] ?? null, enabled: bundles.includes('dsh-ops') }, null, 2)) return 0 } const args = ['plugin', '--profile', target, operation === 'install' ? 'add' : 'remove', operation === 'install' ? (process.env.DSH_OPS_INSTALL_SPEC ?? `dsh-ops@${version}`) : 'dsh-ops'] let command = dshCli ?? process.env.DSH_OPS_DSH_CLI - let prefix = [] + let prefix: string[] = [] if (!command && target === 'desktop') { command = desktopCliCandidates().find(file => fs.existsSync(file)) if (!command) throw new Error('Desktop requires its own CLI. Pass --dsh-cli /resources/runtime/cli/bin/dsh; ordinary dsh cannot manage desktop.') } if (!command) { const globalNpm = process.env.APPDATA ? path.join(process.env.APPDATA, 'npm/node_modules/_anchor.json') : undefined - for (const anchor of [path.join(directory, 'package.json'), path.join(dshHome(), 'package.json'), globalNpm].filter(Boolean)) { + for (const anchor of [path.join(directory, 'package.json'), path.join(dshHome(), 'package.json'), globalNpm].filter((anchor): anchor is string => typeof anchor === 'string')) { try { const require = createRequire(anchor) const file = require.resolve('@deepseek-ai/dsh/package.json') - const manifest = JSON.parse(fs.readFileSync(file, 'utf8')) - const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.dsh - if (bin) { command = process.execPath; prefix = [path.resolve(path.dirname(file), bin)]; break } + const manifest = readManifest(file) + const bin = typeof manifest.bin === 'string' ? manifest.bin : record(manifest.bin) ? manifest.bin.dsh : undefined + if (typeof bin === 'string' && bin) { command = process.execPath; prefix = [path.resolve(path.dirname(file), bin)]; break } } catch {} } if (!command) { diff --git a/src/session-temp.js b/src/session-temp.ts similarity index 86% rename from src/session-temp.js rename to src/session-temp.ts index 5b4fe529..0e3f66c7 100644 --- a/src/session-temp.js +++ b/src/session-temp.ts @@ -30,7 +30,7 @@ export const SESSION_TEMP_PREFIX = 'dsh-ops-session-' * @param {string} [tempRoot] - the system temporary root; defaults to `os.tmpdir()`. * @returns {string} the absolute path. */ -export function sessionTempDir(sessionId, tempRoot = tmpdir()) { +export function sessionTempDir(sessionId: string, tempRoot = tmpdir()): string { return path.join(tempRoot, `${SESSION_TEMP_PREFIX}${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 16)}`) } @@ -44,13 +44,15 @@ export function sessionTempDir(sessionId, tempRoot = tmpdir()) { * @param {string} directory - the absolute directory. * @returns {{ok: true}|{ok: false, message: string}} the outcome. */ -export function ensureSessionTemp(directory) { +export type SessionTempOutcome = { ok: true } | { ok: false; message: string } + +export function ensureSessionTemp(directory: string): SessionTempOutcome { try { fs.mkdirSync(directory, { recursive: true, mode: 0o700 }) const info = fs.lstatSync(directory) if (!info.isDirectory() || info.isSymbolicLink()) return { ok: false, message: 'The session temporary directory must be a regular directory, not a link.' } return { ok: true } } catch (error) { - return { ok: false, message: `could not create the session temporary directory ${directory}: ${String(error?.message ?? error)}` } + return { ok: false, message: `could not create the session temporary directory ${directory}: ${String(error !== null && typeof error === 'object' && 'message' in error ? error.message ?? error : error)}` } } } diff --git a/test/profile-temp.test.mjs b/test/profile-temp.test.mjs new file mode 100644 index 00000000..0ad6fe71 --- /dev/null +++ b/test/profile-temp.test.mjs @@ -0,0 +1,72 @@ +import fs from 'node:fs' +import path from 'node:path' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { profileCommand } from '../lib/profile-install.js' +import { ensureSessionTemp, sessionTempDir } from '../lib/session-temp.js' + +const capture = body => { + const messages = [], previous = console.log + console.log = value => messages.push(String(value)) + try { return { status: body(), messages } } finally { console.log = previous } +} +async function profileFixture(body) { + await withTempDir('profile-delegate', async root => { + const previous = { home: process.env.DSH_HOME, cli: process.env.DSH_OPS_DSH_CLI, spec: process.env.DSH_OPS_INSTALL_SPEC, exit: process.env.PROFILE_TEST_EXIT } + process.env.DSH_HOME = root; delete process.env.DSH_OPS_DSH_CLI; delete process.env.DSH_OPS_INSTALL_SPEC + const directory = path.join(root, 'profiles', 'dsh-tui'), receipt = path.join(root, 'invoked.json') + fs.mkdirSync(directory, { recursive: true }) + const manifest = path.join(directory, 'package.json') + fs.writeFileSync(manifest, JSON.stringify({ dependencies: { 'dsh-ops': '^0.3.1' }, dsh: { profile: { bundles: ['dsh-ops'] } } })) + const cli = path.join(directory, 'node_modules', '@deepseek-ai', 'dsh'); fs.mkdirSync(cli, { recursive: true }) + fs.writeFileSync(path.join(cli, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh', type: 'module', bin: { dsh: './cli.mjs' } })) + fs.writeFileSync(path.join(cli, 'cli.mjs'), `import fs from 'node:fs';fs.writeFileSync(${JSON.stringify(receipt)},JSON.stringify(process.argv.slice(2)));process.exit(Number(process.env.PROFILE_TEST_EXIT ?? 0))`) + try { await body({ root, manifest, receipt }) } finally { + for (const [key, value] of [['DSH_HOME', previous.home], ['DSH_OPS_DSH_CLI', previous.cli], ['DSH_OPS_INSTALL_SPEC', previous.spec], ['PROFILE_TEST_EXIT', previous.exit]]) { + if (value === undefined) delete process.env[key]; else process.env[key] = value + } + } + }) +} +await test('profile status and dry-run preserve the tui alias and never execute the official CLI', async () => { + await profileFixture(({ receipt }) => { + const status = capture(() => profileCommand('status', { profile: 'tui' })) + assert.equal(status.status, 0) + const value = JSON.parse(status.messages[0]); assert.equal(value.profile, 'dsh-tui'); assert.equal(value.enabled, true); assert.equal(value.dependency, '^0.3.1') + assert.equal(capture(() => profileCommand('install', { profile: 'tui', dryRun: true })).status, 0) + assert.equal(fs.existsSync(receipt), false) + assert.throws(() => profileCommand('install', { profile: '../outside' }), /Explicit --profile/) + }) +}) +await test('profile mutations delegate exact arguments and propagate official CLI failure without local rollback logic', async () => { + await profileFixture(({ receipt, manifest }) => { + const before = fs.readFileSync(manifest, 'utf8'); process.env.DSH_OPS_INSTALL_SPEC = 'dsh-ops@0.4.0-test' + assert.equal(capture(() => profileCommand('install', { profile: 'tui' })).status, 0) + assert.deepEqual(JSON.parse(fs.readFileSync(receipt, 'utf8')), ['plugin', '--profile', 'dsh-tui', 'add', 'dsh-ops@0.4.0-test']) + process.env.PROFILE_TEST_EXIT = '17' + assert.equal(capture(() => profileCommand('uninstall', { profile: 'dsh-tui' })).status, 17) + assert.deepEqual(JSON.parse(fs.readFileSync(receipt, 'utf8')), ['plugin', '--profile', 'dsh-tui', 'remove', 'dsh-ops']) + assert.equal(fs.readFileSync(manifest, 'utf8'), before) + }) +}) +await test('malformed profile manifest data fails before CLI invocation', async () => { + await profileFixture(({ manifest, receipt }) => { + fs.writeFileSync(manifest, 'null') + assert.throws(() => profileCommand('status', { profile: 'tui' }), /Invalid package manifest/) + fs.writeFileSync(manifest, JSON.stringify({ dsh: { profile: { bundles: 12 } } })) + assert.throws(() => profileCommand('status', { profile: 'tui' }), /Invalid profile bundles/) + assert.equal(fs.existsSync(receipt), false) + }) +}) +await test('session temp identity is stable, isolated, idempotent and refuses file or link targets', async () => { + await withTempDir('session-temp-types', async root => { + const first = sessionTempDir('session-a', root), second = sessionTempDir('session-b', root) + assert.equal(first, sessionTempDir('session-a', root)); assert.notEqual(first, second) + assert.deepEqual(ensureSessionTemp(first), { ok: true }); assert.deepEqual(ensureSessionTemp(first), { ok: true }) + fs.writeFileSync(second, 'KEEP'); const failed = ensureSessionTemp(second) + assert.equal(failed.ok, false); assert.match(failed.message, /could not create/); assert.equal(fs.readFileSync(second, 'utf8'), 'KEEP') + const alias = path.join(root, 'alias'); fs.symlinkSync(first, alias, process.platform === 'win32' ? 'junction' : 'dir') + assert.equal(ensureSessionTemp(alias).ok, false) + if (process.platform !== 'win32') assert.equal(fs.statSync(first).mode & 0o777, 0o700) + }) +}) +report('profile-temp') diff --git a/test/types/profile-temp.ts b/test/types/profile-temp.ts new file mode 100644 index 00000000..512b932c --- /dev/null +++ b/test/types/profile-temp.ts @@ -0,0 +1,19 @@ +import { profileCommand } from '../../src/profile-install.js' +import { ensureSessionTemp, sessionTempDir } from '../../src/session-temp.js' +const directory: string = sessionTempDir('session-id') +const result = ensureSessionTemp(directory) +if (!result.ok) { const message: string = result.message; void message } +else { + // @ts-expect-error Successful temp creation does not invent an error message. + void result.message +} +const invoke: () => number = () => profileCommand('install', { profile: 'tui', dryRun: true }) +// @ts-expect-error Only the profile CLI's three operations are supported. +profileCommand('overwrite', { profile: 'tui' }) +// @ts-expect-error Dry-run selection is a boolean, not a truthy string. +profileCommand('install', { profile: 'tui', dryRun: 'yes' }) +// @ts-expect-error The session identity is an authoritative string. +sessionTempDir({ id: 'session-id' }) +// @ts-expect-error The chosen temp directory is a path string. +ensureSessionTemp(42) +void invoke diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json index d4fa4ee1..a9456f37 100644 --- a/test/types/tsconfig.json +++ b/test/types/tsconfig.json @@ -9,6 +9,7 @@ "ptc-sdk.ts", "worker-runtime.ts", "tui.ts", - "authority.ts" + "authority.ts", + "profile-temp.ts" ] } From 07b489422a1bb93436a92dd7f10cba7b4e856667 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:26:08 +0800 Subject: [PATCH 077/118] feat(settings): add host configuration pages --- docs/CHANGELOG.md | 3 + docs/CONTRIBUTING.md | 7 + docs/configuration-reference.md | 27 + ...41\347\214\256\346\214\207\345\215\227.md" | 6 + ...15\347\275\256\345\217\202\350\200\203.md" | 25 + package-lock.json | 703 ++++++++++++++++++ package.json | 26 +- scripts/build.mjs | 2 + scripts/lib/build/client.mjs | 43 ++ src/client/forms.tsx | 41 + src/client/index.tsx | 29 + src/client/locales.ts | 41 + src/client/runtime-panel.tsx | 71 ++ src/client/selections.tsx | 45 ++ src/client/shell-form.tsx | 21 + src/client/styles.ts | 26 + test/build.test.mjs | 1 + test/settings-client.test.mjs | 106 +++ test/settings-ui.test.mjs | 168 +++++ tsconfig.json | 16 +- 20 files changed, 1402 insertions(+), 5 deletions(-) create mode 100644 scripts/lib/build/client.mjs create mode 100644 src/client/forms.tsx create mode 100644 src/client/index.tsx create mode 100644 src/client/locales.ts create mode 100644 src/client/runtime-panel.tsx create mode 100644 src/client/selections.tsx create mode 100644 src/client/shell-form.tsx create mode 100644 src/client/styles.ts create mode 100644 test/settings-client.test.mjs create mode 100644 test/settings-ui.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b6f2beee..61627d2f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,9 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Add optional bilingual browser settings pages for replacement selection, discovery pools and explicit Bash/host PowerShell runtime plans. Preserve the default legacy file row; custom pages require a client carrier and unload when it is disabled. +- 新增可选双语浏览器设置页面,支持逐工具替换、发现池及显式 Bash/宿主 PowerShell 运行时计划。默认保留旧文件行;自定义页面需要客户端承载行,关闭承载行即卸载页面。 + - Add discovery as the seventh bundle row; preserve existing named row overrides. Fresh defaults enable shell only on Windows and leave panel/TUI/replacements off. Enabling a fresh panel row starts capture; explicit legacy capture:false stays inert. Provide builtin validated file/PTC/TUI configuration. - bundle 新增第七行 discovery,保留已有带名称行覆盖。首次安装仅 Windows 默认启用 shell,panel/TUI/替换默认关闭;启用新 panel 行即采集,旧配置 capture:false 保持不采集。补充文件/PTC/TUI 内置校验配置。 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index ca0f2ebd..861ddc1b 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -197,3 +197,10 @@ The settings integration tests use exact development-only host settings, profile editor packages at `0.2.0-rc.2`, with matching Cordis loader/group/include, CosmoKit and Schemastery patch versions. They exercise the real profile writer and component reload instead of substituting an in-memory save. These packages are not production dependencies or host runtime imports. + +Browser settings are strict TSX. The build uses the existing TypeScript compiler to produce the +host's lazy `./client` factory, with React supplied by the host. Exact development-only React 18.3.1, +React type packages, jsdom 26.1.0 and the pinned `0.2.0-rc.2` client contracts support compilation, +DOM interactions and the real host slot lifecycle tests. No browser bundler or production dependency +is added. `settings-ui`, `settings-client`, `settings-host` and `settings-runtime` suites cover the +form-to-profile and reviewed-action boundaries. diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 6a6ee9ec..a98135d2 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -8,6 +8,33 @@ Active rows expose validated settings through the host Settings/ConfigEditor. Ac The default bundle retains `dsh-ops/file` and stable ID `dsh-ops-file`, so saved name-qualified overrides continue to match. It does not activate custom browser pages on the pinned host. +## Optional browser settings pages + +When a bare-package client carrier is configured, the replacement, discovery and shell rows have settings pages in the host plugin manager. The default seven-row bundle keeps its legacy file module name and does not activate these pages. Changes +remain a draft until **Save changes** succeeds. A stale or refused save keeps the draft for review; +**Reset to saved** loads the latest saved values. Accepted changes persist through the host profile +writer and reload the affected row. + +- **Replacements:** choose `read`, `glob` and `grep` independently. Selected tools use OPS contracts. + Keep each selected counterpart (`inspect_local_file` for `read`, `glob` or `grep`) always loaded in Discovery, or activate it before selecting the replacement. `read_many` does not satisfy the single-file `read` replacement contract. +- **Discovery:** choose the allowed pool, its always-loaded subset and a result limit from 1 to 5. + Removing an allowed capability removes it from the always-loaded subset in the same save. +- **Shell:** enable Bash, enter an absolute executor override, or select detected managed/Git Bash. + Selecting a runtime fills the draft path; save it explicitly. Clearing the override inherits the + configured default. Runtime installation does not silently change that selection. + +Bash installation/removal and PowerShell upgrade buttons first show a plan. Execute that reviewed +plan explicitly; cancellation after launch can leave changes and reports an unknown effect. Managed +Bash removal only deletes receipt-owned installations. Stop Bash jobs first. PowerShell actions use +WinGet for the standard host PowerShell 7 installation, preserve Windows PowerShell 5.1, and require +a DSH restart. Check a custom host `pwshPath` separately. No installer runs when opening a page. + +A client carrier uses the stable `dsh-ops-file` row with the bare package name `dsh-ops` to load these browser pages. Its +server behavior remains the file component; the `dsh-ops/file` export remains compatible. Disabling +this carrier unloads all custom settings pages. Use the host's **built-in row switch** to re-enable +it. There is no automatic form fallback. A disabled target row must also be re-enabled before its +settings can be edited. The discovery page is available once its component row is installed. + --- ## Where configuration lives diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 73e90a56..38312e25 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -131,3 +131,9 @@ npm run build:bash-ipc # 仅 Windows:Bash IPC 并固定兼容的 Cordis loader/group/include、CosmoKit 和 Schemastery 补丁版本。 测试实际的配置文件写入及组件重新加载,而不是用内存保存代替;这些包不属于生产依赖, 插件宿主代码也不会在运行时导入它们。 + +浏览器设置使用严格 TSX。构建复用现有 TypeScript 编译器生成宿主的延迟 `./client` 工厂, +React 由宿主提供。精确锁定且仅用于开发的 React 18.3.1、React 类型包、jsdom 26.1.0 与 +`0.2.0-rc.2` 客户端契约用于编译、DOM 交互及真实宿主插槽生命周期测试,不添加浏览器打包器或 +生产依赖。`settings-ui`、`settings-client`、`settings-host` 和 `settings-runtime` 套件覆盖 +表单到配置持久化以及先检查后执行的操作边界。 diff --git "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" index 3e22b71e..677ba404 100644 --- "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" +++ "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" @@ -8,6 +8,31 @@ 默认 bundle 保留 `dsh-ops/file` 及稳定 ID `dsh-ops-file`,已有带名称覆盖继续匹配。固定宿主下,此默认配置不会启用自定义浏览器页面。 +## 可选浏览器设置页面 + +配置裸包名客户端承载行后,宿主插件管理器为替换、发现和 shell 行提供设置页面。默认七行 bundle 保留旧文件模块名,不启用这些页面。更改在**保存更改**成功前仅为草稿。 +版本冲突或宿主拒绝保存时保留草稿,供检查;**恢复已保存值**会读取最新保存值。 +接受的更改通过宿主配置写入器持久化,并重新加载对应行。 + +- **替换:**分别选择 `read`、`glob` 和 `grep`,所选工具使用 OPS 契约。 + 请在发现设置中始终加载所选对应能力(`read` 对应 `inspect_local_file`,其余为 `glob` 或 `grep`),或在选择替换前激活它们。`read_many` 不满足单文件 `read` 替换契约。 +- **发现:**选择允许池、始终加载子集以及 1 到 5 的结果数量上限。 + 移除允许能力时,会在同一次保存中将其从始终加载子集移除。 +- **Shell:**启用 Bash、填写绝对执行器路径覆盖,或选择检测到的受管理 Bash/Git Bash。 + 选择运行时仅填写草稿路径,需要显式保存。清空覆盖值可继承配置默认值。 + 安装运行时不会自动更改这一选择。 + +Bash 安装/删除与 PowerShell 升级按钮首先显示计划,随后需要显式执行已检查的计划。 +启动后取消可能已发生更改,此时会报告影响未知。受管理 Bash 删除仅处理具有所有权凭据的安装, +操作前请先停止 Bash 作业。PowerShell 操作通过 WinGet 处理标准宿主 PowerShell 7 安装, +保留 Windows PowerShell 5.1,完成后需要重启 DSH;宿主自定义 `pwshPath` 需单独检查。 +打开页面不会运行安装程序。 + +客户端承载行使用稳定 ID `dsh-ops-file` 和裸包名 `dsh-ops` 承载这些浏览器页面,服务端仍为文件组件; +`dsh-ops/file` 导出保持兼容。关闭此承载行会卸载全部自定义设置页面,请使用宿主的 +**内置行开关**重新启用。没有自动表单回退。目标行被关闭时,也必须先重新启用才能编辑设置。 +发现组件行安装后,即可使用其设置页面。 + --- ## 配置的位置 diff --git a/package-lock.json b/package-lock.json index 92d8b1aa..dc14f090 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,14 @@ "@deepseek-ai/cosmokit": "1.8.5", "@deepseek-ai/dsh-agent-loop": "0.2.0-rc.2", "@deepseek-ai/dsh-api-gateway": "0.2.0-rc.2", + "@deepseek-ai/dsh-api-remotes": "0.2.0-rc.2", "@deepseek-ai/dsh-app-boot": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-locale": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-store": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-ui-renderer": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2", "@deepseek-ai/dsh-config-editor": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-local": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-observation-policy": "0.2.0-rc.2", @@ -47,7 +54,12 @@ "@deepseek-ai/schemastery": "3.18.4", "@dsh-std/manifest": "0.1.1-rc.3", "@types/node": "22.20.5", + "@types/react": "18.3.1", + "@types/react-dom": "18.3.0", "js-tiktoken": "1.0.21", + "jsdom": "26.1.0", + "react": "18.3.1", + "react-dom": "18.3.1", "typescript": "6.0.3", "yaml": "^2.9.1" }, @@ -67,6 +79,137 @@ } } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@deepseek-ai/cordis": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis/-/cordis-4.0.4.tgz", @@ -217,6 +360,22 @@ "@deepseek-ai/dsh-client-connection": "0.2.0-rc.2" } }, + "node_modules/@deepseek-ai/dsh-api-remotes": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-api-remotes/-/dsh-api-remotes-0.2.0-rc.2.tgz", + "integrity": "sha512-mfDctTtpM26rS5O0upviPofuyiiNCkaSgRifc5wgaCiBPJ5rG1FE2lxMQSfUiBASs7s+xXR5JVZlDrrZCzYTLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-deque": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2", + "@deepseek-ai/dsh-util-values": "0.2.0-rc.2" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-scope": "0.2.0-rc.2" + } + }, "node_modules/@deepseek-ai/dsh-app-boot": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-app-boot/-/dsh-app-boot-0.2.0-rc.2.tgz", @@ -293,6 +452,78 @@ "@deepseek-ai/dsh-scope": "0.2.0-rc.2" } }, + "node_modules/@deepseek-ai/dsh-client-locale": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-client-locale/-/dsh-client-locale-0.2.0-rc.2.tgz", + "integrity": "sha512-JdZPMv/8hLOQNxHk1dM6YbNbChB5S6lGaGAPtraP0670i9Tb4dX+yKOAUgkS7nSoO5TYZztXgI1kgcEregRPJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "~3.18.4" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-client-store": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-client-store/-/dsh-client-store-0.2.0-rc.2.tgz", + "integrity": "sha512-unHIDNnHudDIj05W99m+dVPXLYy7qNvMJ/D5FWb7cov0CbiQvliqbvFeMAU6LzC+u3vTkVJgSMIAwAVdQgYHEA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-client-ui-plugin-manager": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-client-ui-plugin-manager/-/dsh-client-ui-plugin-manager-0.2.0-rc.2.tgz", + "integrity": "sha512-AncAiGUMawllpfnqirmABN7KXwmVEhu0E8ClJvfsLDhEd2oazZ6nk2nUPHHPu1tVKq+e8IbiTHqwpnlDR7mPUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-timeout": "0.2.0-rc.2", + "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2", + "@deepseek-ai/schemastery": "~3.18.4", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-client-ui-renderer": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-client-ui-renderer/-/dsh-client-ui-renderer-0.2.0-rc.2.tgz", + "integrity": "sha512-vQ/LH4A53K1J/UeF5fQs1Dj8km5cnVos7vO0rmj+pJROhE6Vs7pAAKGz5P2e02t2/2to6+iMDu2+SSITrcYWTQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-client-ui-settings": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-client-ui-settings/-/dsh-client-ui-settings-0.2.0-rc.2.tgz", + "integrity": "sha512-Kyho8FvRGYb7Ok1t7T429d4Dj/XF8+tE/+MX6T1s41RkXCe7v0sP89i4DUMo6OeUxyE+/uGoAVa5QZXY4O/y+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "~3.18.4" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-client-ui-slots": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-client-ui-slots/-/dsh-client-ui-slots-0.2.0-rc.2.tgz", + "integrity": "sha512-zfILCyG3ijHT7S8+WDXFz4RMxBJtG9A3ngtDBnqcLbD5wzKz8H7E+HC657USa6RgG13C2RGSs73sfKcSs4WtWQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, "node_modules/@deepseek-ai/dsh-config-editor": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-config-editor/-/dsh-config-editor-0.2.0-rc.2.tgz", @@ -1324,6 +1555,34 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz", + "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", + "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@vscode/ripgrep": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.18.0.tgz", @@ -1513,6 +1772,16 @@ "win32" ] }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -1574,6 +1843,66 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/diff": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", @@ -1584,6 +1913,19 @@ "node": ">=0.3.1" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1608,6 +1950,67 @@ ], "license": "BSD-3-Clause" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tiktoken": { "version": "1.0.21", "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", @@ -1618,6 +2021,13 @@ "base64-js": "^1.5.1" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", @@ -1641,6 +2051,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -1676,6 +2126,33 @@ "@koromix/koffi-win32-x64": "3.1.1" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -1908,6 +2385,64 @@ "node-addon-api": "^7.1.0" } }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1942,6 +2477,43 @@ "node": ">=10" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -1955,6 +2527,59 @@ "node": ">=10" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -1987,6 +2612,67 @@ "dev": true, "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ws": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", @@ -2009,6 +2695,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yaml": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", diff --git a/package.json b/package.json index 4f1e33c1..cb468aa1 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,10 @@ "default": "./lib/components/hide-official/index.js" }, "./hide-official/package.json": "./lib/components/hide-official/package.json", - "./hide-official/locale/*.json": "./lib/components/hide-official/locale/*.json" + "./hide-official/locale/*.json": "./lib/components/hide-official/locale/*.json", + "./client": { + "default": "./lib/client.js" + } }, "bin": { "dsh-ops": "bin/dsh-ops.mjs" @@ -92,6 +95,15 @@ "dsh": { "bundle": { "patch": "./cordis.patch.yml" + }, + "client": { + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-plugin-manager" + ], + "platform": "web" } }, "files": [ @@ -162,7 +174,14 @@ "@deepseek-ai/cosmokit": "1.8.5", "@deepseek-ai/dsh-agent-loop": "0.2.0-rc.2", "@deepseek-ai/dsh-api-gateway": "0.2.0-rc.2", + "@deepseek-ai/dsh-api-remotes": "0.2.0-rc.2", "@deepseek-ai/dsh-app-boot": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-locale": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-store": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-ui-renderer": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2", "@deepseek-ai/dsh-config-editor": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-local": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-observation-policy": "0.2.0-rc.2", @@ -181,7 +200,12 @@ "@deepseek-ai/schemastery": "3.18.4", "@dsh-std/manifest": "0.1.1-rc.3", "@types/node": "22.20.5", + "@types/react": "18.3.1", + "@types/react-dom": "18.3.0", "js-tiktoken": "1.0.21", + "jsdom": "26.1.0", + "react": "18.3.1", + "react-dom": "18.3.1", "typescript": "6.0.3", "yaml": "^2.9.1" }, diff --git a/scripts/build.mjs b/scripts/build.mjs index dda4e5f3..4b7de44e 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process' import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { bundleClient } from './lib/build/client.mjs' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const source = path.join(root, 'src') @@ -41,6 +42,7 @@ try { process.exitCode = result.status ?? 1 console.error('build: TypeScript compilation failed; assets were not copied.') } else { + bundleClient(root) const assets = copyAssets(source) if (existsSync(path.join(source, 'ptc/contract.ts'))) { const { writePtcDeclarations } = await import('./lib/build/ptc-sdk.mjs') diff --git a/scripts/lib/build/client.mjs b/scripts/lib/build/client.mjs new file mode 100644 index 00000000..7af4643f --- /dev/null +++ b/scripts/lib/build/client.mjs @@ -0,0 +1,43 @@ +/** Build the host's lazy CommonJS browser entry from already type-checked modules. */ +import fs from 'node:fs' +import path from 'node:path' +import ts from 'typescript' +const external = new Set(['react', 'react/jsx-runtime']) +export function bundleClient(root) { + const output = path.join(root, 'lib'), entry = 'client/index.js' + if (!fs.existsSync(path.join(output, entry))) return + const modules = new Map(), visiting = new Set() + function collect(id) { + if (visiting.has(id)) throw new Error(`Browser module cycle: ${id}`) + if (modules.has(id)) return + if (id.startsWith('../') || path.posix.isAbsolute(id) || !id.endsWith('.js')) throw new Error(`Invalid browser module: ${id}`) + visiting.add(id) + const source = fs.readFileSync(path.join(output, id), 'utf8') + const compiled = ts.transpileModule(source, { fileName: id, compilerOptions: { + target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, allowJs: true, + } }).outputText + const tree = ts.createSourceFile(id, compiled, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS) + const imports = {} + function visit(node) { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'require') { + if (node.arguments.length !== 1 || !ts.isStringLiteral(node.arguments[0])) throw new Error(`Dynamic browser import in ${id}`) + const request = node.arguments[0].text + if (request.startsWith('.')) { + const target = path.posix.normalize(path.posix.join(path.posix.dirname(id), request)) + imports[request] = target + collect(target) + } else if (!external.has(request)) throw new Error(`Unsupported browser external ${request} in ${id}`) + } + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) throw new Error(`Dynamic browser import in ${id}`) + ts.forEachChild(node, visit) + } + visit(tree) + visiting.delete(id) + modules.set(id, { compiled, imports }) + } + collect(entry) + const definitions = [...modules].map(([id, value]) => `${JSON.stringify(id)}:{imports:${JSON.stringify(value.imports)},run(module,exports,require){\n${value.compiled}\n}}`).join(',\n') + const code = `window.__ModuleLoader__.load({id:"dsh-ops",factory:(require)=>{\nconst modules={${definitions}};\nconst cache=Object.create(null);\nfunction load(id){if(cache[id])return cache[id].exports;const entry=modules[id];if(!entry)throw new Error("Unknown OPS browser module: "+id);const module={exports:{}};cache[id]=module;entry.run(module,module.exports,request=>Object.hasOwn(entry.imports,request)?load(entry.imports[request]):require(request));return module.exports;}\nreturn load(${JSON.stringify(entry)});\n}});\n` + fs.writeFileSync(path.join(output, 'client.js'), code) + console.log(`build: bundled ${modules.size} browser modules (${Buffer.byteLength(code)} bytes)`) +} diff --git a/src/client/forms.tsx b/src/client/forms.tsx new file mode 100644 index 00000000..6ed6dc27 --- /dev/null +++ b/src/client/forms.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react' +import type { ConfigPageForm, PluginConfigViewProps } from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import type { SettingsPathOpView } from '@deepseek-ai/dsh-settings/types' +import type { Translator } from './locales.js' +export type SettingsProps = PluginConfigViewProps & { t: Translator } +export const strings = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] +export const errorText = (value: unknown): string => value instanceof Error ? value.message : String(value) +export const checked = (values: string[], key: string, enabled: boolean): string[] => enabled ? [...new Set([...values, key])] : values.filter(value => value !== key) +export function useEditor(form: ConfigPageForm | undefined, read: (value: Record) => T, t: Translator) { + const [draft, setDraft] = useState(() => read(form?.state.value ?? {})) + const [revision, setRevision] = useState(form?.state.revision) + const [dirty, setDirty] = useState(false), [busy, setBusy] = useState(false), [message, setMessage] = useState('') + const ready = form?.state.status === 'ready' && form.state.writable && form.state.mode === 'host' + useEffect(() => { + if (!dirty) { setDraft(read(form?.state.value ?? {})); setRevision(form?.state.revision) } + }, [form?.state.revision, form?.state.status, read, dirty]) + const edit = (value: T) => { setDraft(value); setDirty(true); setMessage('') } + const reset = () => { setDraft(read(form?.state.value ?? {})); setRevision(form?.state.revision); setDirty(false); setMessage('') } + const save = async (operations: readonly SettingsPathOpView[]) => { + if (!form || !ready || busy || revision === undefined) return + setBusy(true); setMessage('') + try { + const accepted = await form.mutate(operations, revision) + setMessage(t(accepted ? 'saved' : 'rejected')) + if (accepted) setDirty(false) + } catch (error) { setMessage(errorText(error)) } + finally { setBusy(false) } + } + return { draft, edit, dirty, busy, ready, message, reset, save } +} +export function Unavailable({ form, t }: SettingsProps) { + return

{t(form?.state.status === 'loading' ? 'loading' : 'unavailable')}

+} +export function FormActions({ busy, dirty, valid = true, reset, message, t }: { + busy: boolean; dirty: boolean; valid?: boolean; reset(): void; message: string; t: Translator +}) { + return <>
+ + +
{message &&

{message}

} +} diff --git a/src/client/index.tsx b/src/client/index.tsx new file mode 100644 index 00000000..197ef93b --- /dev/null +++ b/src/client/index.tsx @@ -0,0 +1,29 @@ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-api-gateway/client' +import { RUNTIME_REMOTE } from '../settings/runtime-contract.js' +import { en, zh } from './locales.js' +import { ReplacementForm, DiscoveryForm } from './selections.js' +import { ShellForm } from './shell-form.js' +import type { SettingsProps } from './forms.js' +import { SETTINGS_CSS } from './styles.js' +export const name = 'dsh-ops-settings' +export const inject = ['slots', 'locale', 'remote'] +export async function apply(ctx: Context): Promise { + ctx.effect(() => ctx.locale.register('dshOps', { en, zh }), 'dsh-ops: settings dictionaries') + await ctx.remote.$mount(RUNTIME_REMOTE) + ctx.effect(() => { + const style = document.createElement('style') + style.dataset.dshOpsSettings = 'true'; style.textContent = SETTINGS_CSS; document.head.append(style) + return () => style.remove() + }, 'dsh-ops: settings styles') + ctx.slots.inject('plugins.row.config', function* () { + yield ctx.slots.register({ name: 'plugins.row.config', key: 'dsh-ops#dsh-ops-hide-official', locale: 'dshOps' }, + (props: SettingsProps) => props.view === 'summary' ? <>{props.t('replacements')} : ) + yield ctx.slots.register({ name: 'plugins.row.config', key: 'dsh-ops#dsh-ops-discovery', locale: 'dshOps' }, + (props: SettingsProps) => props.view === 'summary' ? <>{props.t('discovery')} : ) + yield ctx.slots.register({ name: 'plugins.row.config', key: 'dsh-ops#dsh-ops-shell', locale: 'dshOps' }, + (props: SettingsProps) => props.view === 'summary' ? <>{props.t('shell')} : ) + }) +} diff --git a/src/client/locales.ts b/src/client/locales.ts new file mode 100644 index 00000000..65cb59e8 --- /dev/null +++ b/src/client/locales.ts @@ -0,0 +1,41 @@ +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +export const en = { + replacements: 'Choose which official tools OPS replaces.', discovery: 'Choose discoverable and always-loaded capabilities.', shell: 'Manage Bash and the standard host PowerShell installation.', + unavailable: 'Enable this row with the built-in switch to edit its settings. A writable host profile is required.', loading: 'Loading host settings…', + save: 'Save changes', reset: 'Reset to saved', saved: 'Saved. The host reloaded this row.', rejected: 'The host refused this save. Reset to the latest saved values and review your changes.', saving: 'Saving…', + replacementsHelp: 'Selected tools use OPS argument and output contracts. Read is bounded UTF-8; search excludes hidden files and links. Leave a tool unchecked to keep the official workflow.', + read: 'Read files', glob: 'Find file paths', grep: 'Search file contents', replacementPool: 'Keep inspect_local_file for read, or glob/grep for search, always loaded in Discovery or activate them before selecting a replacement. read_many is not the read counterpart.', + discoverEnabled: 'Enable on-demand discovery', discoverHelp: 'Always-loaded choices remain available when discovery is off. Permissions still apply to every call.', + allowed: 'Allowed capabilities', always: 'Always loaded', limit: 'Maximum tools per discovery call', invalidLimit: 'Choose a whole number from 1 to 5.', + poolHelp: 'Removing a capability from the allowed pool also removes it from the always-loaded selection.', + publishBash: 'Enable Bash for authorized sessions', bashPath: 'Bash executable override', bashHint: 'Use an absolute path. Clear the override to inherit the configured default.', + refresh: 'Refresh runtime status', managed: 'Use managed Bash', git: 'Use Git Bash', selectionHint: 'Review the selected path, then save the shell settings.', + runtime: 'Runtime management', runtimeHelp: 'Installations do not change your saved executor choice. Stop Bash jobs before removing a managed installation.', + previewInstall: 'Review Bash installation', previewRemove: 'Review Bash removal', previewPwsh: 'Review PowerShell upgrade', + install: 'Install managed Bash', remove: 'Remove managed Bash', upgrade: 'Install or upgrade PowerShell', + preview: 'Review this action', cancel: 'Cancel running action', dismiss: 'Close preview', busy: 'Working…', + pwshHelp: 'This updates the standard PowerShell 7 installation through WinGet. Restart DSH afterwards. Review a custom host pwshPath separately; Windows PowerShell 5.1 is preserved.', + unknownEffect: 'Changes may have occurred. Inspect the installation before trying again.', noRuntime: 'Runtime controls require an enabled shell row and the host runtime services.', + exitCode: 'Exit code', managedCount: 'Managed installations', gitCount: 'Git Bash installations', error: 'The operation failed.', +} +export const zh: Record = { + replacements: '选择由 OPS 替换的官方工具。', discovery: '选择可发现及始终加载的能力。', shell: '管理 Bash 和标准宿主 PowerShell 安装。', + unavailable: '请先用内置开关启用此行。编辑设置需要可写的宿主配置。', loading: '正在加载宿主设置…', + save: '保存更改', reset: '恢复已保存值', saved: '已保存,宿主已重新加载此行。', rejected: '宿主拒绝了此次保存。请恢复最新已保存值后重新检查更改。', saving: '正在保存…', + replacementsHelp: '所选工具改用 OPS 参数和输出契约。读取仅支持有界 UTF-8;搜索不包含隐藏文件和链接。取消勾选即可保留官方流程。', + read: '读取文件', glob: '查找文件路径', grep: '搜索文件内容', replacementPool: '请在发现设置中始终加载 read 对应的 inspect_local_file 或搜索对应的 glob/grep,或在选择替换前激活它们。read_many 不对应 read。', + discoverEnabled: '启用按需发现', discoverHelp: '关闭发现后,始终加载项仍然可用。每次调用仍受权限约束。', + allowed: '允许的能力', always: '始终加载', limit: '每次发现最多选择的工具数', invalidLimit: '请输入 1 到 5 的整数。', + poolHelp: '从允许池移除能力时,也会将其从始终加载项中移除。', + publishBash: '为获得授权的会话启用 Bash', bashPath: 'Bash 执行器路径覆盖', bashHint: '请使用绝对路径。清空覆盖值以继承配置默认值。', + refresh: '刷新运行时状态', managed: '使用受管理的 Bash', git: '使用 Git Bash', selectionHint: '请检查所选路径,然后保存 shell 设置。', + runtime: '运行时管理', runtimeHelp: '安装不会更改已保存的执行器选择。删除受管理的安装前,请先停止 Bash 作业。', + previewInstall: '预览 Bash 安装', previewRemove: '预览 Bash 删除', previewPwsh: '预览 PowerShell 升级', + install: '安装受管理的 Bash', remove: '删除受管理的 Bash', upgrade: '安装或升级 PowerShell', + preview: '检查本次操作', cancel: '取消正在执行的操作', dismiss: '关闭预览', busy: '正在处理…', + pwshHelp: '通过 WinGet 更新标准 PowerShell 7 安装,完成后需重启 DSH。宿主自定义 pwshPath 需要单独检查;Windows PowerShell 5.1 将保留。', + unknownEffect: '可能已经发生更改。再次尝试前,请检查安装状态。', noRuntime: '运行时控制需要启用 shell 行并提供宿主运行时服务。', + exitCode: '退出码', managedCount: '受管理的安装', gitCount: 'Git Bash 安装', error: '操作失败。', +} +declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { dshOps: keyof typeof en } } +export type Translator = TranslateNS<'dshOps'> diff --git a/src/client/runtime-panel.tsx b/src/client/runtime-panel.tsx new file mode 100644 index 00000000..b93bfe95 --- /dev/null +++ b/src/client/runtime-panel.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef, useState } from 'react' +import type { RuntimeAction, RuntimePlan, RuntimeRemote, RuntimeResult, RuntimeSnapshot, BashSource } from '../settings/runtime-contract.js' +import type { Translator } from './locales.js' +import { errorText } from './forms.js' +const executeLabel = { 'install-bash': 'install', 'remove-bash': 'remove', 'upgrade-pwsh': 'upgrade' } as const +export function RuntimePanel({ api, t, onSelect, editable }: { api: RuntimeRemote; t: Translator; onSelect(path: string): void; editable: boolean }) { + const [snapshot, setSnapshot] = useState(), [plan, setPlan] = useState() + const [result, setResult] = useState(), [message, setMessage] = useState(''), [busy, setBusy] = useState(false) + const cancellation = useRef(), mounted = useRef(true), refreshEpoch = useRef(0) + useEffect(() => { mounted.current = true; void refresh(); return () => { mounted.current = false; cancellation.current?.abort() } }, [api]) + async function refresh(clearMessage = true) { + const epoch = ++refreshEpoch.current + try { const response = await api.snapshot(); if (!mounted.current || epoch !== refreshEpoch.current) return + if (response.ok) { setSnapshot(response.value); if (clearMessage) setMessage('') } + else { setSnapshot(undefined); setMessage(response.error.message) } + } catch (error) { if (mounted.current && epoch === refreshEpoch.current) setMessage(errorText(error)) } + } + async function preview(action: RuntimeAction) { + setBusy(true); setMessage(''); setResult(undefined); setPlan(undefined) + try { const response = await api.plan(action); if (!mounted.current) return + if (response.ok) setPlan(response.value); else setMessage(response.error.message) + } catch (error) { if (mounted.current) setMessage(errorText(error)) } + finally { if (mounted.current) setBusy(false) } + } + async function select(source: BashSource) { + setBusy(true); setMessage('') + try { const response = await api.select(source); if (!mounted.current) return + if (response.ok) { onSelect(response.value.bashPath); setMessage(`${t('selectionHint')} ${response.value.restriction}`) } + else setMessage(response.error.message) + } catch (error) { if (mounted.current) setMessage(errorText(error)) } + finally { if (mounted.current) setBusy(false) } + } + async function execute() { + if (!plan?.available || busy) return + const controller = new AbortController(); cancellation.current = controller; setBusy(true); setMessage('') + try { const response = await api.execute(plan.id, controller.signal); if (!mounted.current) return + if (response.ok) setResult(response.value) + else setResult({ status: controller.signal.aborted ? 'cancelled' : 'failed', effectState: 'unknown', message: response.error.message }) + setPlan(undefined) + await refresh(false) + } catch (error) { if (mounted.current) setResult({ status: controller.signal.aborted ? 'cancelled' : 'failed', effectState: 'unknown', message: errorText(error) }) } + finally { cancellation.current = undefined; if (mounted.current) { setPlan(undefined); setBusy(false) } } + } + return
+

{t('runtime')}

{t('runtimeHelp')}

+ {!snapshot &&

{t('noRuntime')}

} + {snapshot &&

{t('managedCount')}: {snapshot.managed.filter(entry => entry.owned).length}; {t('gitCount')}: {snapshot.git.length}

} +
+ + + +
+
+ + +

{t('pwshHelp')}

+
+ {snapshot?.powerShell.unavailable &&

{snapshot.powerShell.unavailable}

} + {plan &&
+

{t(executeLabel[plan.action])}

    {plan.details.map((line, index) =>
  • {line}
  • )}
+ {plan.reason &&

{plan.reason}

} +
+
+
} + {busy &&

{t('busy')}

} + {busy && cancellation.current && } + {result &&

{result.message}

{result.exitCode !== undefined &&

{t('exitCode')}: {result.exitCode ?? '—'}

} + {result.effectState === 'unknown' &&

{t('unknownEffect')}

}
} + {message &&

{message}

} +
+} diff --git a/src/client/selections.tsx b/src/client/selections.tsx new file mode 100644 index 00000000..1e0ab192 --- /dev/null +++ b/src/client/selections.tsx @@ -0,0 +1,45 @@ +import { DISCOVERY_TERMS } from '../capabilities/discovery.js' +import { checked, FormActions, strings, Unavailable, useEditor } from './forms.js' +import type { SettingsProps } from './forms.js' +const replacements = ['read', 'glob', 'grep'] as const +const readReplacement = (value: Record) => strings(value.replaceTools) +export function ReplacementForm(props: SettingsProps) { + const { form, t } = props, editor = useEditor(form, readReplacement, t) + if (!editor.ready) return + return
{ event.preventDefault(); void editor.save([{ op: 'set', path: ['replaceTools'], value: editor.draft }]) }}> +

{t('replacementsHelp')}

{t('replacements')} + {replacements.map(name => )} +

{t('replacementPool')}

+ + +} +const names = Object.keys(DISCOVERY_TERMS) +const readDiscovery = (value: Record) => ({ allowed: strings(value.allowedTools), always: strings(value.alwaysLoaded), enabled: value.enabled !== false, limit: String(value.maxResults ?? 3) }) +export function DiscoveryForm(props: SettingsProps) { + const { form, t } = props, editor = useEditor(form, readDiscovery, t), draft = editor.draft + const limit = Number(draft.limit), valid = Number.isInteger(limit) && limit >= 1 && limit <= 5 + if (!editor.ready) return + return
{ event.preventDefault(); if (valid) void editor.save([ + { op: 'set', path: ['enabled'], value: draft.enabled }, { op: 'set', path: ['maxResults'], value: limit }, + { op: 'set', path: ['allowedTools'], value: draft.allowed }, { op: 'set', path: ['alwaysLoaded'], value: draft.always }, + ]) }}> + +

{t('discoverHelp')}

+ + {!valid &&

{t('invalidLimit')}

} +

{t('poolHelp')}

+
+
{t('allowed')}{names.map(name => + )}
+
{t('always')}{names.map(name => + )}
+
+ +} diff --git a/src/client/shell-form.tsx b/src/client/shell-form.tsx new file mode 100644 index 00000000..0915fa90 --- /dev/null +++ b/src/client/shell-form.tsx @@ -0,0 +1,21 @@ +import type { RuntimeRemote } from '../settings/runtime-contract.js' +import { FormActions, Unavailable, useEditor } from './forms.js' +import type { SettingsProps } from './forms.js' +import { RuntimePanel } from './runtime-panel.js' +const readShell = (value: Record) => ({ path: typeof value.bashPath === 'string' ? value.bashPath : '', enabled: value.publishBashTool !== false }) +export function ShellForm(props: SettingsProps & { api: RuntimeRemote }) { + const { form, t, api } = props, editor = useEditor(form, readShell, t) + return
+ {editor.ready ?
{ event.preventDefault(); void editor.save([ + editor.draft.path.trim() ? { op: 'set', path: ['bashPath'], value: editor.draft.path } : { op: 'unset', path: ['bashPath'] }, + { op: 'set', path: ['publishBashTool'], value: editor.draft.enabled }, + ]) }}> + + +

{t('bashHint')}

+ : } + editor.edit({ ...editor.draft, path })} /> +
+} diff --git a/src/client/styles.ts b/src/client/styles.ts new file mode 100644 index 00000000..866538a6 --- /dev/null +++ b/src/client/styles.ts @@ -0,0 +1,26 @@ +/** Host token palette and native controls; all styles stay inside this plugin's forms. */ +export const SETTINGS_CSS = ` +.ops-settings { color:var(--dsw-alias-label-primary,inherit); font:inherit; max-width:48rem; display:grid; gap:16px; } +.ops-settings p { margin:0; line-height:1.55; max-width:76ch; overflow-wrap:anywhere; } +.ops-settings h3 { font-size:1rem; margin:0; font-weight:600; } +.ops-settings fieldset { margin:0; padding:0; border:0; min-width:0; } +.ops-settings legend { font-weight:600; margin-bottom:10px; padding:0; } +.ops-settings-choice { display:flex; align-items:center; gap:10px; min-height:36px; cursor:pointer; } +.ops-settings-choice input { width:16px; height:16px; accent-color:var(--dsw-alias-button-primary-fill,#356be8); } +.ops-settings-choice code { overflow-wrap:anywhere; } +.ops-settings-columns { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; } +.ops-settings-field { display:grid; gap:8px; } +.ops-settings input[type=text], .ops-settings input[type=number] { box-sizing:border-box; max-width:100%; border:1px solid var(--dsw-alias-border-l3,#888); background:transparent; color:inherit; border-radius:var(--dsw-radius-sm,8px); padding:8px 10px; font:inherit; } +.ops-settings input[type=number] { width:7rem; } +.ops-settings button { font:inherit; color:inherit; background:transparent; padding:8px 14px; border:1px solid var(--dsw-alias-border-l3,#888); border-radius:var(--dsw-radius-md,12px); cursor:pointer; min-height:36px; } +.ops-settings button:hover:not(:disabled) { background:var(--dsw-alias-interactive-bg-hover,#8882); } +.ops-settings button.ops-settings-primary { background:var(--dsw-alias-button-primary-fill,#356be8); color:var(--dsw-alias-label-primary-foreground,#fff); border-color:transparent; } +.ops-settings :is(button,input):focus-visible { outline:2px solid var(--dsw-alias-button-primary-fill,#356be8); outline-offset:3px; } +.ops-settings :disabled { opacity:.5; cursor:not-allowed; } +.ops-settings-actions { display:flex; flex-wrap:wrap; gap:10px; } +.ops-settings-note { color:var(--dsw-alias-label-secondary,inherit); } +.ops-settings-runtime { border-top:1px solid var(--dsw-alias-border-l3,#888); padding-top:20px; margin-top:8px; } +.ops-settings-preview { border:1px solid var(--dsw-alias-border-l3,#888); border-radius:var(--dsw-radius-md,12px); padding:16px; display:grid; gap:12px; } +.ops-settings-preview ul { margin:0; padding-left:20px; line-height:1.55; overflow-wrap:anywhere; } +@media(max-width:440px) { .ops-settings-columns { grid-template-columns:1fr; gap:20px; } } +` diff --git a/test/build.test.mjs b/test/build.test.mjs index 7f02578a..05c73124 100644 --- a/test/build.test.mjs +++ b/test/build.test.mjs @@ -16,6 +16,7 @@ function fixture(root) { fs.mkdirSync(path.join(root, 'node_modules'), { recursive: true }) fs.copyFileSync(path.join(PACKAGE_ROOT, 'scripts/build.mjs'), path.join(root, 'scripts/build.mjs')) fs.copyFileSync(path.join(PACKAGE_ROOT, 'tsconfig.json'), path.join(root, 'tsconfig.json')) + write(root, 'scripts/lib/build/client.mjs', fs.readFileSync(path.join(PACKAGE_ROOT, 'scripts/lib/build/client.mjs'))) // Copies also work in Windows sandboxes where directory junctions are unavailable. for (const name of ['typescript', '@types/node', 'undici-types']) { fs.cpSync(path.join(PACKAGE_ROOT, 'node_modules', name), diff --git a/test/settings-client.test.mjs b/test/settings-client.test.mjs new file mode 100644 index 00000000..9b4e9058 --- /dev/null +++ b/test/settings-client.test.mjs @@ -0,0 +1,106 @@ +/** The real host SlotRegistry owns the loaded browser contribution and its teardown. */ +import fs from 'node:fs' +import path from 'node:path' +import vm from 'node:vm' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import * as Cordis from '@deepseek-ai/cordis' +import * as slots from '@deepseek-ai/dsh-client-ui-slots' +import Registry from '@deepseek-ai/dsh-typert-registry' +import Gateway from '@deepseek-ai/dsh-api-gateway' +import { RuntimeActions } from '../lib/settings/runtime-actions.js' +import { RUNTIME_DESCRIPTORS } from '../lib/settings/runtime-contract.js' +import { bundleClient } from '../scripts/lib/build/client.mjs' +import { assert, test, report, PACKAGE_ROOT, TEST_HOME, withTempDir } from './lib/harness.mjs' +import { shutdown } from './lib/host.mjs' +const require = createRequire(import.meta.url) +const dom = new JSDOM('') +globalThis.document = dom.window.document +function capture(file) { + let definition + globalThis.window = { __ModuleLoader__: { load: value => { definition = value } } } + vm.runInThisContext(fs.readFileSync(file, 'utf8'), { filename: file }) + assert.ok(definition); return definition +} +const external = name => name === '@deepseek-ai/cordis' ? Cordis : name === '@deepseek-ai/dsh-client-ui-slots' ? slots : require(name) + +await test('shipped browser entry stays lazy and binds exactly three settings pages to the real host registry', async () => { + const definition = capture(path.join(PACKAGE_ROOT, 'lib/client.js')), imports = [] + assert.equal(definition.id, 'dsh-ops'); assert.equal(typeof definition.factory, 'function') + assert.deepEqual(imports, []) + const plugin = definition.factory(name => { imports.push(name); return external(name) }) + assert.deepEqual([...new Set(imports)].sort(), ['react', 'react/jsx-runtime']) + const Renderer = capture(path.join(PACKAGE_ROOT, 'node_modules/@deepseek-ai/dsh-client-ui-renderer/lib/client.js')).factory(external) + const ctx = new Cordis.Context(); ctx.logger.level = 0 + let dictionaries, disposed = false, calls = 0, markStarted + const started = new Promise(resolve => { markStarted = resolve }) + try { + await ctx.plugin(Renderer.SlotRegistry) + await ctx.plugin(Registry); await ctx.plugin(Gateway, {}) + const ClientGateway = capture(path.join(PACKAGE_ROOT, 'node_modules/@deepseek-ai/dsh-api-gateway/lib/client.js')).factory(external) + await ctx.plugin({ inject: ['typert'], apply(inner) { + const service = new RuntimeActions({ env: { DSH_HOME: path.join(TEST_HOME, 'client-runtime') }, platform: 'win32', arch: 'x64', run: async (_argv, signal) => { + calls++; markStarted(); await new Promise(resolve => signal.addEventListener('abort', resolve, { once: true })) + return { started: true, exitCode: null, signal: 'aborted' } + } }) + inner.provide('opsSettings', service) + inner.typert.register({ package: 'dsh-ops', face: 'host', schemas: [], model: { services: [], events: [], objects: [] }, invocations: RUNTIME_DESCRIPTORS }) + inner.effect(() => () => service.dispose()) + inner.provide('connection', { registerGenerationSource: () => () => {}, start: () => ({ stop() {} }), generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, rpc: { + open() { throw new Error('No streams in this fixture') }, + async call(prefix, endpoint, payload, signal) { + assert.equal(prefix, '/api') + const [namespace, method] = endpoint.split('/') + return { ok: true, value: await ctx.typertGateway.invoke({ namespace, method, args: payload.args, signal }) } + }, + } }) + } }) + await ctx.plugin(ClientGateway) + await ctx.plugin({ inject: ['slots'], apply(inner) { + inner.provide('locale', { register(namespace, values) { dictionaries = { namespace, values }; return () => { disposed = true } } }) + inner.slots.register({ name: 'root', children: { 'plugins.row.config': { kind: 'keyed', scope: 'root' } } }, () => null) + } }) + const fiber = await ctx.plugin(plugin) + const plan = await ctx.get('remote').opsSettings.plan('install-bash') + assert.equal(plan.ok, true); assert.equal(calls, 0) + const controller = new AbortController() + const execution = ctx.get('remote').opsSettings.execute(plan.value.id, controller.signal) + await Promise.race([started, execution.then(value => { throw new Error(`Execution ended before spawn: ${JSON.stringify(value)}`) })]) + assert.equal(calls, 1); controller.abort() + const result = await execution + assert.equal(result.ok, false); assert.match(result.error.message, /abort/i) + assert.equal(dictionaries.namespace, 'dshOps') + assert.deepEqual(Object.keys(dictionaries.values.en).sort(), Object.keys(dictionaries.values.zh).sort()) + assert.deepEqual(ctx.get('slots').entries('plugins.row.config').map(value => value.options.key).sort(), ['dsh-ops#dsh-ops-discovery', 'dsh-ops#dsh-ops-hide-official', 'dsh-ops#dsh-ops-shell']) + assert.equal(document.querySelectorAll('style[data-dsh-ops-settings]').length, 1) + await fiber.dispose() + assert.equal(ctx.get('slots').entries('plugins.row.config').length, 0) + assert.equal(document.querySelectorAll('style[data-dsh-ops-settings]').length, 0) + assert.equal(disposed, true) + } finally { await shutdown(ctx) } +}) + +await test('browser bundler rejects Node externals and dynamic imports before writing an entry', () => withTempDir('browser-build-boundaries', root => { + const directory = path.join(root, 'lib/client'); fs.mkdirSync(directory, { recursive: true }) + for (const source of ["import fs from 'node:fs'; export const value=fs", "export async function value(name) { return import(name) }"]) { + fs.writeFileSync(path.join(directory, 'index.js'), source) + assert.throws(() => bundleClient(root), /Unsupported browser external|Dynamic browser import/) + assert.equal(fs.existsSync(path.join(root, 'lib/client.js')), false) + } + fs.writeFileSync(path.join(directory, 'index.js'), "import { answer } from './answer.js'; export const value=answer") + fs.writeFileSync(path.join(directory, 'answer.js'), 'export const answer=42') + bundleClient(root) + const loaded = capture(path.join(root, 'lib/client.js')).factory(() => { throw new Error('unexpected external') }) + assert.equal(loaded.value, 42) +})) + +await test('browser package declares an optional client facet while the default bundle preserves the legacy file row', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'))) + assert.equal(pkg.exports['./client'].default, './lib/client.js') + assert.equal(pkg.dsh.client.platform, 'web') + assert.ok(pkg.dsh.client.inject.includes('@deepseek-ai/dsh-client-ui-plugin-manager')) + assert.equal(pkg.exports['./file'].default, './lib/components/file/index.js') + assert.match(fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'), 'utf8'), /id: dsh-ops-file\s+name: dsh-ops\/file\s/) +}) +dom.window.close() +report('settings-client') diff --git a/test/settings-ui.test.mjs b/test/settings-ui.test.mjs new file mode 100644 index 00000000..3894a7b9 --- /dev/null +++ b/test/settings-ui.test.mjs @@ -0,0 +1,168 @@ +/** Browser interactions exercise real DOM events; the first fixture writes through the host. */ +import fs from 'node:fs' +import { JSDOM } from 'jsdom' +import { assert, test, report } from './lib/harness.mjs' +import { profileFixture } from './lib/settings-profile.mjs' +import { shutdown } from './lib/host.mjs' +import { Config } from '../lib/components/hide-official/index.js' +const dom = new JSDOM('', { url: 'http://localhost/' }) +for (const name of ['window', 'document', 'HTMLElement', 'HTMLInputElement', 'Node', 'Event', 'MouseEvent']) globalThis[name] = dom.window[name] +Object.defineProperty(globalThis, 'navigator', { configurable: true, value: dom.window.navigator }) +globalThis.IS_REACT_ACT_ENVIRONMENT = true +const { createElement: h, act } = await import('react') +const { createRoot } = await import('react-dom/client') +const { ReplacementForm, DiscoveryForm } = await import('../lib/client/selections.js') +const { ShellForm } = await import('../lib/client/shell-form.js') +const { RuntimePanel } = await import('../lib/client/runtime-panel.js') +const { en } = await import('../lib/client/locales.js') +const t = key => en[key] +const ready = (value, revision = 'v1') => ({ status: 'ready', mode: 'host', writable: true, value, base: {}, user: {}, revision }) +const snapshot = { platform: 'win32', managed: [{ version: 'fixture', directory: 'C:\\managed', owned: true }], git: ['C:\\Git\\bin\\bash.exe'], powerShell: { supported: true, guidance: 'Restart DSH.' } } +const ok = value => ({ ok: true, value }) +const defaultApi = () => ({ snapshot: async () => ok(snapshot), select: async () => ok({ bashPath: snapshot.git[0], restriction: 'Partial enforcement.' }), plan: async action => ok({ id: 'plan-id', action, available: true, details: ['Review installation first.'] }), execute: async () => ok({ status: 'completed', effectState: 'applied', message: 'Completed.' }) }) +async function mount(component, props) { + const container = document.createElement('div'); document.body.append(container) + const root = createRoot(container) + const update = next => root.render(h(component, { view: 'page', t, ...next })) + const render = async next => { await act(async () => update(next)) } + await render(props) + return { container, render, update, close: async () => { await act(async () => root.unmount()); container.remove() } } +} +const button = (container, text) => [...container.querySelectorAll('button')].find(node => node.textContent === text) +async function click(node) { assert.ok(node, 'DOM target exists'); await act(async () => node.click()) } +async function input(node, value) { + await act(async () => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(node, value); node.dispatchEvent(new Event('input', { bubbles: true })) }) +} + +await test('replacement DOM saves use host revision, persist and reload; subsequent edits use the new revision', async () => { + let observed, mounts = 0 + const fixture = await profileFixture('browser-replacements', { Config, apply(_ctx, value) { observed = value; mounts++ } }, { replaceTools: [] }) + let view, pending + const form = { state: ready(fixture.snapshot().value, fixture.snapshot().revision), mutate(ops, revision) { return pending = (async () => { + await fixture.mutate(ops, revision); await fixture.ctx.loader.await() + form.state = ready(fixture.snapshot().value, fixture.snapshot().revision) + view.update({ form }); return true + })() } } + try { + view = await mount(ReplacementForm, { form }) + const initialMounts = mounts + await click(view.container.querySelector('input')) + await click(button(view.container, en.save)) + await act(async () => pending) + assert.deepEqual(observed.replaceTools, ['read']) + assert.equal(mounts, initialMounts + 1) + assert.match(fs.readFileSync(fixture.patchPath, 'utf8'), /- read/) + assert.match(view.container.textContent, /Saved\. The host reloaded/) + await click(view.container.querySelectorAll('input')[1]) + await click(button(view.container, en.save)) + await act(async () => pending) + assert.deepEqual(observed.replaceTools, ['read', 'glob']) + assert.equal(mounts, initialMounts + 2) + } finally { await view?.close(); await shutdown(fixture.ctx) } +}) + +await test('dirty drafts retain their revision and refused saves never claim success', async () => { + const calls = [], form = { state: ready({ replaceTools: [] }), mutate: async (...args) => { calls.push(args); return false } } + const view = await mount(ReplacementForm, { form }) + try { + await click(view.container.querySelector('input')) + form.state = ready({ replaceTools: ['grep'] }, 'v2'); await view.render({ form }) + await click(button(view.container, en.save)) + assert.deepEqual(calls, [[[{ op: 'set', path: ['replaceTools'], value: ['read'] }], 'v1']]) + assert.match(view.container.textContent, /refused/) + assert.equal(view.container.textContent.includes(en.saved), false) + await click(button(view.container, en.reset)) + assert.equal(view.container.querySelectorAll('input')[2].checked, true) + form.mutate = async () => { throw new Error('Profile is read-only') } + await click(view.container.querySelector('input')); await click(button(view.container, en.save)) + assert.match(view.container.textContent, /Profile is read-only/) + } finally { await view.close() } +}) + +await test('discovery controls remove always-loaded entries with their pool and reject invalid limits', async () => { + const calls = [], form = { state: ready({ allowedTools: ['glob', 'grep'], alwaysLoaded: ['glob'], enabled: true, maxResults: 3 }), mutate: async (...args) => { calls.push(args); return true } } + const view = await mount(DiscoveryForm, { form }) + try { + const fields = view.container.querySelectorAll('fieldset') + const glob = [...fields[0].querySelectorAll('label')].find(node => node.textContent === 'glob').querySelector('input') + await click(glob) + const always = [...fields[1].querySelectorAll('label')].find(node => node.textContent === 'glob').querySelector('input') + assert.equal(always.checked, false); assert.equal(always.disabled, true) + await input(view.container.querySelector('input[type=number]'), '6') + assert.equal(button(view.container, en.save).disabled, true) + assert.match(view.container.querySelector('[role=alert]').textContent, /1 to 5/) + await input(view.container.querySelector('input[type=number]'), '2') + await click(button(view.container, en.save)) + assert.deepEqual(calls, [[[{ op: 'set', path: ['enabled'], value: true }, { op: 'set', path: ['maxResults'], value: 2 }, { op: 'set', path: ['allowedTools'], value: ['grep'] }, { op: 'set', path: ['alwaysLoaded'], value: [] }], 'v1']]) + } finally { await view.close() } +}) + +await test('runtime selection only edits the shell draft; clearing an override uses unset', async () => { + const calls = [], form = { state: ready({ publishBashTool: true }), mutate: async (...args) => { calls.push(args); return true } } + const view = await mount(ShellForm, { form, api: defaultApi() }) + try { + await click(button(view.container, en.git)) + assert.equal(calls.length, 0) + assert.equal(view.container.querySelector('input[type=text]').value, snapshot.git[0]) + await click(button(view.container, en.save)) + assert.equal(calls[0][0][0].value, snapshot.git[0]) + await input(view.container.querySelector('input[type=text]'), 'temporary') + await input(view.container.querySelector('input[type=text]'), '') + await click(button(view.container, en.save)) + assert.deepEqual(calls[1][0][0], { op: 'unset', path: ['bashPath'] }) + } finally { await view.close() } +}) + +await test('runtime actions require preview plus explicit execution and retain transport failures after refresh', async () => { + const calls = [], api = defaultApi() + api.plan = async action => { calls.push(['plan', action]); return ok({ id: 'reviewed-id', action, available: true, details: ['Standard host installation.'] }) } + api.execute = async id => { calls.push(['execute', id]); return { ok: false, error: { message: 'Shell row became unavailable' } } } + const view = await mount(RuntimePanel, { api, editable: true, onSelect() {} }) + try { + await click(button(view.container, en.previewPwsh)) + assert.deepEqual(calls, [['plan', 'upgrade-pwsh']]) + assert.match(view.container.querySelector('[role=region]').textContent, /Standard host installation/) + await click(button(view.container, en.upgrade)) + assert.deepEqual(calls, [['plan', 'upgrade-pwsh'], ['execute', 'reviewed-id']]) + assert.match(view.container.textContent, /Shell row became unavailable/) + assert.match(view.container.textContent, /Changes may have occurred/) + assert.equal(view.container.querySelector('[role=region]'), null) + } finally { await view.close() } +}) + +await test('cancelled runtime execution preserves unknown effects and unavailable plans cannot execute', async () => { + const api = defaultApi(); let signal, finish, executions = 0 + api.execute = async (_id, inputSignal) => { executions++; signal = inputSignal; return new Promise(resolve => { finish = () => resolve({ ok: false, error: { message: 'Remote call cancelled after launch.' } }); inputSignal.addEventListener('abort', finish, { once: true }) }) } + const view = await mount(RuntimePanel, { api, editable: true, onSelect() {} }) + try { + await click(button(view.container, en.previewInstall)); await click(button(view.container, en.install)) + assert.equal(executions, 1); assert.equal(signal.aborted, false) + await click(button(view.container, en.cancel)) + assert.equal(signal.aborted, true); assert.match(view.container.textContent, /Changes may have occurred/) + api.plan = async action => ok({ id: 'blocked', action, available: false, details: [], reason: 'Unsupported platform' }) + await click(button(view.container, en.previewInstall)) + assert.equal(button(view.container, en.install).disabled, true) + assert.equal(executions, 1) + } finally { finish?.(); await view.close() } +}) + +await test('an older failed runtime refresh cannot replace a newer successful status', async () => { + const pending = [], api = defaultApi() + api.snapshot = () => new Promise((resolve, reject) => pending.push({ resolve, reject })) + const view = await mount(RuntimePanel, { api, editable: true, onSelect() {} }) + try { + await click(button(view.container, en.refresh)) + await act(async () => pending[1].resolve(ok(snapshot))) + await act(async () => pending[0].reject(new Error('obsolete network failure'))) + assert.equal(view.container.textContent.includes('obsolete network failure'), false) + assert.equal(button(view.container, en.git).disabled, false) + } finally { await view.close() } +}) + +await test('unavailable settings expose recovery instructions without an editable form', async () => { + const view = await mount(ReplacementForm, { form: { state: { status: 'unavailable', value: {} } } }) + try { assert.equal(view.container.querySelector('form'), null); assert.match(view.container.textContent, /built-in switch/); assert.equal(view.container.querySelector('input'), null) } + finally { await view.close() } +}) +dom.window.close() +report('settings-ui') diff --git a/tsconfig.json b/tsconfig.json index c4d96988..60501e39 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,8 +5,13 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ES2023", - "lib": ["ES2023"], - "types": ["node"], + "lib": [ + "ES2023", + "DOM" + ], + "types": [ + "node" + ], "allowJs": true, "checkJs": false, "strict": true, @@ -17,7 +22,10 @@ "sourceMap": false, "newLine": "lf", "noEmitOnError": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx" }, - "include": ["src/**/*"] + "include": [ + "src/**/*" + ] } From 49475fc34ce174ce7273451aa982ce034e970974 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:21:37 +0800 Subject: [PATCH 078/118] feat(settings): add opt-in profile carrier migration --- bin/dsh-ops.mjs | 9 + docs/CHANGELOG.md | 2 + docs/CONTRIBUTING.md | 5 + docs/configuration-reference.md | 26 +++ ...41\347\214\256\346\214\207\345\215\227.md" | 4 + ...15\347\275\256\345\217\202\350\200\203.md" | 22 +++ package-lock.json | 11 ++ package.json | 1 + scripts/build.mjs | 2 + scripts/lib/build/client-profile.mjs | 12 ++ src/cli/settings-profile.ts | 31 +++ src/profiles/client/package.json | 7 + src/settings/carrier-plan.ts | 80 ++++++++ src/settings/carrier-write.ts | 45 +++++ test/build.test.mjs | 5 +- test/settings-carrier.test.mjs | 183 ++++++++++++++++++ 16 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 scripts/lib/build/client-profile.mjs create mode 100644 src/cli/settings-profile.ts create mode 100644 src/profiles/client/package.json create mode 100644 src/settings/carrier-plan.ts create mode 100644 src/settings/carrier-write.ts create mode 100644 test/settings-carrier.test.mjs diff --git a/bin/dsh-ops.mjs b/bin/dsh-ops.mjs index 5e29a772..af94b363 100644 --- a/bin/dsh-ops.mjs +++ b/bin/dsh-ops.mjs @@ -23,6 +23,7 @@ import { commandProvisionShells } from '../lib/cli/provision-shells.js' import { commandUpgradeHostPwsh } from '../lib/cli/pwsh-upgrade.js' import { commandBashRuntime } from '../lib/cli/bash-runtime.js' import { commandUninstall } from '../lib/cli/uninstall.js' +import { commandSettingsProfile } from '../lib/cli/settings-profile.js' /** * Print one usage block. @@ -38,6 +39,8 @@ Usage: Remove the profile plugin and its binary dependency references dsh-ops status --profile Report target-profile installation (tui maps to dsh-tui) + dsh-ops settings-profile --profile-dir --host-anchor [--dsh-home ] [--offline --yes] + Preview or explicitly enable the optional browser settings carrier dsh-ops build-ptc Build the independent native Rust worker (all platforms) dsh-ops uninstall [--yes] [--purge-fastctx] Report files older versions left under /dsh-ops and, @@ -93,6 +96,10 @@ async function main() { allowPositionals: true, options: { profile: { type: 'string' }, + 'profile-dir': { type: 'string' }, + 'host-anchor': { type: 'string' }, + 'dsh-home': { type: 'string' }, + offline: { type: 'boolean', default: false }, 'dsh-cli': { type: 'string' }, force: { type: 'boolean', default: false }, yes: { type: 'boolean', default: false }, @@ -115,6 +122,8 @@ async function main() { return 2 } switch (command) { + case 'settings-profile': + return commandSettingsProfile({ profileDir: values['profile-dir'], hostAnchor: values['host-anchor'], home: values['dsh-home'], yes: values.yes, offline: values.offline, dryRun: values['dry-run'] }) case 'upgrade-host-pwsh': return commandUpgradeHostPwsh({ yes: values.yes, dryRun: values['dry-run'] }) case 'bash-runtime': diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 61627d2f..5fdef019 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,8 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add optional bilingual browser settings pages for replacement selection, discovery pools and explicit Bash/host PowerShell runtime plans. Preserve the default legacy file row; custom pages require a client carrier and unload when it is disabled. - 新增可选双语浏览器设置页面,支持逐工具替换、发现池及显式 Bash/宿主 PowerShell 运行时计划。默认保留旧文件行;自定义页面需要客户端承载行,关闭承载行即卸载页面。 +- Add an opt-in offline settings-carrier migration with exact host composition checks, original-file backups, atomic writes and recovery. The normal upgrade retains legacy file-row names and settings. +- 新增可选离线设置承载行迁移:精确验证宿主配置合成结果,备份原文件,原子写入并支持恢复。常规升级仍保留旧文件行名称与配置。 - Add discovery as the seventh bundle row; preserve existing named row overrides. Fresh defaults enable shell only on Windows and leave panel/TUI/replacements off. Enabling a fresh panel row starts capture; explicit legacy capture:false stays inert. Provide builtin validated file/PTC/TUI configuration. - bundle 新增第七行 discovery,保留已有带名称行覆盖。首次安装仅 Windows 默认启用 shell,panel/TUI/替换默认关闭;启用新 panel 行即采集,旧配置 capture:false 保持不采集。补充文件/PTC/TUI 内置校验配置。 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 861ddc1b..a34e8c6a 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -204,3 +204,8 @@ React type packages, jsdom 26.1.0 and the pinned `0.2.0-rc.2` client contracts s DOM interactions and the real host slot lifecycle tests. No browser bundler or production dependency is added. `settings-ui`, `settings-client`, `settings-host` and `settings-runtime` suites cover the form-to-profile and reviewed-action boundaries. + +The optional carrier regression also uses development-only `@deepseek-ai/dsh-client-modules` +`0.2.0-rc.2` (published 2026-09-29) to assert actual client registry activation, teardown and +restart against temporary installed profiles. `settings-carrier` covers exact composition, +backups and atomic rollback; it never migrates a developer profile. diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index a98135d2..f19808f9 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -35,6 +35,32 @@ this carrier unloads all custom settings pages. Use the host's **built-in row sw it. There is no automatic form fallback. A disabled target row must also be re-enabled before its settings can be edited. The discovery page is available once its component row is installed. +### Enable the optional browser carrier + +This is an opt-in offline profile migration, not a prerequisite for upgrading. With DSH stopped, +preview the installed profile using absolute paths: + +```console +dsh-ops settings-profile --profile-dir --host-anchor --dsh-home +``` + +`--host-anchor` identifies the host installation whose public profile APIs the command uses. +After reviewing the preview, repeat it with `--offline --yes`. The command selects +`dsh-ops/lib/profiles/client` **instead of** `dsh-ops`, removes only verified legacy file-name +matchers in that profile's patch, and preserves all seven IDs, switches, and effective configuration. +It refuses skipped bundles, duplicate OPS rows, external name-qualified file overrides, +incompatible higher-priority patches, or stale previews. Home patches are included; launch-time command-line overlays are outside this preview. +No other profile is changed. Do not enable both bundle choices together. + +Original files are backed up in the reported `.dsh-ops-settings-backup-*` directory. Each write uses +the host's atomic writer and profile lock. The first write remains valid with the old bundle if the +process stops before the second; reported write failures restore unchanged originals when possible. +For manual recovery while stopped, restore `package.json` first, then `cordis.patch.yml` as described +in `recovery.json`. Keep the backup until the restarted profile is verified. The installed `dsh-ops` +package supplies both bundle choices; the nested layer is not a separate package to install. +An already-disabled file row remains disabled, so re-enable it through the builtin switch before +expecting custom pages. The default legacy bundle remains supported without this command. + --- ## Where configuration lives diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 38312e25..21b55512 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -137,3 +137,7 @@ React 由宿主提供。精确锁定且仅用于开发的 React 18.3.1、React `0.2.0-rc.2` 客户端契约用于编译、DOM 交互及真实宿主插槽生命周期测试,不添加浏览器打包器或 生产依赖。`settings-ui`、`settings-client`、`settings-host` 和 `settings-runtime` 套件覆盖 表单到配置持久化以及先检查后执行的操作边界。 + +可选承载行回归测试还使用仅开发依赖 `@deepseek-ai/dsh-client-modules` +`0.2.0-rc.2`(2026-09-29 发布),在临时安装 profile 中验证真实客户端注册表的启用、 +卸载与重启。`settings-carrier` 覆盖精确配置合成、备份和原子回滚,不迁移开发者实际 profile。 diff --git "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" index 677ba404..75dadff0 100644 --- "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" +++ "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" @@ -33,6 +33,28 @@ Bash 安装/删除与 PowerShell 升级按钮首先显示计划,随后需要 **内置行开关**重新启用。没有自动表单回退。目标行被关闭时,也必须先重新启用才能编辑设置。 发现组件行安装后,即可使用其设置页面。 +### 启用可选浏览器承载行 + +这是主动选择的离线 profile 迁移,不是升级前提。停止 DSH 后,使用绝对路径预览已安装 profile: + +```console +dsh-ops settings-profile --profile-dir --host-anchor --dsh-home +``` + +`--host-anchor` 指定提供公开 profile API 的宿主安装。检查预览后,添加 `--offline --yes` +再次执行。命令以 `dsh-ops/lib/profiles/client` **替代** `dsh-ops` bundle, +仅移除该 profile 补丁中经过验证的旧文件行名称匹配条件,保留全部七个 ID、开关和有效配置。 +遇到缺失 bundle、重复 OPS 行、外部带名称文件行覆盖、不兼容的更高优先级补丁或过期预览时拒绝写入。 +预览包括 home 补丁,不包括启动命令行覆盖;不会更改其他 profile。不要同时启用两种 bundle。 + +原文件备份到输出显示的 `.dsh-ops-settings-backup-*` 目录;每次写入使用宿主原子写入器和 +profile 锁。如果两次写入之间进程退出,第一步结果仍兼容旧 bundle;报告写入失败时, +会在没有并发修改的情况下恢复原文件。如需手动恢复,停止 DSH 后按 `recovery.json` 指示先恢复 +`package.json`,再恢复 `cordis.patch.yml`。重启验证成功前请保留备份。 +两种 bundle 均由已安装的 `dsh-ops` 包提供,嵌套层不是需要另行安装的软件包。 +原来关闭的文件行保持关闭,需先用内置行开关启用才能显示自定义页面。 +不运行该命令也可以继续使用默认旧名称 bundle。 + --- ## 配置的位置 diff --git a/package-lock.json b/package-lock.json index dc14f090..301a1f15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-api-remotes": "0.2.0-rc.2", "@deepseek-ai/dsh-app-boot": "0.2.0-rc.2", "@deepseek-ai/dsh-client-locale": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-modules": "0.2.0-rc.2", "@deepseek-ai/dsh-client-store": "0.2.0-rc.2", "@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.0-rc.2", "@deepseek-ai/dsh-client-ui-renderer": "0.2.0-rc.2", @@ -465,6 +466,16 @@ "@deepseek-ai/cordis": "~4.0.4" } }, + "node_modules/@deepseek-ai/dsh-client-modules": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-client-modules/-/dsh-client-modules-0.2.0-rc.2.tgz", + "integrity": "sha512-eDF7dycEl8QJ8CAZTSTz+dZh2PFHsf/ESAsjWjhcHxueLji7LF8/MY54PgU051Ho0V7zQKCAAeFWK2/GyIpgIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, "node_modules/@deepseek-ai/dsh-client-store": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-client-store/-/dsh-client-store-0.2.0-rc.2.tgz", diff --git a/package.json b/package.json index cb468aa1..aaa567ea 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,7 @@ "@deepseek-ai/dsh-api-remotes": "0.2.0-rc.2", "@deepseek-ai/dsh-app-boot": "0.2.0-rc.2", "@deepseek-ai/dsh-client-locale": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-modules": "0.2.0-rc.2", "@deepseek-ai/dsh-client-store": "0.2.0-rc.2", "@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.0-rc.2", "@deepseek-ai/dsh-client-ui-renderer": "0.2.0-rc.2", diff --git a/scripts/build.mjs b/scripts/build.mjs index 4b7de44e..fdc282ff 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -5,6 +5,7 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:f import path from 'node:path' import { fileURLToPath } from 'node:url' import { bundleClient } from './lib/build/client.mjs' +import { buildClientProfile } from './lib/build/client-profile.mjs' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const source = path.join(root, 'src') @@ -49,6 +50,7 @@ try { const sdk = await writePtcDeclarations(root) console.log(`build: generated PTC declarations for ${sdk.tools} capabilities (${sdk.bytes} bytes)`) } + buildClientProfile(root) console.log(`build: compiled src/ to lib/ and copied ${assets} assets`) } } catch (error) { diff --git a/scripts/lib/build/client-profile.mjs b/scripts/lib/build/client-profile.mjs new file mode 100644 index 00000000..c410f7a1 --- /dev/null +++ b/scripts/lib/build/client-profile.mjs @@ -0,0 +1,12 @@ +/** Generate the opt-in layer from canonical defaults; only the file specifier differs. */ +import { readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { parse, stringify } from 'yaml' +export function buildClientProfile(root) { + const patches = parse(readFileSync(path.join(root, 'cordis.patch.yml'), 'utf8')) + const rows = patches.flatMap(patch => patch.insert ?? []) + const files = rows.filter(row => row.id === 'dsh-ops-file') + if (rows.length !== 7 || files.length !== 1 || files[0].name !== 'dsh-ops/file') throw new Error('Cannot derive the optional settings profile from this bundle layout.') + files[0].name = 'dsh-ops' + writeFileSync(path.join(root, 'lib/profiles/client/cordis.patch.yml'), '# Generated from the canonical bundle; do not edit.\n' + stringify(patches)) +} diff --git a/src/cli/settings-profile.ts b/src/cli/settings-profile.ts new file mode 100644 index 00000000..1d73a987 --- /dev/null +++ b/src/cli/settings-profile.ts @@ -0,0 +1,31 @@ +/** Explicit offline profile change; no host package values load with the plugin. */ +import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' +import path from 'node:path' +import { dshHome } from '../paths.js' +import { applyCarrier, carrierPreview } from '../settings/carrier-write.js' +import { CLIENT_BUNDLE } from '../settings/carrier-plan.js' +import type { CarrierHost } from '../settings/carrier-plan.js' +export async function loadCarrierHost(anchor: string): Promise { + if (!path.isAbsolute(anchor)) throw new Error('--host-anchor must be an absolute path to the host package.json.') + const require = createRequire(anchor) + const editorRequire = createRequire(require.resolve('@deepseek-ai/dsh-config-editor')) + const boot = await import(pathToFileURL(require.resolve('@deepseek-ai/dsh-app-boot')).href) as CarrierHost['boot'] + const yaml = await import(pathToFileURL(editorRequire.resolve('yaml')).href) as CarrierHost['yaml'] + const atomic = await import(pathToFileURL(editorRequire.resolve('@deepseek-ai/dsh-atomic-write')).href) as CarrierHost['atomic'] + for (const name of ['loadProfileDirectory', 'composeEntries', 'readProfilePatches', 'resolveBundleDir', 'loadOverlayPatches'] as const) if (typeof boot[name] !== 'function') throw new Error(`Host profile API unavailable: ${name}`) + return { boot, yaml, atomic } +} +export async function commandSettingsProfile(options: { profileDir?: string; hostAnchor?: string; home?: string; yes?: boolean; offline?: boolean; dryRun?: boolean }): Promise { + if (!options.profileDir || !options.hostAnchor) throw new Error('Use --profile-dir and --host-anchor .') + const host = await loadCarrierHost(options.hostAnchor) + const plan = await carrierPreview(host, { dir: options.profileDir, installAnchor: options.hostAnchor, home: options.home ?? dshHome() }) + console.log(`Select ${CLIENT_BUNDLE} instead of dsh-ops; retain seven row IDs, switches and effective configuration.`) + console.log(`Normalize ${plan.changedQualifiers} verified file-row name matcher(s). Files: ${plan.files.map(file => file.path).join(', ')}`) + console.log('Stop all DSH processes using this profile before applying. Launch-time command-line overlays are outside this persistent profile preview.') + if (!options.yes || options.dryRun) { console.log('Preview only. Apply with --offline --yes after stopping DSH.'); return 0 } + if (!options.offline) throw new Error('--offline is required to confirm the profile has been stopped.') + const backup = await applyCarrier(host, plan) + console.log(`Saved and verified. Backup: ${backup}. Restart DSH; custom pages require the file row to remain enabled.`) + return 0 +} diff --git a/src/profiles/client/package.json b/src/profiles/client/package.json new file mode 100644 index 00000000..5bd73bd3 --- /dev/null +++ b/src/profiles/client/package.json @@ -0,0 +1,7 @@ +{ + "name": "dsh-ops-client-profile", + "private": true, + "type": "module", + "description": "Optional seven-row OPS bundle with a browser settings carrier; select instead of the default bundle.", + "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } +} diff --git a/src/settings/carrier-plan.ts b/src/settings/carrier-plan.ts new file mode 100644 index 00000000..9e869649 --- /dev/null +++ b/src/settings/carrier-plan.ts @@ -0,0 +1,80 @@ +/** Offline planning against the host's actual persistent profile composition. */ +import fs from 'node:fs' +import path from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import type * as Boot from '@deepseek-ai/dsh-app-boot' +import type * as Yaml from 'yaml' +import type * as Atomic from '@deepseek-ai/dsh-atomic-write' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { COMPONENTS } from '../components/defaults.js' + +export const CLIENT_BUNDLE = 'dsh-ops/lib/profiles/client' +export interface CarrierHost { boot: typeof Boot; yaml: typeof Yaml; atomic: typeof Atomic } +export interface CarrierLocation { dir: string; home: string; installAnchor: string } +export interface FileChange { path: string; before?: string; after: string; mode: number } +export interface CarrierPlan { files: FileChange[]; changedQualifiers: number; beforeRows: EntryOptions[]; afterRows: EntryOptions[]; location: CarrierLocation } +function object(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected an object in the profile manifest.') + return value as Record +} +function read(file: string, optional = false): { text?: string; mode: number } { + try { + const stat = fs.lstatSync(file) + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) throw new Error(`Expected a regular profile file at most 1 MiB: ${file}`) + return { text: fs.readFileSync(file, 'utf8'), mode: stat.mode & 0o777 } + } catch (error) { + if (optional && (error as NodeJS.ErrnoException).code === 'ENOENT') return { mode: 0o600 } + throw error + } +} +const flatten = (rows: EntryOptions[]): EntryOptions[] => rows.flatMap(row => [row, ...row.group && Array.isArray(row.config) ? flatten(row.config as EntryOptions[]) : []]) +export function profileContext(location: CarrierLocation): Boot.ProfileContext { + return { ...location, name: path.basename(location.dir), cwd: location.dir, patchPath: path.join(location.dir, 'cordis.patch.yml'), startedBundles: [], overlays: [], telemetryDisabledEnv: process.env.DSH_TELEMETRY_DISABLED } +} +export function prepareCarrier(host: CarrierHost, location: CarrierLocation): CarrierPlan { + for (const value of Object.values(location)) if (!path.isAbsolute(value)) throw new Error('Profile directory, DSH home and host anchor must be absolute paths.') + const directoryStat = fs.lstatSync(location.dir) + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error('Use a physical profile directory, not a symbolic link.') + const manifestPath = path.join(location.dir, 'package.json'), patchPath = path.join(location.dir, 'cordis.patch.yml') + const manifestFile = read(manifestPath), patchFile = read(patchPath, true) + const manifest = object(JSON.parse(manifestFile.text!)), dsh = object(manifest.dsh), profile = object(dsh.profile) + const bundles = profile.bundles + if (!Array.isArray(bundles) || bundles.some(name => typeof name !== 'string')) throw new Error('Profile bundles must be a string array.') + if (bundles.filter(name => name === 'dsh-ops').length !== 1 || bundles.includes(CLIENT_BUNDLE)) throw new Error('Select exactly one default dsh-ops bundle before this one-time migration.') + const context = profileContext(location), loaded = host.boot.loadProfileDirectory('dsh', location.dir, location.installAnchor) + if (loaded.skippedBundles.length) throw new Error('Resolve skipped profile bundles before enabling custom settings.') + // Equal values today do not erase a saved override's intent when defaults change later. + const external = host.boot.readProfilePatches('dsh', context, { ...loaded, patches: [], + layers: loaded.layers.filter(layer => layer.packageName !== 'dsh-ops') }) + if (external.some(patch => !patch.insert && patch.id === 'dsh-ops-file' && patch.name === 'dsh-ops/file')) throw new Error('External name-qualified file overrides require manual review; nothing was written.') + const original = host.boot.composeEntries([host.boot.readProfilePatches('dsh', context, loaded)]) + const own = flatten(original).filter(row => row.id?.startsWith('dsh-ops-')) + if (own.length !== 7 || COMPONENTS.some(name => own.filter(row => row.id === `dsh-ops-${name}` && row.name === `dsh-ops/${name}`).length !== 1)) throw new Error('Expected the seven unique legacy-named OPS rows; custom insertions or shadowing require manual review.') + const document = host.yaml.parseDocument(patchFile.text ?? '[]\n', { customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: (value: string) => value }] }) + if (document.errors.length || !host.yaml.isSeq(document.contents)) throw new Error('Profile patch must be a valid YAML sequence.') + let changedQualifiers = 0 + for (const node of document.contents.items) { + if (host.yaml.isMap(node) && node.get('id', true)?.toJSON() === 'dsh-ops-file' && !node.has('insert') && node.get('name', true)?.toJSON() === 'dsh-ops/file') { + node.delete('name'); changedQualifiers++ + } + } + const patchAfter = changedQualifiers ? String(document) : patchFile.text ?? '[]\n' + const parsed = host.yaml.parseDocument(patchAfter, { customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: (value: string) => ({ __jsExpr: value }) }] }).toJS() as PatchOptions[] + const neutral = { ...loaded, patches: parsed } + if (!isDeepStrictEqual(host.boot.composeEntries([host.boot.readProfilePatches('dsh', context, neutral)]), original)) throw new Error('Removing a file-row matcher would change effective configuration; nothing was written.') + const parent = loaded.layers.find(layer => layer.packageName === 'dsh-ops')! + const directory = host.boot.resolveBundleDir('dsh', CLIENT_BUNDLE, location.installAnchor, location.dir) + if (fs.realpathSync(directory) !== fs.realpathSync(path.join(parent.packageDir, 'lib/profiles/client'))) throw new Error('The optional layer must belong to the selected OPS installation.') + const patch = path.join(directory, 'cordis.patch.yml') + const alternate = { packageName: CLIENT_BUNDLE, packageDir: directory, patchPaths: [patch], patches: host.boot.loadOverlayPatches('dsh', patch) } + const candidate = { ...neutral, layers: loaded.layers.map(layer => layer === parent ? alternate : layer) } + const afterRows = host.boot.composeEntries([host.boot.readProfilePatches('dsh', context, candidate)]) + const expected = structuredClone(original) + flatten(expected).find(row => row.id === 'dsh-ops-file')!.name = 'dsh-ops' + if (!isDeepStrictEqual(afterRows, expected)) throw new Error('The optional layer or a higher-priority patch changes more than the carrier name; nothing was written.') + profile.bundles = bundles.map(name => name === 'dsh-ops' ? CLIENT_BUNDLE : name) + return { location, changedQualifiers, beforeRows: original, afterRows, + files: [...(patchFile.text === undefined ? [] : [{ path: patchPath, before: patchFile.text, after: patchAfter, mode: patchFile.mode }]), + { path: manifestPath, before: manifestFile.text, after: JSON.stringify(manifest, null, 2) + '\n', mode: manifestFile.mode }] } +} diff --git a/src/settings/carrier-write.ts b/src/settings/carrier-write.ts new file mode 100644 index 00000000..02bb1e09 --- /dev/null +++ b/src/settings/carrier-write.ts @@ -0,0 +1,45 @@ +/** The neutral matcher update stays valid if interrupted before the bundle switch. */ +import fs from 'node:fs/promises' +import path from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import { prepareCarrier, profileContext } from './carrier-plan.js' +import type { CarrierHost, CarrierLocation, CarrierPlan } from './carrier-plan.js' +async function text(file: string): Promise { + try { return await fs.readFile(file, 'utf8') } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error } +} +export async function applyCarrier(host: CarrierHost, reviewed: CarrierPlan): Promise { + const manifestPath = path.join(reviewed.location.dir, 'package.json') + return host.atomic.withFileLock(manifestPath, async () => { + const plan = prepareCarrier(host, reviewed.location) + if (!isDeepStrictEqual(plan, reviewed)) throw new Error('Profile changed after the preview; review a fresh plan.') + const backup = await fs.mkdtemp(path.join(plan.location.dir, '.dsh-ops-settings-backup-')) + for (const file of plan.files) if (file.before !== undefined) await fs.writeFile(path.join(backup, path.basename(file.path)), file.before, { flag: 'wx', mode: 0o600 }) + await fs.writeFile(path.join(backup, 'recovery.json'), JSON.stringify({ files: plan.files.map(file => ({ name: path.basename(file.path), existed: file.before !== undefined })), order: 'Restore package.json before cordis.patch.yml while DSH is stopped.' }, null, 2), { flag: 'wx', mode: 0o600 }) + const written: typeof plan.files = [] + try { + for (const file of plan.files) { + if (await text(file.path) !== file.before) throw new Error('A profile file changed during migration.') + if (file.after === file.before) continue + written.push(file) + await host.atomic.writeFileAtomic(file.path, file.after, { mode: file.mode }) + } + const after = host.boot.composeEntries([host.boot.readProfilePatches('dsh', profileContext(plan.location))]) + if (!isDeepStrictEqual(after, plan.afterRows)) throw new Error('Saved profile composition did not match the reviewed plan.') + return backup + } catch (error) { + const failures: string[] = [] + for (const file of written.reverse()) try { + const current = await text(file.path) + if (current === file.before) continue + if (current !== file.after) throw new Error('A concurrent edit prevents automatic restoration.') + if (file.before === undefined) await fs.unlink(file.path) + else await host.atomic.writeFileAtomic(file.path, file.before, { mode: file.mode }) + } catch { failures.push(path.basename(file.path)) } + throw new Error(`Settings migration failed: ${error instanceof Error ? error.message : String(error)} Backup: ${backup}.${failures.length ? ` Restore manually: ${failures.join(', ')}.` : ' Previous profile restored.'}`) + } + }) +} +export async function carrierPreview(host: CarrierHost, location: CarrierLocation): Promise { + return host.atomic.withFileLock(path.join(location.dir, 'package.json'), async () => prepareCarrier(host, location)) +} diff --git a/test/build.test.mjs b/test/build.test.mjs index 05c73124..8e357892 100644 --- a/test/build.test.mjs +++ b/test/build.test.mjs @@ -17,8 +17,11 @@ function fixture(root) { fs.copyFileSync(path.join(PACKAGE_ROOT, 'scripts/build.mjs'), path.join(root, 'scripts/build.mjs')) fs.copyFileSync(path.join(PACKAGE_ROOT, 'tsconfig.json'), path.join(root, 'tsconfig.json')) write(root, 'scripts/lib/build/client.mjs', fs.readFileSync(path.join(PACKAGE_ROOT, 'scripts/lib/build/client.mjs'))) + write(root, 'scripts/lib/build/client-profile.mjs', fs.readFileSync(path.join(PACKAGE_ROOT, 'scripts/lib/build/client-profile.mjs'))) + write(root, 'cordis.patch.yml', fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'))) + write(root, 'src/profiles/client/package.json', fs.readFileSync(path.join(PACKAGE_ROOT, 'src/profiles/client/package.json'))) // Copies also work in Windows sandboxes where directory junctions are unavailable. - for (const name of ['typescript', '@types/node', 'undici-types']) { + for (const name of ['typescript', '@types/node', 'undici-types', 'yaml']) { fs.cpSync(path.join(PACKAGE_ROOT, 'node_modules', name), path.join(root, 'node_modules', name), { recursive: true }) } diff --git a/test/settings-carrier.test.mjs b/test/settings-carrier.test.mjs new file mode 100644 index 00000000..03ccaa0c --- /dev/null +++ b/test/settings-carrier.test.mjs @@ -0,0 +1,183 @@ +/** Real persistent composition, atomic writer and browser module activation. */ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import * as boot from '@deepseek-ai/dsh-app-boot' +import * as yaml from 'yaml' +import * as atomic from '@deepseek-ai/dsh-atomic-write' +import { ClientModuleRegistry } from '@deepseek-ai/dsh-client-modules' +import { carrierPreview, applyCarrier } from '../lib/settings/carrier-write.js' +import { CLIENT_BUNDLE, profileContext } from '../lib/settings/carrier-plan.js' +import { componentProfile } from './lib/component-profile.mjs' +import { shutdown } from './lib/host.mjs' +import { assert, test, report, PACKAGE_ROOT, waitFor } from './lib/harness.mjs' +const host = { boot, yaml, atomic } +const bundle = fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'), 'utf8') +const location = fixture => ({ dir: fixture.profile.dir, home: fixture.profile.home, installAnchor: fixture.profile.installAnchor }) +const manifest = fixture => path.join(fixture.profile.dir, 'package.json') +const rows = fixture => boot.composeEntries([boot.readProfilePatches('dsh', profileContext(location(fixture)))]) +const backups = fixture => fs.readdirSync(fixture.profile.dir).filter(name => name.startsWith('.dsh-ops-settings-backup-')) +const graph = ctx => ctx.get('clientModules').graph().entries.map(row => row.id) +const source = `# Preserve operator comments and expression semantics. +- id: dsh-ops-file + name: dsh-ops/file + disabled: false + config: + extraGuidance: keep operator text + configUpdateVersion: 2 +- id: dsh-ops-shell + name: dsh-ops/shell + disabled: !!js process.platform !== 'win32' +- id: dsh-ops-panel + name: dsh-ops/panel + disabled: true + config: + capture: false +- insert: + - id: theme-sentinel + name: cordis:fixtureTheme + config: + preference: dark +` +await test('default subpath preserves profiles; optional carrier survives reload and actual client activation', async () => { + const fixture = componentProfile('carrier-composition', bundle) + fs.writeFileSync(fixture.profile.patchPath, source) + const metadata = JSON.parse(fs.readFileSync(manifest(fixture), 'utf8')) + metadata.description = 'preserved profile metadata'; metadata.dependencies = { 'dsh-ops': '0.3.1' } + fs.writeFileSync(manifest(fixture), JSON.stringify(metadata)) + const originalManifest = fs.readFileSync(manifest(fixture), 'utf8'), before = rows(fixture) + let runtime = await fixture.boot() + try { + await runtime.ctx.plugin(ClientModuleRegistry) + assert.deepEqual(graph(runtime.ctx), [], 'legacy subpath cannot advertise browser pages') + await shutdown(runtime.ctx) + const preview = await carrierPreview(host, location(fixture)) + assert.equal(preview.changedQualifiers, 1) + assert.equal(fs.readFileSync(manifest(fixture), 'utf8'), originalManifest) + assert.equal(fs.readFileSync(fixture.profile.patchPath, 'utf8'), source) + assert.deepEqual(backups(fixture), []) + // A stop between these two writes remains a valid legacy profile. + fs.writeFileSync(fixture.profile.patchPath, preview.files[0].after) + assert.deepEqual(rows(fixture), before) + fs.writeFileSync(fixture.profile.patchPath, source) + const backup = await applyCarrier(host, preview) + assert.equal(fs.readFileSync(path.join(backup, 'package.json'), 'utf8'), originalManifest) + assert.equal(fs.readFileSync(path.join(backup, 'cordis.patch.yml'), 'utf8'), source) + assert.match(fs.readFileSync(fixture.profile.patchPath, 'utf8'), /# Preserve operator comments/) + assert.match(fs.readFileSync(fixture.profile.patchPath, 'utf8'), /!!js/) + assert.deepEqual(rows(fixture), preview.afterRows) + const resolution = await boot.createRuntimeResolution({ installAnchor: fixture.profile.installAnchor, home: fixture.profile.home, + profile: boot.loadProfileDirectory('dsh', fixture.profile.dir, fixture.profile.installAnchor) }) + assert.ok(resolution.localPackageNames.includes('dsh-ops'), 'the real runtime resolver accepts the nested bundle') + assert.equal(rows(fixture).length, 8, 'seven OPS rows plus unchanged foreign row') + assert.equal(JSON.parse(fs.readFileSync(manifest(fixture), 'utf8')).description, metadata.description) + assert.deepEqual(JSON.parse(fs.readFileSync(manifest(fixture), 'utf8')).dsh.profile.bundles, [CLIENT_BUNDLE]) + for (let restart = 0; restart < 2; restart++) { + runtime = await fixture.boot() + await runtime.ctx.plugin(ClientModuleRegistry) + assert.deepEqual(graph(runtime.ctx), ['dsh-ops']) + assert.equal(runtime.entries().length, 7) + assert.equal(runtime.entries().find(entry => entry.options.id === 'dsh-ops-file').options.config.extraGuidance, 'keep operator text') + const entry = runtime.entries().find(entry => entry.options.id === 'dsh-ops-file') + await entry.update({ disabled: true }); await runtime.ctx.loader.await() + await waitFor(() => graph(runtime.ctx).length === 0) + await entry.update({ disabled: false }); await runtime.ctx.loader.await() + await waitFor(() => graph(runtime.ctx).includes('dsh-ops')) + await shutdown(runtime.ctx) + } + await assert.rejects(carrierPreview(host, location(fixture)), /one-time migration/) + } finally { await shutdown(runtime.ctx) } +}) +await test('an explicitly disabled file carrier remains off until the builtin row switch enables it', async () => { + const fixture = componentProfile('carrier-disabled', bundle, [{ id: 'dsh-ops-file', name: 'dsh-ops/file', disabled: true, config: { configUpdateVersion: 2 } }]) + const plan = await carrierPreview(host, location(fixture)) + await applyCarrier(host, plan) + const runtime = await fixture.boot() + try { + await runtime.ctx.plugin(ClientModuleRegistry) + assert.deepEqual(graph(runtime.ctx), []) + const entry = runtime.entries().find(entry => entry.options.id === 'dsh-ops-file') + assert.equal(entry.disabled, true) + await entry.update({ disabled: false }); await runtime.ctx.loader.await() + await waitFor(() => graph(runtime.ctx).includes('dsh-ops')) + } finally { await shutdown(runtime.ctx) } +}) +await test('stale preview, duplicate OPS rows and higher-priority named overrides refuse without writes', async () => { + const fixture = componentProfile('carrier-stale', bundle) + const plan = await carrierPreview(host, location(fixture)) + fs.appendFileSync(fixture.profile.patchPath, '\n# concurrent edit\n') + await assert.rejects(applyCarrier(host, plan), /changed after the preview/) + assert.deepEqual(backups(fixture), []) + const duplicate = componentProfile('carrier-duplicate', bundle, [{ insert: [{ id: 'dsh-ops-file', name: 'dsh-ops/file' }] }]) + await assert.rejects(carrierPreview(host, location(duplicate)), /seven unique/) + assert.deepEqual(backups(duplicate), []) + const shadow = componentProfile('carrier-home-shadow', bundle) + fs.writeFileSync(path.join(shadow.profile.home, 'cordis.patch.yml'), '- id: dsh-ops-file\n name: dsh-ops/file\n disabled: true\n') + await assert.rejects(carrierPreview(host, location(shadow)), /External name-qualified/) + assert.deepEqual(backups(shadow), []) + // Matching current defaults is still a meaningful preference for later upgrades. + const inheritedConfig = yaml.parse(bundle)[0].insert.find(row => row.id === 'dsh-ops-file').config + fs.writeFileSync(path.join(shadow.profile.home, 'cordis.patch.yml'), JSON.stringify([{ id: 'dsh-ops-file', name: 'dsh-ops/file', config: inheritedConfig }])) + await assert.rejects(carrierPreview(host, location(shadow)), /External name-qualified/) + assert.deepEqual(backups(shadow), []) +}) +await test('missing optional patches remain absent and host-invalid empty patches refuse unchanged', async () => { + for (const content of [undefined, '', '# only a comment\n']) { + const fixture = componentProfile(`carrier-empty-${String(content?.length)}`, bundle) + if (content === undefined) fs.unlinkSync(fixture.profile.patchPath) + else { + fs.writeFileSync(fixture.profile.patchPath, content) + await assert.rejects(carrierPreview(host, location(fixture)), /top-level YAML array/) + assert.equal(fs.readFileSync(fixture.profile.patchPath, 'utf8'), content) + assert.deepEqual(backups(fixture), []) + continue + } + const plan = await carrierPreview(host, location(fixture)) + assert.equal(plan.changedQualifiers, 0) + await applyCarrier(host, plan) + if (content === undefined) assert.equal(fs.existsSync(fixture.profile.patchPath), false) + else assert.equal(fs.readFileSync(fixture.profile.patchPath, 'utf8'), content) + assert.deepEqual(rows(fixture), plan.afterRows) + } +}) +await test('atomic failure restores originals and retains a bounded recovery backup', async () => { + for (const committedBeforeFailure of [false, true]) { + const fixture = componentProfile(`carrier-rollback-${committedBeforeFailure}`, bundle) + fs.writeFileSync(fixture.profile.patchPath, source) + const plan = await carrierPreview(host, location(fixture)) + let calls = 0 + const faulty = { ...host, atomic: { ...atomic, async writeFileAtomic(file, text, options) { + calls++ + if (calls === 2) { + if (committedBeforeFailure) await atomic.writeFileAtomic(file, text, options) + throw new Error('injected second-write failure') + } + return atomic.writeFileAtomic(file, text, options) + } } } + await assert.rejects(applyCarrier(faulty, plan), /Previous profile restored/) + for (const file of plan.files) assert.equal(fs.readFileSync(file.path, 'utf8'), file.before) + assert.deepEqual(rows(fixture), plan.beforeRows) + assert.equal(backups(fixture).length, 1) + // Prove the host profile lock was released and this is recoverable. + await applyCarrier(host, await carrierPreview(host, location(fixture))) + assert.deepEqual(rows(fixture), plan.afterRows) + } +}) +await test('CLI preview requires an explicit stopped-profile assertion before applying', async () => { + const fixture = componentProfile('carrier-cli', bundle) + fs.symlinkSync(path.join(PACKAGE_ROOT, 'node_modules'), path.join(path.dirname(fixture.profile.installAnchor), 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir') + const args = [path.join(PACKAGE_ROOT, 'bin/dsh-ops.mjs'), 'settings-profile', '--profile-dir', fixture.profile.dir, + '--host-anchor', fixture.profile.installAnchor, '--dsh-home', fixture.profile.home] + const run = (...extra) => spawnSync(process.execPath, [...args, ...extra], { encoding: 'utf8', env: process.env }) + const preview = run() + assert.equal(preview.status, 0, preview.stderr); assert.match(preview.stdout, /Preview only/) + assert.deepEqual(backups(fixture), []) + const refused = run('--yes') + assert.notEqual(refused.status, 0); assert.match(refused.stderr, /--offline is required/) + assert.deepEqual(backups(fixture), []) + const applied = run('--yes', '--offline') + assert.equal(applied.status, 0, applied.stderr); assert.match(applied.stdout, /Saved and verified/) + assert.equal(backups(fixture).length, 1) + assert.equal(rows(fixture).find(row => row.id === 'dsh-ops-file').name, 'dsh-ops') +}) +report('settings-carrier') From 211834e2fab481d587df2e18b0e21375339f6419 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:28:41 +0800 Subject: [PATCH 079/118] refactor(types): type the component host entry --- docs/CHANGELOG.md | 5 ++ docs/CONTRIBUTING.md | 2 + ...41\347\214\256\346\214\207\345\215\227.md" | 2 + src/{index.js => index.ts} | 51 ++++++++++++------- test/types/host-entry.ts | 14 +++++ test/types/tsconfig.json | 3 +- 6 files changed, 57 insertions(+), 20 deletions(-) rename src/{index.js => index.ts} (72%) create mode 100644 test/types/host-entry.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 247d8f31..30328041 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,11 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Type the component host entry (#261, 16/16) + +- Migrate the root component entry to strict TypeScript using public Cordis and Agent types; preserve configuration validation, scoped registration, shell denial and teardown behavior. +- 将根组件入口迁移为严格 TypeScript,使用公开 Cordis 与 Agent 类型;保留配置验证、作用域注册、Shell 拒绝与卸载行为。 + ### Type profile delegation and session temporary directories (#261, 16/16) - Migrate profile commands and private temporary-directory helpers to strict TypeScript, preserving official CLI delegation and per-session path behavior; narrow malformed manifest data before use. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 1d311cf4..26177153 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -196,3 +196,5 @@ Native build and acceptance details: [native-ptc.md](native-ptc.md) and The credential fence, trusted path anchors and permission helpers are strict TypeScript modules (`src/credential-policy.ts`, `src/authority-paths.ts`, `src/permissions.ts`). Their emitted `.js` imports and wire failures stay stable. Authority policies use type-only host definitions; unknown worker failures must be narrowed before use. `node test/run.mjs kernel-types authority credential-fence credential-denial safety-atomicity permissions` covers the typed boundary and existing authority behavior. `src/profile-install.ts` and `src/session-temp.ts` are strict TypeScript helpers with unchanged emitted paths. Profile mutations still delegate to the official CLI; manifest data is narrowed before reading it. Private temporary directories retain their session-derived names and refusal of link/file targets. `node test/run.mjs profile-temp kernel-types` checks delegation, dry-run, failures and typed outcomes. + +`src/index.ts` types the component entry against the public Cordis `Context` and `Agent` contracts. Raw configuration remains `unknown` until validation; callbacks and tool-denial decisions retain their public host signatures. Runtime imports remain `.js`, and host dependencies remain type-only at module load. `node test/run.mjs kernel-types mount bundle-startup progressive-loop` checks the type boundary, scoped startup/disposal and next-step publication. diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 8bd17014..8630cc0d 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -130,3 +130,5 @@ npm run build:bash-ipc # 仅 Windows:Bash IPC 凭据围栏、可信路径锚点和权限辅助函数已迁移为严格 TypeScript 模块(`src/credential-policy.ts`、`src/authority-paths.ts`、`src/permissions.ts`),生成的 `.js` 导入路径及线上错误结构保持不变。权限策略仅以类型方式引用宿主定义;未知 worker 错误必须先缩窄类型才能使用。运行 `node test/run.mjs kernel-types authority credential-fence credential-denial safety-atomicity permissions` 验证类型边界与既有权限行为。 `src/profile-install.ts` 与 `src/session-temp.ts` 已迁移为严格 TypeScript,生成路径不变。配置档修改仍委托官方 CLI,manifest 数据先缩窄再读取;临时目录保持按会话派生命名并拒绝链接/文件目标。运行 `node test/run.mjs profile-temp kernel-types` 验证委托、预览、失败及类型化结果。 + +`src/index.ts` 使用公开 Cordis `Context` 与 `Agent` 契约约束组件入口。原始配置在验证前保持 `unknown`,回调与工具拒绝决策遵循宿主公开签名;运行时导入路径仍为 `.js`,模块加载时仅以类型方式引用宿主依赖。运行 `node test/run.mjs kernel-types mount bundle-startup progressive-loop` 验证类型边界、作用域启动/卸载及下一步发布。 diff --git a/src/index.js b/src/index.ts similarity index 72% rename from src/index.js rename to src/index.ts index 4802a6f5..7d8d40b4 100644 --- a/src/index.js +++ b/src/index.ts @@ -1,4 +1,8 @@ /** dsh-ops host half. Own registrations only; no @deepseek-ai value imports. */ +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-system-prompt' import { resolveConfig } from './config.js' import { mountConfigUpdates, updatedConfig } from './config-updates.js' import { SECTION_ORDERS, TOOLING_SECTION, BASH_TOOL, publicToolName, hostShellRefusal, renderToolingPolicy } from './policy.js' @@ -12,13 +16,19 @@ import { safeDispose, safeDisposeAll } from './teardown.js' import { mountResultLifecycle } from './kernel/result-lifecycle.js' export const name = 'dsh-ops' -function report(ctx, message, level = 'error') { +type ResolvedConfig = ReturnType +type Component = 'shell' | 'file' +function errorText(error: unknown): string { + return String(error !== null && typeof error === 'object' && 'message' in error ? error.message ?? error : error) +} +function report(ctx: Context, message: string, level = 'error'): void { const logger = ctx?.logger - if (typeof logger?.[level] === 'function') logger[level](message) + const sink: unknown = logger ? Reflect.get(logger, level) : undefined + if (typeof sink === 'function') sink.call(logger, message) else if (level === 'warn') console.warn(message) else console.error(message) } -function visibleTools(ctx, agent) { +function visibleTools(ctx: Context, agent?: Agent): string[] { try { return (ctx.get('tools')?.schemas(agent) ?? []).map(schema => schema.name) .filter(tool => typeof tool === 'string') @@ -26,50 +36,53 @@ function visibleTools(ctx, agent) { } /** Explicit host-shell denial is independent of whether ops commands exist. */ -function installHostShellDenial(ctx, config) { +function installHostShellDenial(ctx: Context, config: ResolvedConfig): void { const denied = new Set(config.deniedHostTools) - const lifts = new Map() + const lifts = new Map void>() let warned = false - const hide = agent => { + const hide = (agent: Agent) => { if (lifts.has(agent) || !agent?.ctx) return try { - const names = (ctx.get('tools').schemas(agent) ?? []).map(tool => tool.name).filter(name => denied.has(name)) + const names = (ctx.tools.schemas(agent) ?? []).map(tool => tool.name).filter(name => denied.has(name)) if (names.length) lifts.set(agent, agent.ctx.tools.restrict({ deny: names })) } catch (error) { if (!warned) { warned = true - report(ctx, `dsh-ops: host shell visibility restriction unavailable: ${error.message}; calls remain denied.`, 'warn') + report(ctx, `dsh-ops: host shell visibility restriction unavailable: ${errorText(error)}; calls remain denied.`, 'warn') } } } - ctx.on('agent/created', ({ agent }) => hide(agent)) + ctx.on('agent/created', ({ agent }) => { hide(agent) }) // Same isolation as the bulk teardown below: a throwing lift must not stop `lifts.delete`, nor // leave this agent's shell visibility permanently restricted. ctx.on('agent/disposed', ({ agent }) => { - safeDispose(lifts.get(agent), (error) => report(ctx, `dsh-ops: host shell restriction lift failed: ${error?.message ?? error}`, 'warn'), 'host shell lift (agent disposed)') + safeDispose(lifts.get(agent), (error) => report(ctx, `dsh-ops: host shell restriction lift failed: ${errorText(error)}`, 'warn'), 'host shell lift (agent disposed)') lifts.delete(agent) }) for (const agent of ctx.get('agents')?.list?.() ?? []) hide(agent) ctx.effect(() => () => { // Isolate each lift: these restore the host's shell visibility, so one throwing callback // must not leave the remaining agents' tools hidden (nor skip the map cleanup). - safeDisposeAll(lifts.values(), (error) => report(ctx, `dsh-ops: host shell restriction lift failed: ${error?.message ?? error}`, 'warn'), 'host shell lift') + safeDisposeAll(lifts.values(), (error) => report(ctx, `dsh-ops: host shell restriction lift failed: ${errorText(error)}`, 'warn'), 'host shell lift') lifts.clear() }, 'dsh-ops: host shell restrictions') - ctx.on('tools/pre-execute', (exec, next) => hostShellRefusal({ toolName: String(exec?.name ?? ''), denied }) ?? next()) + ctx.on('tools/pre-execute', async (exec, next) => { + const refusal = hostShellRefusal({ toolName: String(exec?.name ?? ''), denied }) + return refusal ? { kind: 'deny', reason: refusal.reason } : next() + }) if (typeof ctx.tools.guard === 'function') { ctx.effect(() => ctx.tools.guard(exec => hostShellRefusal({ toolName: String(exec?.name ?? ''), denied })?.reason), 'dsh-ops: host shell guard') } } -async function mountNativeFile(ctx, config, file) { +async function mountNativeFile(ctx: Context, config: ResolvedConfig, file: string | undefined): Promise { mountResultLifecycle(ctx) const surface = new NativeFileTools({ ctx, config: { ...config, workerPath: file }, report: (message, level) => report(ctx, message, level) }) // `safeDispose` isolates synchronous teardown callbacks; a *rejecting promise* needs its own // catch, or the rejection is dropped and the worker's shutdown failure stays invisible (#30). ctx.effect(() => () => { - return surface.stop().catch(error => report(ctx, `dsh-ops: native file worker shutdown failed: ${error?.message ?? error}`, 'warn')) + return surface.stop().catch(error => report(ctx, `dsh-ops: native file worker shutdown failed: ${errorText(error)}`, 'warn')) }, 'dsh-ops: native file worker') const tools = await surface.start(NATIVE_RESIDENT_FILE_TOOLS) if (tools.length) report(ctx, `dsh-ops: native Rust file worker ready; ${tools.length} tools.`, 'info') @@ -78,7 +91,7 @@ async function mountNativeFile(ctx, config, file) { /** The components this host half mounts; `dsh-ops/background` is a separate no-op stub. */ const COMPONENTS = Object.freeze(['shell', 'file']) -export function applyComponent(root, rawConfig, component) { +export function applyComponent(root: Context, rawConfig: unknown, component: Component): void { if (!COMPONENTS.includes(component)) throw new Error(`dsh-ops: unknown component ${JSON.stringify(component)}`) // Validate before migrating: malformed/unknown keys still fail loud. resolveConfig(rawConfig) @@ -87,8 +100,8 @@ export function applyComponent(root, rawConfig, component) { const nativeWorker = component === 'file' ? (() => { try { return resolveWorker(config) } catch (error) { - report(root, `dsh-ops: native file worker unavailable: ${error.message}`) - if (config.required) throw new Error(`Native file worker unavailable for binaryPath ${config.binaryPath ?? '(automatic resolution)'}: ${error.message}`) + report(root, `dsh-ops: native file worker unavailable: ${errorText(error)}`) + if (config.required) throw new Error(`Native file worker unavailable for binaryPath ${config.binaryPath ?? '(automatic resolution)'}: ${errorText(error)}`) return undefined } })() : undefined @@ -110,7 +123,7 @@ export function applyComponent(root, rawConfig, component) { // Workspace preparation is Windows-specific; the host owns its PowerShell executor. if (process.platform === 'win32') mountWorkspacePreparation(root) root.inject(['tools'], ctx => { - mountSessionBash(ctx, config, shells) + if (shells) mountSessionBash(ctx, config, shells) if (config.shellPolicy === 'deny-host-shell') installHostShellDenial(ctx, config) }) } else { @@ -119,4 +132,4 @@ export function applyComponent(root, rawConfig, component) { } // Backward-compatible module entry. The bundle uses independently toggled rows. -export function apply(root, rawConfig) { return applyComponent(root, rawConfig, 'file') } +export function apply(root: Context, rawConfig: unknown): void { return applyComponent(root, rawConfig, 'file') } diff --git a/test/types/host-entry.ts b/test/types/host-entry.ts new file mode 100644 index 00000000..540de63d --- /dev/null +++ b/test/types/host-entry.ts @@ -0,0 +1,14 @@ +import type { Context } from '@deepseek-ai/cordis' +import { apply, applyComponent } from '../../src/index.js' + +declare const ctx: Context +declare const raw: unknown +apply(ctx, raw) +applyComponent(ctx, raw, 'shell') +applyComponent(ctx, raw, 'file') +// @ts-expect-error The component entry requires a real host Context. +apply({}, raw) +// @ts-expect-error This entry owns only the shell and Native file components. +applyComponent(ctx, raw, 'ptc') +// @ts-expect-error Component selection must be explicit. +applyComponent(ctx, raw) diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json index a9456f37..f665d0db 100644 --- a/test/types/tsconfig.json +++ b/test/types/tsconfig.json @@ -10,6 +10,7 @@ "worker-runtime.ts", "tui.ts", "authority.ts", - "profile-temp.ts" + "profile-temp.ts", + "host-entry.ts" ] } From ff19e32c10a94ff997447244fa993889878a9d71 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:12:28 +0800 Subject: [PATCH 080/118] feat(documents): read bounded typed CSV and TSV rows --- docs/CHANGELOG.md | 9 + docs/native-kernel.md | 45 +++++ ...37\347\224\237\345\206\205\346\240\270.md" | 36 ++++ native/Cargo.lock | 12 ++ native/Cargo.toml | 1 + native/crates/ops-documents/Cargo.toml | 14 ++ native/crates/ops-documents/src/decode.rs | 84 ++++++++ native/crates/ops-documents/src/lib.rs | 129 ++++++++++++ native/crates/ops-documents/src/parse.rs | 42 ++++ native/crates/ops-documents/src/tests.rs | 52 +++++ native/crates/ops-results/src/spill.rs | 2 +- native/src/lib.rs | 1 + native/src/main.rs | 2 +- test/package-source.test.mjs | 5 +- test/table-worker.test.mjs | 183 ++++++++++++++++++ 15 files changed, 614 insertions(+), 3 deletions(-) create mode 100644 native/crates/ops-documents/Cargo.toml create mode 100644 native/crates/ops-documents/src/decode.rs create mode 100644 native/crates/ops-documents/src/lib.rs create mode 100644 native/crates/ops-documents/src/parse.rs create mode 100644 native/crates/ops-documents/src/tests.rs create mode 100644 test/table-worker.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5fdef019..27227a1e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,15 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Add bounded CSV/TSV worker observations (#261, 15a/16) + +- Add an ops-documents workspace crate with strict UTF-8/UTF-16 CSV/TSV decoding, positional + string cells, physical line ranges, bounded row pages and safe preceding rows on parse failures. +- Separate complete physical scans from requested pages and reference capture; reuse no-follow, + credential, readonly and automatic-spill boundaries without new external dependencies. +- 新增有界 CSV/TSV worker 读取,严格解码、保留原字符串与物理行范围;错误保留此前有效行, + 并分别报告扫描、分页和引用捕获完整性,不新增外部依赖。发现/宿主注册由后续切片接入。 + - Add optional bilingual browser settings pages for replacement selection, discovery pools and explicit Bash/host PowerShell runtime plans. Preserve the default legacy file row; custom pages require a client carrier and unload when it is disabled. - 新增可选双语浏览器设置页面,支持逐工具替换、发现池及显式 Bash/宿主 PowerShell 运行时计划。默认保留旧文件行;自定义页面需要客户端承载行,关闭承载行即卸载页面。 - Add an opt-in offline settings-carrier migration with exact host composition checks, original-file backups, atomic writes and recovery. The normal upgrade retains legacy file-row names and settings. diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 91c9e419..4bb8b670 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -12,6 +12,7 @@ and writes one JSON result. Splitting the kernel does not introduce a persistent | `ops-files` | No-follow handles, reads, CAS publication, metadata, tree operations, directory sync | protocol | | `ops-search` | Glob, grep and replacement orchestration over guarded file primitives | protocol, files | | `ops-results` | Bounded generation store, result pages and immutable job-audit access | protocol, files | +| `ops-documents` | Strict bounded CSV/TSV Unicode decoding and typed positional rows | protocol, files | | `ops-batch` | Plans, write-ahead transitions and conservative recovery | protocol, files, results | The root crate routes requests and opens audit storage only for batch/result capabilities. @@ -127,3 +128,47 @@ cannot create a reference. Cache refusal retains an incomplete bounded head with completed mutations must not be repeated. Reference metadata is budget-checked before publication, so an unusably small head budget does not create an unreachable blob. Calls without trusted storage retain the legacy transport contract; the host supplies recovery guidance when storage is unavailable. + +## CSV/TSV worker observations (15a/16) + +The new ops-documents crate handles `table_read` in the same one-shot worker. This slice exposes +only the worker capability; discovery/host registration is separate. It adds no external packages: +serde/serde_json and sha2 reuse the existing locked workspace dependency graph. PDF/DOC parsing +remains separate work requiring its own dependency review. + +Arguments are `{ path, format?, encoding?, header?, offset?, limit? }`. The host resolves path +against current cwd before sending an absolute path. Format is csv or tsv, inferred as tsv only +for a .tsv extension (case-insensitive), otherwise csv. Encoding defaults to utf-8; utf-16le and +utf-16be are explicit alternatives. Valid matching BOMs are consumed, contradictory BOMs and +malformed Unicode are rejected without replacement decoding. Without a BOM the explicit encoding +is authoritative; no unreliable byte-pattern guessing is performed. + +Header defaults true. Duplicate/empty column names remain positional; header=false returns an +empty columns array and infers columnCount from the first record. Data rows are +`{ row, cells: string[], lineStart, lineEnd }`: row is 1-based excluding the header, while lineStart +and lineEnd count physical LF lines including the header and quoted multiline fields. Values are +never guessed as numbers, dates or booleans. Quoted fields support delimiters, escaped double quotes +and exact embedded LF/CRLF. Record separators are LF/CRLF; bare CR outside quotes, trailing text +after a closing quote, unquoted quotes and inconsistent widths are errors. Blank records contain +one empty cell; a final newline adds no phantom record. Empty files have zero rows. + +Offset is 1..100000 data rows, default 1; limit is 1..1000, default 200. Each call streams a bounded +physical scan, retains only its requested rows, and reports rowsObserved, bytesRead (including +bounded decoder read-ahead), scanComplete and pageComplete separately. rowCount/total and the +raw-byte SHA-256 revision appear only after a clean EOF with unchanged observed file metadata. +Complete also requires no remaining rows after the requested page. A malformed suffix preserves +preceding valid rows with status=partial/error and no invented total, even when the requested page +was already fully observed. Metadata changes invalidate the complete-scan claim. + +Hard limits are 32 MiB input, 100000 data records plus one bounded EOF lookahead, 1 MiB decoded +record, 1024 columns, 8192 serialized header bytes and 30 seconds between records (the host/worker +request deadline also applies). A stopped scan returns explicit scan-budget/time recovery; row +paging does not bypass these physical limits. Use a dedicated streaming table reader for larger +inputs. Error codes include TABLE_PARSE_ERROR, ERR_INVALID_ENCODING, OUTPUT_LIMIT, FILE_CHANGED +and DEADLINE_EXCEEDED; preflight authority failures keep the existing policy codes. + +Credential and no-follow checks reuse ops-files. Readonly observations create no audit/cache +objects. Oversized requested rows use the existing bounded spill sink before display clipping: +references contain exact typed row JSON, source retains table/coverage facts, and captureComplete +is independent of scanComplete. Page with the original referenceRoot. Cache effects never imply +business writes; table_read is classified as read-only even in a writable session. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index a0d8e41f..c0dd4b09 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -11,6 +11,7 @@ English: [native-kernel.md](native-kernel.md). | `ops-files` | 禁止跟随链接的句柄、读取、CAS 发布、元数据、树操作、目录同步 | protocol | | `ops-search` | 基于受保护文件原语的 glob、grep 和替换编排 | protocol、files | | `ops-results` | 有界的代际存储、结果分页和只读作业审计访问 | protocol、files | +| `ops-documents` | 严格有界 CSV/TSV Unicode 解码与保留位置的类型化行 | protocol、files | | `ops-batch` | 计划、预写状态转移和保守恢复 | protocol、files、results | 根 crate 负责分派请求,仅为 batch/result 能力打开审计存储。作业归属校验与协议记录放在一起, @@ -103,3 +104,38 @@ complete=true 后 JSON.parse;须验证连续索引、偏移及结束标记。S 不完整的有界开头,并提示范围/十六进制恢复;已完成的修改不能重跑。发布前先验证引用元数据 能否放进头部预算,避免因预算过小而留下无法取得的快照。没有可信存储时保留旧传输契约, 由宿主在存储不可用时补充恢复指导。 + +## CSV/TSV worker 观察(15a/16) + +新增 ops-documents crate,在同一个单次 worker 内处理 table_read。本切片只增加 worker 能力, +宿主注册及发现入口另行接入。没有新增外部包:serde/serde_json 与 sha2 复用既有锁定依赖。 +PDF/DOC 解析仍是需要独立依赖审查的后续工作。 + +参数为 `{ path, format?, encoding?, header?, offset?, limit? }`。宿主先相对于当前 cwd 解析 +path,再发送绝对路径。format 为 csv/tsv;仅 .tsv 扩展名(忽略大小写)推断为 tsv,否则为 csv。 +encoding 默认 utf-8,也可明确指定 utf-16le/utf-16be。匹配的 BOM 被消费,矛盾 BOM 和非法 +Unicode 被拒绝,绝不使用替代字符解码。无 BOM 时以明确指定的编码为准,不做不可靠的字节猜测。 + +header 默认 true;重复或空列名按原位置保留。header=false 返回空 columns 数组,并从首条记录 +确定 columnCount。数据行为 `{ row, cells: string[], lineStart, lineEnd }`:row 从 1 开始且 +不含标题;lineStart/lineEnd 按实际 LF 计数,包含标题及引号内的多行。单元格始终保留字符串, +不猜测数字、日期或布尔类型。引号字段支持分隔符、双写引号和原样嵌入的 LF/CRLF。记录分隔符 +为 LF/CRLF;引号外单独 CR、闭合引号后的多余文本、未加引号字段中的引号以及列数不一致均报错。 +空记录含一个空单元格;末尾换行不产生多余记录,空文件为零行。 + +offset 为不含标题的 1..100000 数据行,默认 1;limit 为 1..1000,默认 200。每次调用流式执行 +有界物理扫描,只保留所请求的行,并分别报告 rowsObserved、bytesRead(含有界预读)、 +scanComplete 和 pageComplete。只有正常 EOF 且文件元数据观察未改变时,才返回 rowCount/total +和原始字节 SHA-256 revision。complete 还要求所请求页之后没有剩余行。即使目标页已观察完整, +非法后缀仍保留此前有效行、返回 status=partial/error,且不编造总数。元数据变化使完整扫描声明失效。 + +硬上限:32 MiB 输入、100000 条数据记录(另加一次有界 EOF 探测)、1 MiB 解码后记录、1024 列、 +8192 字节序列化标题,以及记录间检查的 30 秒截止时间;宿主/worker 请求截止时间仍同时适用。 +提前停止时明确返回 scan-budget/time 和恢复指导;行分页不能绕过这些物理扫描限制,更大的输入 +应使用专门的流式表格读取器。错误包括 TABLE_PARSE_ERROR、ERR_INVALID_ENCODING、OUTPUT_LIMIT、 +FILE_CHANGED、DEADLINE_EXCEEDED;预检权限拒绝沿用原有策略错误码。 + +凭据和禁止跟随链接检查复用 ops-files。只读观察不创建审计或缓存对象。请求行过大时在显示 +裁切前使用既有有界捕获器:引用保留可精确重建的类型化行 JSON,source 保留表格/覆盖事实, +captureComplete 独立于 scanComplete。分页须使用原 referenceRoot。缓存副作用不代表业务写入; +即使会话可写,table_read 也按只读操作分类。 diff --git a/native/Cargo.lock b/native/Cargo.lock index bf053112..d5af1de9 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -128,6 +128,7 @@ dependencies = [ "cap-fs-ext", "cap-std", "ops-batch", + "ops-documents", "ops-files", "ops-protocol", "ops-results", @@ -305,6 +306,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "ops-documents" +version = "0.3.1" +dependencies = [ + "ops-files", + "ops-protocol", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "ops-files" version = "0.3.1" diff --git a/native/Cargo.toml b/native/Cargo.toml index 48a3bf18..0a36acb7 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -18,6 +18,7 @@ cap-fs-ext = "3.4" serde_json = "1.0" ops-protocol = { path = "crates/ops-protocol" } ops-files = { path = "crates/ops-files" } +ops-documents = { path = "crates/ops-documents" } ops-search = { path = "crates/ops-search" } ops-results = { path = "crates/ops-results" } ops-batch = { path = "crates/ops-batch" } diff --git a/native/crates/ops-documents/Cargo.toml b/native/crates/ops-documents/Cargo.toml new file mode 100644 index 00000000..49ad6617 --- /dev/null +++ b/native/crates/ops-documents/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ops-documents" +version = "0.3.1" +edition = "2024" +license = "MIT" +rust-version = "1.88" +publish = false + +[dependencies] +ops-protocol = { path = "../ops-protocol" } +ops-files = { path = "../ops-files" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10.9" diff --git a/native/crates/ops-documents/src/decode.rs b/native/crates/ops-documents/src/decode.rs new file mode 100644 index 00000000..2896d121 --- /dev/null +++ b/native/crates/ops-documents/src/decode.rs @@ -0,0 +1,84 @@ +//! Strict streaming Unicode decoding. Invalid bytes/surrogates are never replaced. +use ops_protocol::{Failure, Result}; +use sha2::{Digest, Sha256}; +use std::io::{BufReader, Read}; +pub const MAX_BYTES: usize = 32 * 1024 * 1024; +#[derive(Clone, Copy)] +pub enum Encoding { Utf8, Utf16Le, Utf16Be } +impl Encoding { + pub fn parse(name: &str) -> Result { + match name.to_ascii_lowercase().as_str() { + "utf-8" => Ok(Self::Utf8), "utf-16le" => Ok(Self::Utf16Le), "utf-16be" => Ok(Self::Utf16Be), + _ => Err(invalid("Supported table encodings: utf-8, utf-16le, utf-16be")), + } + } + pub fn name(self) -> &'static str { match self { Self::Utf8 => "utf-8", Self::Utf16Le => "utf-16le", Self::Utf16Be => "utf-16be" } } +} +fn invalid(message: &str) -> Failure { Failure::new("ERR_INVALID_ENCODING", message) } +struct Tracked { input: R, bytes: usize, hash: Sha256 } +impl Read for Tracked { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let room = buffer.len().min((MAX_BYTES + 1).saturating_sub(self.bytes)); + let count = self.input.read(&mut buffer[..room])?; + self.bytes += count; self.hash.update(&buffer[..count]); Ok(count) + } +} +pub struct Decoder { input: BufReader>, encoding: Encoding, first: bool, consumed: usize, prefix: [u8; 3], prefix_len: usize, prefix_at: usize, pub line: usize } +impl Decoder { + pub fn new(input: R, encoding: Encoding) -> Result { + let mut decoder = Self { input: BufReader::new(Tracked { input, bytes: 0, hash: Sha256::new() }), encoding, first: true, consumed: 0, prefix: [0; 3], prefix_len: 0, prefix_at: 0, line: 1 }; + while decoder.prefix_len < 3 { + let count = decoder.input.read(&mut decoder.prefix[decoder.prefix_len..])?; + if count == 0 { break; } + decoder.prefix_len += count; + } + let prefix = &decoder.prefix[..decoder.prefix_len]; + let mismatch = (prefix.starts_with(&[0xef, 0xbb, 0xbf]) && !matches!(encoding, Encoding::Utf8)) + || (prefix.starts_with(&[0xff, 0xfe]) && !matches!(encoding, Encoding::Utf16Le)) + || (prefix.starts_with(&[0xfe, 0xff]) && !matches!(encoding, Encoding::Utf16Be)); + if mismatch { return Err(invalid("Table BOM conflicts with the requested encoding")); } + Ok(decoder) + } + pub fn bytes_read(&self) -> usize { self.input.get_ref().bytes } + pub fn revision(&self) -> String { format!("{:x}", self.input.get_ref().hash.clone().finalize()) } + fn byte(&mut self) -> Result> { + let mut byte = [0]; + if self.prefix_at < self.prefix_len { byte[0] = self.prefix[self.prefix_at]; self.prefix_at += 1; } + else if self.input.read(&mut byte)? == 0 { return Ok(None); } + self.consumed += 1; + if self.consumed > MAX_BYTES { return Err(Failure::new("OUTPUT_LIMIT", "Table scan reached the 32 MiB byte limit")); } + Ok(Some(byte[0])) + } + fn required(&mut self) -> Result { self.byte()?.ok_or_else(|| invalid("Incomplete Unicode sequence at end of table")) } + fn unit(&mut self) -> Result> { + let Some(first) = self.byte()? else { return Ok(None); }; + let second = self.required()?; + Ok(Some(match self.encoding { Encoding::Utf16Le => u16::from_le_bytes([first, second]), _ => u16::from_be_bytes([first, second]) })) + } + pub fn next(&mut self) -> Result> { + let character = match self.encoding { + Encoding::Utf8 => { + let Some(first) = self.byte()? else { return Ok(None); }; + let width = match first { 0..=0x7f => 1, 0xc2..=0xdf => 2, 0xe0..=0xef => 3, 0xf0..=0xf4 => 4, _ => return Err(invalid("Invalid UTF-8 table bytes")) }; + let mut bytes = [0; 4]; bytes[0] = first; + for byte in bytes.iter_mut().take(width).skip(1) { *byte = self.required()?; } + std::str::from_utf8(&bytes[..width]).map_err(|_| invalid("Invalid UTF-8 table bytes"))?.chars().next().unwrap() + }, + Encoding::Utf16Le | Encoding::Utf16Be => { + let Some(first) = self.unit()? else { return Ok(None); }; + if (0xd800..=0xdbff).contains(&first) { + let second = self.unit()?.ok_or_else(|| invalid("Unpaired UTF-16 high surrogate"))?; + if !(0xdc00..=0xdfff).contains(&second) { return Err(invalid("Invalid UTF-16 surrogate pair")); } + char::from_u32(0x10000 + ((u32::from(first) - 0xd800) << 10) + u32::from(second) - 0xdc00).unwrap() + } else { char::from_u32(u32::from(first)).ok_or_else(|| invalid("Unpaired UTF-16 low surrogate"))? } + }, + }; + if self.first { + self.first = false; + if character == '\u{fffe}' { return Err(invalid("Table BOM conflicts with the requested byte order")); } + if character == '\u{feff}' { return self.next(); } + } + if character == '\n' { self.line += 1; } + Ok(Some(character)) + } +} diff --git a/native/crates/ops-documents/src/lib.rs b/native/crates/ops-documents/src/lib.rs new file mode 100644 index 00000000..713576a9 --- /dev/null +++ b/native/crates/ops-documents/src/lib.rs @@ -0,0 +1,129 @@ +//! Bounded table observations; cells stay strings, and incomplete traversals never invent totals. +#![forbid(unsafe_code)] +mod decode; +mod parse; +use decode::{Decoder, Encoding}; +use ops_protocol::{Failure, Request, Result, capture::json_size}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::{path::{Path, PathBuf}, fs::{File, Metadata}, time::{Duration, Instant}}; +const MAX_ROWS: usize = 100_000; +const INLINE_BYTES: usize = 96 * 1024; +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Args { + path: String, + format: Option, + #[serde(default = "utf8")] encoding: String, + #[serde(default = "yes")] header: bool, + #[serde(default = "one")] offset: usize, + #[serde(default = "two_hundred")] limit: usize, +} +fn utf8() -> String { "utf-8".into() } +fn yes() -> bool { true } +fn one() -> usize { 1 } +fn two_hundred() -> usize { 200 } +fn recovery(error: &mut Failure) { + if error.code == "OUTPUT_LIMIT" { + error.next_action = "Use a dedicated streaming table reader for this bounded scan/record limit; ordinary row paging does not bypass physical scan limits. Do not assume an unobserved suffix is empty.".into(); + } else if error.code == "TABLE_PARSE_ERROR" { + error.next_action = "Inspect the reported record and correct quoting or column counts; preceding complete rows remain observations.".into(); + } else if error.code == "FILE_CHANGED" { + error.next_action = "Acquire a new stable observation before relying on totals or revision; do not treat these rows as a complete snapshot.".into(); + } else if error.code == "DEADLINE_EXCEEDED" { + error.next_action = "Use a dedicated streaming table reader for this scan deadline; the unobserved suffix is unknown.".into(); + } +} +struct Source { path: PathBuf, file: File, before: Metadata, started: Instant } +pub fn dispatch(request: &Request) -> Result { + if request.tool != "table_read" { return Err(Failure::new("INVALID_ARGUMENT", "Unknown document capability")); } + let args: Args = serde_json::from_value(request.args.clone())?; + if args.offset == 0 || args.offset > MAX_ROWS || args.limit == 0 || args.limit > 1000 { + return Err(Failure::new("INVALID_ARGUMENT", "table_read offset is 1-100000 data rows and limit is 1-1000")); + } + let encoding = Encoding::parse(&args.encoding)?; + let format = args.format.as_deref().unwrap_or(if Path::new(&args.path).extension().and_then(|name| name.to_str()).is_some_and(|name| name.eq_ignore_ascii_case("tsv")) { "tsv" } else { "csv" }); + let delimiter = match format { "csv" => ',', "tsv" => '\t', _ => return Err(Failure::new("INVALID_ARGUMENT", "format must be csv or tsv")) }; + request.authorize_path(Path::new(&args.path))?; + let target = ops_files::file_path::checked(&args.path)?; + request.authorize_path(&target.display)?; + let file = ops_files::file_path::read_file(&target)?.into_std(); + let before = file.metadata()?; + observe(request, &args, format, encoding, delimiter, Source { path: target.display, file, before, started: Instant::now() }) +} +fn observe(request: &Request, args: &Args, format: &str, encoding: Encoding, delimiter: char, source: Source) -> Result { + let Source { path, file, before, started } = source; + let observer = file.try_clone()?; + let mut input = Decoder::new(file, encoding)?; + let sink = request.output_sink.as_deref(); + let (mut columns, mut items) = (Vec::::new(), Vec::::new()); + let (mut rows, mut width, mut inline_bytes, mut selected) = (0usize, None, 0usize, 0usize); + let (mut scan_complete, mut output_cut, mut first) = (false, false, true); + let mut failure = None; + loop { + if started.elapsed() >= Duration::from_secs(30) { + failure = Some(Failure::new("DEADLINE_EXCEEDED", "Table scan reached its 30-second deadline")); break; + } + // One bounded lookahead proves EOF for a file with exactly MAX_ROWS data rows. + let record = match parse::next(&mut input, delimiter) { + Ok(Some(record)) => record, + Ok(None) => { scan_complete = true; break; }, + Err(error) => { failure = Some(error); break; }, + }; + if first && args.header { + first = false; + if json_size(&json!(record.cells)) > 8192 { + failure = Some(Failure::new("OUTPUT_LIMIT", "Table header exceeds the 8192-byte metadata limit")); break; + } + width = Some(record.cells.len()); columns = record.cells; continue; + } + first = false; + if rows >= MAX_ROWS { failure = Some(Failure::new("OUTPUT_LIMIT", "Table scan reached 100000 data records")); break; } + let expected = *width.get_or_insert(record.cells.len()); + if record.cells.len() != expected { + failure = Some(Failure::new("TABLE_PARSE_ERROR", format!("Data row {} has {} cells; expected {expected} at physical line {}", rows + 1, record.cells.len(), record.first))); break; + } + rows += 1; + if rows < args.offset || rows >= args.offset.saturating_add(args.limit) { continue; } + let item = json!({"row":rows,"cells":record.cells,"lineStart":record.first,"lineEnd":record.last}); + selected += 1; + if let Some(sink) = sink { sink.push(&item); } + let cost = json_size(&item) + 1; + if output_cut || inline_bytes + cost > INLINE_BYTES - 16384 { + output_cut = true; if let Some(sink) = sink { sink.clipped(); } + } else { inline_bytes += cost; items.push(item); } + } + let after = observer.metadata()?; + if before.len() != after.len() || before.modified().ok() != after.modified().ok() { + scan_complete = false; failure = Some(Failure::new("FILE_CHANGED", "Table metadata changed while reading; rows are observations, not a stable complete file")); + } + if let Some(error) = &mut failure { recovery(error); } + let next = args.offset.saturating_add(selected); + let page_observed = scan_complete || rows >= args.offset.saturating_add(args.limit).saturating_sub(1); + let more = !scan_complete || next <= rows; + let cause = if let Some(error) = &failure { match error.code.as_str() { + "ERR_INVALID_ENCODING" => "encoding", "TABLE_PARSE_ERROR" => "parse-error", "DEADLINE_EXCEEDED" => "time", "FILE_CHANGED" => "changed", _ => "scan-budget", + }} else if more { "row-limit" } else { "none" }; + let mut output = json!({"status":if failure.is_some() {"partial"} else {"completed"},"phase":"verify","changed":0,"effectState":"none", + "path":path,"format":format,"encoding":encoding.name(),"header":args.header,"columns":columns,"columnCount":width.unwrap_or(0), + "bytes":before.len(),"bytesRead":input.bytes_read(),"rowsObserved":rows,"skipped":args.offset-1, + "scanComplete":scan_complete,"pageComplete":page_observed,"complete":scan_complete && !more,"truncated":more, + "truncatedBy":cause,"nextOffset":next,"items":[]}); + if scan_complete { output["rowCount"] = json!(rows); output["total"] = json!(rows); output["omitted"] = json!(rows.saturating_sub(next.saturating_sub(1))); output["revision"] = json!(input.revision()); } + if let Some(error) = failure { + output["nextAction"] = json!(error.next_action); output["error"] = json!(error); + } + if let Some(sink) = sink { sink.source(output.clone()); } + if output_cut { + output["pageComplete"] = json!(false); output["complete"] = json!(false); output["truncated"] = json!(true); + output["nextOffset"] = json!(args.offset + items.len()); + if scan_complete { output["truncatedBy"] = json!("output-budget"); output["omitted"] = json!(rows.saturating_sub(args.offset - 1 + items.len())); } + if output.get("nextAction").is_none() { output["nextAction"] = json!("Use the stored reference when available; otherwise reduce the row limit or use bounded text/hex inspection for a very large record."); } + } + output["items"] = json!(items); + Ok(output) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/native/crates/ops-documents/src/parse.rs b/native/crates/ops-documents/src/parse.rs new file mode 100644 index 00000000..845befe6 --- /dev/null +++ b/native/crates/ops-documents/src/parse.rs @@ -0,0 +1,42 @@ +//! CSV/TSV record state machine; quoted fields retain exact embedded LF/CRLF text. +use crate::decode::Decoder; +use ops_protocol::{Failure, Result}; +use std::io::Read; +pub const MAX_COLUMNS: usize = 1024; +const MAX_RECORD_BYTES: usize = 1024 * 1024; +pub struct Record { pub cells: Vec, pub first: usize, pub last: usize } +fn malformed(message: &str, line: usize) -> Failure { + let mut error = Failure::new("TABLE_PARSE_ERROR", format!("{message} at physical line {line}")); + error.next_action = "Inspect the reported record and correct CSV/TSV quoting or column counts; preceding complete rows remain observations.".into(); error +} +pub fn next(input: &mut Decoder, delimiter: char) -> Result> { + let first = input.line; let mut cells = Vec::new(); let mut field = String::new(); + let (mut quoted, mut closed, mut started, mut count) = (false, false, false, 0usize); + loop { + let line = input.line; + let character = input.next()?; + let Some(character) = character else { + if quoted { return Err(malformed("Unclosed quoted field", line)); } + if !started { return Ok(None); } + cells.push(field); return Ok(Some(Record { cells, first, last: line })); + }; + started = true; count += character.len_utf8(); + if count > MAX_RECORD_BYTES { return Err(Failure::new("OUTPUT_LIMIT", "One decoded table record exceeds 1 MiB")); } + if quoted { + if character == '"' { quoted = false; closed = true; } else { field.push(character); } + continue; + } + if character == '"' { + if closed { field.push('"'); quoted = true; closed = false; } + else if field.is_empty() { quoted = true; } + else { return Err(malformed("Quote inside unquoted field", line)); } + } else if character == delimiter { + cells.push(std::mem::take(&mut field)); closed = false; + if cells.len() >= MAX_COLUMNS { return Err(Failure::new("OUTPUT_LIMIT", "One table record exceeds 1024 columns")); } + } else if character == '\n' || character == '\r' { + if character == '\r' && input.next()? != Some('\n') { return Err(malformed("Bare CR outside quoted field; use LF or CRLF records", line)); } + cells.push(field); return Ok(Some(Record { cells, first, last: line })); + } else if closed { return Err(malformed("Unexpected character after closing quote", line)); } + else { field.push(character); } + } +} diff --git a/native/crates/ops-documents/src/tests.rs b/native/crates/ops-documents/src/tests.rs new file mode 100644 index 00000000..30d867e2 --- /dev/null +++ b/native/crates/ops-documents/src/tests.rs @@ -0,0 +1,52 @@ +use super::*; +fn fixture(test: impl FnOnce(PathBuf, Request, Args)) { + let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let root = std::fs::canonicalize(std::env::temp_dir()).unwrap().join(format!("dsh-table-{}-{unique}", std::process::id())); + std::fs::create_dir(&root).unwrap(); + let path = root.join("table.csv"); std::fs::write(&path, "v\nsafe\n").unwrap(); + let request = serde_json::from_value(json!({"root":root,"scope":"table-unit","tool":"table_read","args":{}})).unwrap(); + let args = serde_json::from_value(json!({"path":path})).unwrap(); + test(path, request, args); + std::fs::remove_dir_all(root).unwrap(); +} +#[test] +fn expired_scan_deadline_has_no_total_revision_or_complete_claim() { + fixture(|path, request, args| { + let file = File::open(&path).unwrap(); let before = file.metadata().unwrap(); + let source = Source { path, file, before, started: Instant::now() - Duration::from_secs(31) }; + let output = observe(&request, &args, "csv", Encoding::Utf8, ',', source).unwrap(); + assert_eq!(output["status"], "partial"); assert_eq!(output["error"]["code"], "DEADLINE_EXCEEDED"); + assert_eq!(output["scanComplete"], false); assert_eq!(output["rowsObserved"], 0); + assert!(output.get("rowCount").is_none()); assert!(output.get("revision").is_none()); + }); +} +#[test] +fn changed_file_metadata_invalidates_totals_without_discarding_safe_rows() { + fixture(|path, request, args| { + let file = File::open(&path).unwrap(); let before = file.metadata().unwrap(); + std::fs::write(&path, "v\nchanged-long\n").unwrap(); + let source = Source { path, file, before, started: Instant::now() }; + let output = observe(&request, &args, "csv", Encoding::Utf8, ',', source).unwrap(); + assert_eq!(output["error"]["code"], "FILE_CHANGED"); assert_eq!(output["scanComplete"], false); + assert_eq!(output["complete"], false); assert_eq!(output["items"][0]["cells"][0], "changed-long"); + assert!(output.get("total").is_none()); assert!(output.get("revision").is_none()); + }); +} + +#[test] +fn short_reads_do_not_bypass_bom_or_unicode_validation() { + struct OneByte(std::io::Cursor>); + impl std::io::Read for OneByte { + fn read(&mut self, bytes: &mut [u8]) -> std::io::Result { + let count = bytes.len().min(1); std::io::Read::read(&mut self.0, &mut bytes[..count]) + } + } + let reader = |bytes: Vec| OneByte(std::io::Cursor::new(bytes)); + assert!(Decoder::new(reader(vec![0xef, 0xbb, 0xbf, b'x']), Encoding::Utf16Be).is_err()); + assert!(Decoder::new(reader(vec![0xff, 0xfe, b'x', 0]), Encoding::Utf8).is_err()); + let mut valid = Decoder::new(reader("\u{feff}中😀".as_bytes().to_vec()), Encoding::Utf8).unwrap(); + assert_eq!(valid.next().unwrap(), Some('中')); assert_eq!(valid.next().unwrap(), Some('😀')); + assert_eq!(valid.next().unwrap(), None); + let mut invalid = Decoder::new(reader(vec![0xf0, 0x9f]), Encoding::Utf8).unwrap(); + assert_eq!(invalid.next().unwrap_err().code, "ERR_INVALID_ENCODING"); +} diff --git a/native/crates/ops-results/src/spill.rs b/native/crates/ops-results/src/spill.rs index cb779a5e..de56e43e 100644 --- a/native/crates/ops-results/src/spill.rs +++ b/native/crates/ops-results/src/spill.rs @@ -19,7 +19,7 @@ fn mark_head(request: &Request, value: &mut Value) { let skipped = if request.tool == "replace" { 0 } else { value["skipped"].as_u64().unwrap_or(0) }; if let Some(total) = value["total"].as_u64() { value["omitted"] = json!(total.saturating_sub(skipped)); } if value.get("nextOffset").is_some() { - value["nextOffset"] = json!(if request.tool == "inspect_local_file" { + value["nextOffset"] = json!(if request.tool == "table_read" { skipped + 1 } else if request.tool == "inspect_local_file" { if request.args["view"] == "hex" { skipped / 16 + 1 } else { skipped + 1 } } else { skipped }); } diff --git a/native/src/lib.rs b/native/src/lib.rs index f5939863..f6fe2c4a 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -33,6 +33,7 @@ pub fn dispatch(request: Request) -> Result { Ok(ops_results::spill::finish(&request, value)) } fn execute(dir: &Dir, request: &Request) -> Result { + if request.tool == "table_read" { return ops_documents::dispatch(request); } // File reads/searches do not create or consult the durable audit store. // This is important for read-only sessions and keeps observation separate // from mutation audit records. diff --git a/native/src/main.rs b/native/src/main.rs index f4c28f3b..5191b642 100644 --- a/native/src/main.rs +++ b/native/src/main.rs @@ -4,7 +4,7 @@ use serde_json::json; use std::io::{Read, Write}; fn may_mutate(request: &Request) -> bool { - !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "glob" | "grep" | "diff" | "stat") + !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "table_read" | "glob" | "grep" | "diff" | "stat") && !(request.tool == "result" && request.args.get("action").and_then(serde_json::Value::as_str) == Some("page")) } fn main() { diff --git a/test/package-source.test.mjs b/test/package-source.test.mjs index cada4b64..281ab153 100644 --- a/test/package-source.test.mjs +++ b/test/package-source.test.mjs @@ -14,7 +14,10 @@ await test('npm pack includes compiled host code and native sources, never host 'lib/components/panel/package.json', 'lib/components/panel/icon.png', 'lib/panel/panel.html', 'lib/components/tui/dsh-plugin.json', 'native/Cargo.toml', 'native/Cargo.lock', 'native/src/lib.rs', 'native/windows-metadata/Cargo.toml', 'native/windows-metadata/src/lib.rs', - 'native/darwin-metadata/Cargo.toml', 'native/darwin-metadata/src/lib.rs'] + 'native/darwin-metadata/Cargo.toml', 'native/darwin-metadata/src/lib.rs', + 'native/crates/ops-documents/Cargo.toml', 'native/crates/ops-documents/src/lib.rs', + 'native/crates/ops-documents/src/decode.rs', 'native/crates/ops-documents/src/parse.rs', + 'native/crates/ops-documents/src/tests.rs'] for (const name of required) assert.ok(files.includes(name), `missing source-build dependency: ${name}`) assert.equal(files.some(name => name.startsWith('src/')), false, 'host source must not ship beside compiled lib/') assert.equal(files.some(name => /^native\/(?:target[^/]*|(?:windows|darwin)-metadata\/target)\//u.test(name)), false) diff --git a/test/table-worker.test.mjs b/test/table-worker.test.mjs new file mode 100644 index 00000000..83c20938 --- /dev/null +++ b/test/table-worker.test.mjs @@ -0,0 +1,183 @@ +/** Bounded table parsing through the real one-shot worker, including reference recovery. */ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { callWorker, request, envelopeOf, requireWorker } from './lib/boundary.mjs' +requireWorker('table-worker') +const hash = value => createHash('sha256').update(value).digest('hex') +const snapshot = root => fs.readdirSync(root, { recursive: true }).sort() +const quoted = text => `"${text.replaceAll('"', '""')}"` +const payload = (root, args, options = {}) => ({ version: 1, requestId: 'table-test', component: 'file', + budget: { timeoutMs: 30000, maxBytes: options.maxBytes ?? 131072 }, + ...(options.cache ? { storage: { root: options.cache, authorityId: 'a'.repeat(64), allowedTools: ['table_read', 'result'] } } : {}), + request: { ...request(options.tool ?? 'table_read', args, { root, readOnly: options.readOnly, writeRoots: [] }), ...options.authority } }) +async function send(value) { + const raw = await callWorker(JSON.stringify(value)); const output = envelopeOf(raw, assert) + assert.ok(Buffer.byteLength(raw.stdout) <= value.budget.maxBytes); return output +} +const run = (root, args, options) => send(payload(root, args, options)) +const fileAt = (root, name, bytes) => { const file = path.join(root, name); fs.writeFileSync(file, bytes); return file } +function gap(body, code) { + assert.equal(body.status, 'partial'); assert.equal(body.error.code, code); assert.equal(body.error.effectState, 'none') + assert.equal(body.scanComplete, false); assert.equal(body.complete, false) + for (const field of ['total', 'rowCount', 'revision']) assert.equal(body[field], undefined, `${field} requires a complete scan`) +} +await test('CSV preserves lexical cells, duplicate/empty headers, quoted commas, escaped quotes and physical LF ranges', async () => { + await withTempDir('table-csv', async root => { + const text = '\ufeffname,name,,value\r\n"alpha,beta","q""q",,"two\r\nlines"\r\n0001,TRUE,,2026-10-19\r\n' + const file = fileAt(root, 'values.csv', text); const body = await run(root, { path: file }) + assert.equal(body.status, 'completed'); assert.equal(body.complete, true); assert.equal(body.scanComplete, true) + assert.deepEqual(body.columns, ['name', 'name', '', 'value']); assert.equal(body.columnCount, 4) + assert.deepEqual(body.items, [{ row: 1, cells: ['alpha,beta', 'q"q', '', 'two\r\nlines'], lineStart: 2, lineEnd: 3 }, + { row: 2, cells: ['0001', 'TRUE', '', '2026-10-19'], lineStart: 4, lineEnd: 4 }]) + assert.equal(body.rowCount, 2); assert.equal(body.revision, hash(text)); assert.equal(body.bytesRead, Buffer.byteLength(text)) + assert.equal(body.encoding, 'utf-8'); assert.equal(body.format, 'csv'); assert.equal(body.nextOffset, 3) + }) +}) +await test('TSV inference and headerless/empty records have no phantom trailing row', async () => { + await withTempDir('table-tsv', async root => { + const file = fileAt(root, 'values.TSV', 'a\t"b\tc"\t\nx\t""\tz\n') + const body = await run(root, { path: file, header: false }) + assert.equal(body.format, 'tsv'); assert.deepEqual(body.columns, []); assert.equal(body.columnCount, 3) + assert.deepEqual(body.items.map(item => item.cells), [['a', 'b\tc', ''], ['x', '', 'z']]); assert.equal(body.total, 2) + for (const [text, count] of [['', 0], ['\ufeff', 0], ['\n\n', 2], ['a,\n', 1]]) { + const observed = await run(root, { path: fileAt(root, 'empty.csv', text), header: false }) + assert.equal(observed.total, count); assert.equal(observed.complete, true) + } + const headerOnly = await run(root, { path: fileAt(root, 'header.csv', 'a,b\n') }) + assert.equal(headerOnly.total, 0); assert.deepEqual(headerOnly.columns, ['a', 'b']); assert.deepEqual(headerOnly.items, []) + }) +}) +await test('bounded row pages keep full observed totals separate from page completeness', async () => { + await withTempDir('table-pages', async root => { + const file = fileAt(root, 'rows.csv', 'id\n' + Array.from({ length: 500 }, (_, i) => i + 1).join('\n')) + const first = await run(root, { path: file }) + assert.equal(first.items.length, 200); assert.equal(first.nextOffset, 201); assert.equal(first.complete, false) + assert.equal(first.pageComplete, true); assert.equal(first.rowCount, 500); assert.equal(first.rowsObserved, 500) + assert.equal(first.omitted, 300); assert.equal(first.truncatedBy, 'row-limit') + const tail = await run(root, { path: file, offset: 499, limit: 2 }) + assert.deepEqual(tail.items.map(item => item.cells), [['499'], ['500']]); assert.equal(tail.complete, true); assert.equal(tail.nextOffset, 501) + const after = await run(root, { path: file, offset: 999 }) + assert.equal(after.complete, true); assert.deepEqual(after.items, []); assert.equal(after.nextOffset, 999) + }) +}) +await test('UTF-16 byte orders decode strictly and all conflicting BOMs are refused', async () => { + await withTempDir('table-encoding', async root => { + const text = '\ufeffname,value\n中,😀\n'; const le = Buffer.from(text, 'utf16le'); const be = Buffer.from(le).swap16() + for (const [encoding, bytes] of [['utf-16le', le], ['utf-16be', be]]) { + const file = fileAt(root, `${encoding}.csv`, bytes); const body = await run(root, { path: file, encoding }) + assert.equal(body.encoding, encoding); assert.deepEqual(body.items[0].cells, ['中', '😀']); assert.equal(body.revision, hash(bytes)) + } + const cases = [[le, 'utf-8'], [le, 'utf-16be'], [be, 'utf-8'], [be, 'utf-16le'], [Buffer.from(text), 'utf-16be'], [Buffer.from(text), 'utf-16le']] + for (const [bytes, encoding] of cases) { + const body = await run(root, { path: fileAt(root, 'mismatch.csv', bytes), encoding }) + assert.equal(body.error.code, 'ERR_INVALID_ENCODING'); assert.equal(body.error.effectState, 'none'); assert.equal(body.items, undefined) + } + }) +}) +await test('malformed encoding after safe rows never emits replacement characters or a full total', async () => { + await withTempDir('table-invalid', async root => { + const cases = [ + [Buffer.concat([Buffer.from('v\nsafe\n'), Buffer.from([0xff])]), 'utf-8'], + [Buffer.concat([Buffer.from('v\nsafe\n'), Buffer.from([0xf0, 0x9f])]), 'utf-8'], + [Buffer.concat([Buffer.from('v\nsafe\n', 'utf16le'), Buffer.from([0, 0xd8])]), 'utf-16le'], + [Buffer.concat([Buffer.from('v\nsafe\n', 'utf16le'), Buffer.from([0, 0xdc])]), 'utf-16le'], + [Buffer.concat([Buffer.from('v\nsafe\n', 'utf16le'), Buffer.from([1])]), 'utf-16le'], + ] + for (const [bytes, encoding] of cases) { + const body = await run(root, { path: fileAt(root, 'bad.csv', bytes), encoding, limit: 1 }) + gap(body, 'ERR_INVALID_ENCODING'); assert.equal(body.truncatedBy, 'encoding'); assert.equal(body.pageComplete, true) + assert.deepEqual(body.items.map(item => item.cells), [['safe']]); assert.doesNotMatch(JSON.stringify(body.items), /�/) + } + }) +}) +await test('malformed quote/column records preserve only preceding complete rows', async () => { + await withTempDir('table-malformed', async root => { + for (const suffix of ['"unfinished', 'un"quoted', '"closed" suffix', 'x,y', 'bare\rnext']) { + const body = await run(root, { path: fileAt(root, 'bad.csv', 'v\nsafe\n' + suffix) }) + gap(body, 'TABLE_PARSE_ERROR'); assert.equal(body.truncatedBy, 'parse-error') + assert.deepEqual(body.items.map(item => item.cells), [['safe']]); assert.equal(body.rowsObserved, 1) + } + }) +}) +await test('strict argument errors happen before reads and do not create artifacts', async () => { + await withTempDir('table-args', async root => { + const file = fileAt(root, 'data.csv', 'a\nb'); const before = snapshot(root) + for (const bad of [{ offset: 0 }, { offset: 100001 }, { limit: 0 }, { limit: 1001 }, { format: 'auto' }, { header: 'yes' }, { unknown: true }]) { + const body = await run(root, { path: file, ...bad }); assert.equal(body.error.code, 'INVALID_ARGUMENT'); assert.equal(body.error.effectState, 'none') + } + assert.equal((await run(root, { path: file, encoding: 'latin1' })).error.code, 'ERR_INVALID_ENCODING') + assert.deepEqual(snapshot(root), before) + }) +}) +await test('readonly observations retain credential/no-follow checks and create no audit/cache files', async () => { + await withTempDir('table-authority', async root => { + const good = fileAt(root, 'good.csv', 'v\nsafe'); const secret = path.join(root, 'vault'); fs.mkdirSync(secret) + const privateFile = fileAt(secret, 'secret.csv', 'v\nDO_NOT_DISCLOSE'); const credential = fileAt(root, '.env', 'v\nSECRET') + const linked = path.join(root, 'linked'); fs.symlinkSync(secret, linked, process.platform === 'win32' ? 'junction' : 'dir') + const before = snapshot(root) + const options = { readOnly: true, authority: { deny_roots: [secret], deny_credential_names: true } } + assert.equal((await run(root, { path: good }, options)).items[0].cells[0], 'safe') + for (const [file, code] of [[privateFile, 'CREDENTIAL_DENIED'], [credential, 'CREDENTIAL_DENIED'], [path.join(linked, 'secret.csv'), 'PATH_RESTRICTED'], [root, 'IS_DIRECTORY']]) { + const body = await run(root, { path: file }, options); assert.equal(body.error.code, code); assert.equal(body.error.effectState, 'none') + assert.doesNotMatch(JSON.stringify(body), /DO_NOT_DISCLOSE|SECRET/) + } + assert.deepEqual(snapshot(root), before) + }) +}) +await test('record, header, column and scan-record caps never label an unobserved suffix complete', async () => { + await withTempDir('table-caps', async root => { + for (const text of ['v\nsafe\n' + 'x'.repeat(1024 * 1024 + 1), 'v\nsafe\n' + Array(1025).fill('x').join(','), 'x'.repeat(9000) + '\nvalue']) { + const body = await run(root, { path: fileAt(root, 'capped.csv', text) }); gap(body, 'OUTPUT_LIMIT') + assert.equal(body.truncatedBy, 'scan-budget'); assert.match(body.nextAction, /does not bypass physical scan limits/) + } + const exact = await run(root, { path: fileAt(root, 'exact.csv', 'v\n' + 'x\n'.repeat(100000)), offset: 100000, limit: 1 }) + assert.equal(exact.scanComplete, true); assert.equal(exact.rowCount, 100000); assert.equal(exact.complete, true) + const cap = await run(root, { path: fileAt(root, 'over.csv', 'v\n' + 'x\n'.repeat(100001)), offset: 100000, limit: 1 }) + gap(cap, 'OUTPUT_LIMIT'); assert.equal(cap.rowsObserved, 100000); assert.equal(cap.items[0].row, 100000) + }) +}) +await test('32 MiB streamed scan cap retains safe rows without fabricating a revision', async () => { + await withTempDir('table-byte-cap', async root => { + const file = fileAt(root, 'large.csv', 'v\n' + ('x'.repeat(4096) + '\n').repeat(8193)) + const body = await run(root, { path: file, limit: 1 }); gap(body, 'OUTPUT_LIMIT') + assert.equal(body.items.length, 1); assert.ok(body.rowsObserved > 0 && body.rowsObserved < 10000) + assert.ok(body.bytesRead <= 32 * 1024 * 1024 + 1); assert.equal(body.pageComplete, true) + }) +}) +await test('large table cells spill before clipping and recover as exact typed records', async () => { + await withTempDir('table-spill', async root => { + const cache = path.join(root, 'cache'); fs.mkdirSync(cache, { mode: 0o700 }) + const text = '中😀\0"\\'.repeat(10000); const file = fileAt(root, 'large.csv', `value\n${quoted(text)}\nlast`) + const head = await run(root, { path: file }, { cache, maxBytes: 4096 }) + assert.ok(head.reference); assert.equal(head.nextOffset, 1); assert.equal(head.complete, false) + const records = []; let offset = 0, source + do { + const page = await run(head.referenceRoot, { action: 'page', id: head.reference.refId, offset, limit: 512, maxBytes: 65536 }, { cache, tool: 'result', readOnly: true }) + assert.equal(page.error, undefined); source = page.source; records.push(...page.items) + assert.ok(page.nextOffset > offset); offset = page.nextOffset; if (page.complete) break + } while (true) + assert.equal(source.rowCount, 2); assert.equal(source.captureComplete, true); assert.equal(source.itemEncoding, 'tagged-json-v1') + const rows = []; let chunks = [], byteOffset = 0 + for (const record of records) { + assert.equal(record.index, rows.length) + if (record.kind === 'item') { assert.equal(chunks.length, 0); rows.push(record.value); continue } + assert.equal(record.chunk, chunks.length); assert.equal(record.byteOffset, byteOffset) + chunks.push(record.text); byteOffset += Buffer.byteLength(record.text) + if (record.complete) { rows.push(JSON.parse(chunks.join(''))); chunks = []; byteOffset = 0 } + } + assert.equal(chunks.length, 0); assert.deepEqual(rows.map(row => row.cells), [[text], ['last']]) + const empty = path.join(root, 'empty-cache'); fs.mkdirSync(empty, { mode: 0o700 }) + const readonly = await run(root, { path: file }, { cache: empty, maxBytes: 4096, readOnly: true }) + assert.equal(readonly.reference, undefined); assert.equal(readonly.storageError.code, 'POLICY_DENIED'); assert.deepEqual(fs.readdirSync(empty), []) + }) +}) +await test('table_read transport overflow remains a read-only operation in writable sessions', async () => { + await withTempDir('table-transport', async root => { + const file = fileAt(root, 'large.csv', 'v\n' + 'x'.repeat(40000)); const before = snapshot(root) + const body = await run(root, { path: file }, { maxBytes: 1024 }) + assert.equal(body.error.code, 'OUTPUT_LIMIT'); assert.equal(body.error.effectState, 'none'); assert.deepEqual(snapshot(root), before) + }) +}) +report('table-worker') From b6597e9b9463223777c08c40894c9dbc4306712a Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:41:57 +0800 Subject: [PATCH 081/118] feat(documents): expose scoped typed table readers --- docs/CHANGELOG.md | 4 +- docs/native-kernel.md | 15 ++- ...37\347\224\237\345\206\205\346\240\270.md" | 11 +- dsh-plugin.json | 3 +- src/capabilities/discovery.ts | 1 + src/capabilities/documents.ts | 40 ++++++ src/capabilities/file.ts | 5 + src/capabilities/registry.ts | 2 +- src/kernel/errors.ts | 2 + src/modes/native/documents.ts | 19 +++ src/modes/native/render.ts | 7 +- src/modes/ptc/canonical-schema.ts | 4 +- src/modes/ptc/continuation.ts | 9 +- test/capabilities.test.mjs | 6 +- test/lib/document-host.mjs | 49 +++++++ test/table-host.test.mjs | 120 ++++++++++++++++++ test/types/ptc-sdk.ts | 15 +++ 17 files changed, 297 insertions(+), 15 deletions(-) create mode 100644 src/capabilities/documents.ts create mode 100644 src/modes/native/documents.ts create mode 100644 test/lib/document-host.mjs create mode 100644 test/table-host.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 27227a1e..f248e1f0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,8 +13,10 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm string cells, physical line ranges, bounded row pages and safe preceding rows on parse failures. - Separate complete physical scans from requested pages and reference capture; reuse no-follow, credential, readonly and automatic-spill boundaries without new external dependencies. +- Expose discoverable-only Native rows and typed PTC cells with full-scan-only continuations; retain + safe prefix data beside non-resumable parse/scan gaps and preserve authority and reference metadata. - 新增有界 CSV/TSV worker 读取,严格解码、保留原字符串与物理行范围;错误保留此前有效行, - 并分别报告扫描、分页和引用捕获完整性,不新增外部依赖。发现/宿主注册由后续切片接入。 + 并分别报告扫描、分页和引用捕获完整性,不新增外部依赖。通过发现/SDK 按代理在下一步骤启用;解析缺口保留前缀且不伪造续取。 - Add optional bilingual browser settings pages for replacement selection, discovery pools and explicit Bash/host PowerShell runtime plans. Preserve the default legacy file row; custom pages require a client carrier and unload when it is disabled. - 新增可选双语浏览器设置页面,支持逐工具替换、发现池及显式 Bash/宿主 PowerShell 运行时计划。默认保留旧文件行;自定义页面需要客户端承载行,关闭承载行即卸载页面。 diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 4bb8b670..0e555b2a 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -131,10 +131,11 @@ retain the legacy transport contract; the host supplies recovery guidance when s ## CSV/TSV worker observations (15a/16) -The new ops-documents crate handles `table_read` in the same one-shot worker. This slice exposes -only the worker capability; discovery/host registration is separate. It adds no external packages: +The new ops-documents crate handles `table_read` in the same one-shot worker. The capability stays outside +the default schema: Native uses `ops_discover` and PTC uses `ops_sdk` to activate `table_read` +for the next agent step. Siblings and descendants require their own selection. It adds no external packages: serde/serde_json and sha2 reuse the existing locked workspace dependency graph. PDF/DOC parsing -remains separate work requiring its own dependency review. +requires separate dependency review. Arguments are `{ path, format?, encoding?, header?, offset?, limit? }`. The host resolves path against current cwd before sending an absolute path. Format is csv or tsv, inferred as tsv only @@ -172,3 +173,11 @@ objects. Oversized requested rows use the existing bounded spill sink before dis references contain exact typed row JSON, source retains table/coverage facts, and captureComplete is independent of scanComplete. Page with the original referenceRoot. Cache effects never imply business writes; table_read is classified as read-only even in a writable session. + +Native renders positional JSON cells with physical LF ranges and explicit non-exhaustive gap notices. +PTC exposes lexical `string[]` cells through generated declarations. Stable complete scans with +remaining rows supply read-only forward continuations; callers must compare revisions across calls. +Malformed/changed input and physical scan limits retain prefix data and recovery advice, with no +continuation that would repeat the same gap. Native reference pages and PTC result pages preserve +original table coverage separately from stored-row pagination. Shared admission, current authority, +credential checks, cancellation and storage bindings apply unchanged. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index c0dd4b09..c17331e4 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -107,8 +107,9 @@ complete=true 后 JSON.parse;须验证连续索引、偏移及结束标记。S ## CSV/TSV worker 观察(15a/16) -新增 ops-documents crate,在同一个单次 worker 内处理 table_read。本切片只增加 worker 能力, -宿主注册及发现入口另行接入。没有新增外部包:serde/serde_json 与 sha2 复用既有锁定依赖。 +新增 ops-documents crate,在同一个单次 worker 内处理 table_read。该能力不进入默认工具 schema: +Native 经 `ops_discover`、PTC 经 `ops_sdk` 选择 table_read 后,于代理下一步骤生效;兄弟及 +子代理必须独立选择。没有新增外部包:serde/serde_json 与 sha2 复用既有锁定依赖。 PDF/DOC 解析仍是需要独立依赖审查的后续工作。 参数为 `{ path, format?, encoding?, header?, offset?, limit? }`。宿主先相对于当前 cwd 解析 @@ -139,3 +140,9 @@ FILE_CHANGED、DEADLINE_EXCEEDED;预检权限拒绝沿用原有策略错误码 裁切前使用既有有界捕获器:引用保留可精确重建的类型化行 JSON,source 保留表格/覆盖事实, captureComplete 独立于 scanComplete。分页须使用原 referenceRoot。缓存副作用不代表业务写入; 即使会话可写,table_read 也按只读操作分类。 + +Native 用带物理 LF 行范围的原位置 JSON 单元格展示,并明确提示扫描缺口不完整。PTC 的生成 +声明保留 `string[]` 单元格。只有稳定完整扫描且仍有后续行时提供只读前进续取;跨调用组合页面 +须比较 revision。非法输入、文件变化和物理扫描上限保留前缀数据与恢复提示,不提供会重复 +原缺口的续取。Native/PTC 引用页均区分原表格覆盖率与已存记录分页;既有排队、实时权限、 +凭据检查、取消与存储绑定继续适用。 diff --git a/dsh-plugin.json b/dsh-plugin.json index 891dfa3d..406d2e35 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -85,6 +85,7 @@ "remove", "read_many", "edit_many", + "table_read", "result" ], "shellPermission": "Per-call DSH sandbox policy and native subprocess" @@ -110,7 +111,7 @@ "targets": ["win32-x64", "linux-x64", "darwin-arm64"], "presentation": "PTC-only; reader/result/SDK loader resident, other APIs become visible on the next agent step", "runtime": "independent native Rust file worker", - "tools": ["ops_inspect_local_file","ops_grep","ops_glob","ops_replace","ops_write_file","ops_edit_file","ops_diff","ops_stat","ops_copy","ops_move","ops_remove","ops_read_many","ops_edit_many","ops_batch","ops_result","ops_sdk"], + "tools": ["ops_inspect_local_file","ops_grep","ops_glob","ops_replace","ops_write_file","ops_edit_file","ops_diff","ops_stat","ops_copy","ops_move","ops_remove","ops_read_many","ops_edit_many","ops_table_read","ops_batch","ops_result","ops_sdk"], "permissions": "Per-call original DSH sandboxPolicy and sandbox.confine (host full/partial enforcement reported); full access bypasses confinement and permits explicit operation roots; subprocess owns cancellation and credential scrub" }, { diff --git a/src/capabilities/discovery.ts b/src/capabilities/discovery.ts index 7e37aab8..87d5e0c7 100644 --- a/src/capabilities/discovery.ts +++ b/src/capabilities/discovery.ts @@ -3,6 +3,7 @@ import type { ParameterSchema } from './types.js' /** Reviewed, currently implemented advanced operations. Future formats are absent. */ export const DISCOVERY_TERMS = Object.freeze({ + table_read: 'table csv tsv delimiter quoted records columns rows encoding utf16', inspect_local_file: 'read inspect single file text hex binary bounded line windows', edit_many: 'edit patch multiple files atomic per file revision cas', result: 'result reference page release stored output continuation', diff --git a/src/capabilities/documents.ts b/src/capabilities/documents.ts new file mode 100644 index 00000000..a296d568 --- /dev/null +++ b/src/capabilities/documents.ts @@ -0,0 +1,40 @@ +/** Document schemas describe their bounded observation scope; registration remains catalogue-owned. */ +import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import type { ParameterSchema } from './types.js' +import { STORAGE_REFERENCE } from './result.js' +const string: JsonSchemaNode = { type: 'string' } +const integer: JsonSchemaNode = { type: 'integer' } +const boolean: JsonSchemaNode = { type: 'boolean' } +const strings: JsonSchemaNode = { type: 'array', items: string } +const object = (properties: Record, required: string[] = []): ParameterSchema => ({ type: 'object', properties, required, additionalProperties: false }) +export const DOCUMENT_PARAMETERS = Object.freeze({ + table_read: object({ + path: { ...string, description: 'Regular file path relative to the current session cwd.' }, + format: { type: 'string', enum: ['csv', 'tsv'], description: 'Default tsv for .tsv paths, otherwise csv.' }, + encoding: { type: 'string', enum: ['utf-8', 'utf-16le', 'utf-16be'], default: 'utf-8', description: 'Strict decoding; conflicting BOMs fail. No replacement characters.' }, + header: { type: 'boolean', default: true, description: 'First record is columns when true; empty/duplicate column names stay positional.' }, + offset: { type: 'integer', default: 1, description: '1-100000 data rows, 1-based; header is excluded.' }, + limit: { type: 'integer', default: 200, description: '1-1000 data rows.' }, + }, ['path']), +}) +export const DOCUMENT_DESCRIPTIONS = Object.freeze({ + table_read: 'Read bounded CSV/TSV lexical string cells relative to session cwd. Preserves quoted multiline records and physical LF lineStart/lineEnd. Check scanComplete versus pageComplete; total/revision exist only after a stable full scan. Up to 32 MiB, 100000 data records, 1 MiB/record, 1024 columns and 30s; row paging cannot bypass scan gaps. Errors retain safe preceding rows with nextAction. Compare revisions before combining rescanned pages. No spreadsheet formulas or type coercion.', +}) +export const DOCUMENT_FACTS: Record = { + format: string, encoding: string, header: boolean, columns: strings, columnCount: integer, + bytes: integer, bytesRead: integer, rowsObserved: integer, skipped: integer, + scanComplete: boolean, pageComplete: boolean, complete: boolean, truncated: boolean, + truncatedBy: string, nextOffset: integer, total: integer, rowCount: integer, omitted: integer, revision: string, +} +export const DOCUMENT_SOURCE: JsonSchemaNode = { type: 'object', properties: { ...DOCUMENT_FACTS, root: string, + itemEncoding: string, capturedItems: integer, captureComplete: boolean } } +export const DOCUMENT_OUTPUT: JsonSchemaNode = { + type: 'object', properties: { + ...DOCUMENT_FACTS, status: string, phase: string, changed: integer, effectState: string, path: string, + items: { type: 'array', items: object({ row: integer, cells: strings, lineStart: integer, lineEnd: integer }, ['row', 'cells', 'lineStart', 'lineEnd']) }, + error: { type: 'object' }, nextAction: string, reference: STORAGE_REFERENCE, referenceRoot: string, + source: DOCUMENT_SOURCE, storageError: { type: 'object' }, + }, +} +export function isDocumentTool(tool: string): tool is keyof typeof DOCUMENT_PARAMETERS { return Object.hasOwn(DOCUMENT_PARAMETERS, tool) } +export function documentOutput(tool: string): JsonSchemaNode | undefined { return isDocumentTool(tool) ? DOCUMENT_OUTPUT : undefined } diff --git a/src/capabilities/file.ts b/src/capabilities/file.ts index c21bbd9c..66520864 100644 --- a/src/capabilities/file.ts +++ b/src/capabilities/file.ts @@ -1,5 +1,6 @@ /** File capability data; no host services or registration side effects. */ import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import { DOCUMENT_PARAMETERS, DOCUMENT_DESCRIPTIONS, documentOutput } from './documents.js' import { STORAGE_REFERENCE } from './result.js' import type { ParameterSchema } from './types.js' @@ -16,6 +17,7 @@ const existingRevision: JsonSchemaNode = { type: 'string', description: '64 lowe const edits = bounded({ path: string, expectedRevision: existingRevision, edits: { type: 'array', description: '1-100 nonoverlapping edits against the same original bytes.', items: bounded({ oldText: { ...string, description: 'Nonempty text that occurs exactly once in the original.' }, newText: string }, ['oldText', 'newText']) }, dryRun: { type: 'boolean' } }, ['path', 'expectedRevision', 'edits']) export const PARAMETERS = Object.freeze({ + ...DOCUMENT_PARAMETERS, read_many: bounded({ files: { type: 'array', description: '1-32 file ranges.', items: bounded({ path: string, offset: { type: 'integer', description: 'Line >=1; default 1.' }, limit: range(1, 10000, 'Default 200.'), }, ['path']) } }, ['files']), @@ -105,6 +107,8 @@ export const SEARCH_OUTPUT: JsonSchemaNode = { /** Every projection declares the same canonical execution shape. */ export function fileOutput(rawName: keyof typeof PARAMETERS): JsonSchemaNode { + const document = documentOutput(rawName) + if (document) return document if (rawName === 'read_many') return READ_MANY_OUTPUT if (rawName === 'glob' || rawName === 'grep') return SEARCH_OUTPUT return OUTPUT @@ -114,6 +118,7 @@ export function fileOutput(rawName: keyof typeof PARAMETERS): JsonSchemaNode { const RESULT = ' Failures resolve as {status:"error"|"rejected", error.code}; they are not thrown.' const EDIT_DESCRIPTION = 'Precisely patch one UTF-8 file using 1-100 oldText/newText edits against the SAME original bytes. Every nonempty oldText must occur exactly once; overlapping edits reject all. Required expectedRevision from inspect/stat. Atomic CAS; dryRun previews. Darwin nonempty extended ACLs fail closed with METADATA_UNSUPPORTED. Preserve CRLF/BOM explicitly in text. Windows preserves owner/group/ordered DACL and supported basic attributes/timestamps; readonly overwrite is refused and unsupported streams/EA/special storage fail with METADATA_UNSUPPORTED. SACL/audit labels are not preserved.' + RESULT export const DESCRIPTIONS = Object.freeze({ + ...DOCUMENT_DESCRIPTIONS, read_many: 'Read UTF-8 ranges relative to session cwd. Check each file complete/error; nextOffset continues lines, nextIndex resumes pending files. Binary needs a binary reader. Failures return status:error|rejected and error.code; never throw.', inspect_local_file: 'Read bounded UTF-8 text lines or a hex view from path relative to the DSH session cwd. Directories are rejected; use ops_glob or ops_grep.' + RESULT, glob: 'Find regular files below path relative to the current session cwd. pattern is an ARRAY of globs matched against paths relative to path: ["**/*.md","!vendor/**"]; "*.md" matches the top level only, "!" excludes, at least one positive entry. Symlinks, reparse points, hidden files and dependency/build directories are excluded. limit defaults to 100; paging uses 0-based offset/nextOffset. Check scanComplete and pageComplete; matchedTotal exists only after a complete scan. Prefer continuation with unchanged options; SEARCH_STALE means restart. Incomplete scans require a narrower query.' + RESULT, diff --git a/src/capabilities/registry.ts b/src/capabilities/registry.ts index 885abc9b..9d1f9647 100644 --- a/src/capabilities/registry.ts +++ b/src/capabilities/registry.ts @@ -10,7 +10,7 @@ import type { CapabilityMetadata, Component, SchemaCapability } from './types.js export type FileToolName = keyof typeof fileParameters export type PtcToolName = keyof typeof ptcParameters // Preserve the published registration order independently of schema object key order. -const fileOrder = ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many'] as const satisfies readonly FileToolName[] +const fileOrder = ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read'] as const satisfies readonly FileToolName[] const fileMutations = new Set(['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) const allPresentations = Object.freeze(['native', 'ptc', 'both'] as const) diff --git a/src/kernel/errors.ts b/src/kernel/errors.ts index aa9ea6ba..bd489c6f 100644 --- a/src/kernel/errors.ts +++ b/src/kernel/errors.ts @@ -22,6 +22,8 @@ export const ERROR_POLICIES: Readonly> = Object.free OUTPUT_LIMIT: invalid, CROSS_DEVICE: invalid, ERR_INVALID_ENCODING: invalid, + TABLE_PARSE_ERROR: invalid, + FILE_CHANGED: changed, REFERENCE_EXPIRED: changed, AUTHORITY_CHANGED: changed, ACCESS_DENIED: changed, diff --git a/src/modes/native/documents.ts b/src/modes/native/documents.ts new file mode 100644 index 00000000..49551e53 --- /dev/null +++ b/src/modes/native/documents.ts @@ -0,0 +1,19 @@ +/** Native document rendering keeps safe parsed prefixes beside non-exhaustive coverage facts. */ +type Row = Record +const object = (value: unknown): value is Row => !!value && typeof value === 'object' && !Array.isArray(value) +const show = (value: unknown): string => (JSON.stringify(value) ?? 'null').replace(/[\u0085\u2028\u2029]/gu, char => '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0')) +const facts = (value: Row, keys: string[]): string => keys.filter(key => value[key] !== undefined).map(key => key + '=' + show(value[key])).join('; ') +export function renderDocument(_tool: string, args: Row, value: Row): string[] { + const items = Array.isArray(value.items) ? value.items.filter(object) : [] + const lines = ['=== DOCUMENT: ' + show(value.path ?? args.path ?? '(path unavailable)') + ' ===', + '[Observation: ' + facts(value, ['status', 'format', 'encoding', 'header', 'bytes', 'bytesRead', 'revision', 'columnCount']) + '.]', + '[Coverage: ' + facts(value, ['scanComplete', 'pageComplete', 'complete', 'rowsObserved', 'skipped', 'total', 'omitted', 'nextOffset', 'truncatedBy']) + '.]'] + if (Array.isArray(value.columns)) lines.push('Columns: ' + show(value.columns)) + if (object(value.error)) lines.push('Error: ' + facts(value.error, ['code', 'message', 'effectState', 'nextAction'])) + if (value.scanComplete === false || object(value.error)) lines.push('[Notice: This observation is not exhaustive. Only preceding complete records are shown; page offsets do not recover a physical scan gap.]') + for (const item of items) lines.push('Row ' + show(item.row) + ' (LF lines ' + show(item.lineStart) + '-' + show(item.lineEnd) + ') | ' + show(item.cells)) + if (!items.length) lines.push('(no records in this displayed range)') + lines.push('[Notice: displayedRecords=' + items.length + '. Scan, requested range, and displayed content have separate completeness.]') + if (typeof value.nextAction === 'string') lines.push('Next: ' + show(value.nextAction)) + return lines +} diff --git a/src/modes/native/render.ts b/src/modes/native/render.ts index 7f97434b..20ec692e 100644 --- a/src/modes/native/render.ts +++ b/src/modes/native/render.ts @@ -1,4 +1,6 @@ import { Buffer } from 'node:buffer' +import { renderDocument } from './documents.js' +import { isDocumentTool } from '../../capabilities/documents.js' import { renderResult } from './result.js' import { errorPolicy } from '../../kernel/errors.js' import type { EffectState } from '../../kernel/outcome.js' @@ -161,7 +163,8 @@ export function renderNative(tool: string, args: unknown, result: unknown): stri if (!record(result)) return capNativeText('[Notice: No structured result was returned.]') const input = record(args) ? args : {} let lines: string[] - if (record(result.error) || ['error', 'rejected', 'conflict', 'unknown', 'failed'].includes(String(result.status))) lines = failure(result) + if (isDocumentTool(tool)) lines = renderDocument(tool, input, result) + else if (record(result.error) || ['error', 'rejected', 'conflict', 'unknown', 'failed'].includes(String(result.status))) lines = failure(result) else if (tool === 'result') return capNativeText(renderResult(input, result), NATIVE_OUTPUT_BYTES, 'Repeat the same offset with smaller limit/maxBytes; do not advance past undisplayed rows.') else if (tool === 'read_many') lines = readMany(result) else if (tool === 'inspect_local_file') lines = inspect(input, result) @@ -175,7 +178,7 @@ export function renderNative(tool: string, args: unknown, result: unknown): stri if (record(result.storageError)) lines.unshift('[Storage: ' + facts(result.storageError, ['code', 'message', 'effectState']) + '. Cache effects are separate from business effects.]') return capNativeText(lines.join('\n'), NATIVE_OUTPUT_BYTES, mutations.has(tool) ? 'Inspect current state before another mutation; do not repeat the write to recover undisplayed receipts.' - : undefined) + : isDocumentTool(tool) ? 'Use the stored reference when available; otherwise repeat this range with a smaller limit or use a dedicated reader for a large record. Do not advance past undisplayed content.' : undefined) } function readMany(value: Row): string[] { diff --git a/src/modes/ptc/canonical-schema.ts b/src/modes/ptc/canonical-schema.ts index 00689dd4..8d90548c 100644 --- a/src/modes/ptc/canonical-schema.ts +++ b/src/modes/ptc/canonical-schema.ts @@ -1,5 +1,6 @@ /** Typed fields describe canonical data, independently of the Native display projection. */ import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import { DOCUMENT_FACTS, isDocumentTool } from '../../capabilities/documents.js' import { capability } from '../../capabilities/registry.js' import type { FileToolName, PtcToolName } from '../../capabilities/registry.js' import { STORAGE_REFERENCE } from '../../capabilities/result.js' @@ -27,6 +28,7 @@ export function canonicalOutput(tool: FileToolName | PtcToolName): JsonSchemaNod const entry = capability(tool) if (!entry || !('output' in entry)) throw new Error(`Missing canonical schema: ${tool}`) const properties: Record = { ...entry.output.properties, ...cache } + if (isDocumentTool(tool)) return { ...entry.output, properties } if (tool === 'inspect_local_file') Object.assign(properties, facts, { path: string, items: array(physicalLine) }) else if (tool === 'read_many') properties.items = array(object({ ...range, items: array(physicalLine) })) else if (tool === 'glob') properties.items = array({ oneOf: [string, object({ path: string, bytes: integer, modified: nullableInteger })] }) @@ -34,7 +36,7 @@ export function canonicalOutput(tool: FileToolName | PtcToolName): JsonSchemaNod else if (tool === 'stat') Object.assign(properties, facts, { exists: boolean, path: string, readonly: boolean, mode: string, modifiedMs: nullableInteger }) else if (tool === 'diff') Object.assign(properties, { before: object(facts), after: object(facts), hunk: object({ startLine: integer, removed: integer, added: integer }), items: array(object({ kind: string, line: integer, text: string })) }) - else if (tool === 'result') properties.source = object({ ...range, ...coverage, root: string, + else if (tool === 'result') properties.source = object({ ...range, ...coverage, ...DOCUMENT_FACTS, root: string, itemEncoding: string, capturedItems: integer, captureComplete: boolean }) else if (tool !== 'batch') properties.items = array(receipt) return { ...entry.output, properties } diff --git a/src/modes/ptc/continuation.ts b/src/modes/ptc/continuation.ts index 459aeffc..608b4e5d 100644 --- a/src/modes/ptc/continuation.ts +++ b/src/modes/ptc/continuation.ts @@ -2,7 +2,7 @@ import type { CanonicalData, JsonValue } from './outcome.js' const object = (value: unknown): CanonicalData | undefined => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as CanonicalData : undefined const integer = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0 -const readers = ['inspect_local_file', 'read_many', 'glob', 'grep', 'diff', 'result'] as const +const readers = ['inspect_local_file', 'read_many', 'glob', 'grep', 'diff', 'result', 'table_read'] as const export type ContinuationTool = typeof readers[number] export interface RetrievalContinuation { version: 1, tool: ContinuationTool, args: CanonicalData } const PREFIX = 'ops-next-v1:' @@ -53,6 +53,13 @@ export function continuationFor(tool: string, rawArgs: unknown, data: CanonicalD if ((tool === 'grep' || tool === 'glob') && data.scanComplete === true && typeof data.continuation === 'string') { return encode(tool, { ...args, continuation: data.continuation, ...(integer(data.nextOffset) ? { offset: data.nextOffset } : {}) }) } + if (tool === 'table_read') { + const offset = args.offset ?? 1 + return data.scanComplete === true && typeof data.revision === 'string' && /^[a-f0-9]{64}$/u.test(data.revision) + && integer(offset) && offset > 0 && integer(data.nextOffset) && data.nextOffset > offset + && integer(data.total) && data.nextOffset <= data.total + ? encode(tool, { ...args, offset: data.nextOffset }) : undefined + } if (tool === 'read_many') { const next = readMany(args, data) return next ? encode('read_many', next) : undefined diff --git a/test/capabilities.test.mjs b/test/capabilities.test.mjs index 4ac03c75..4ea6ed71 100644 --- a/test/capabilities.test.mjs +++ b/test/capabilities.test.mjs @@ -10,9 +10,9 @@ import { BASH_TOOL_NAME } from '../lib/shells/constants.js' import { assert, test, report } from './lib/harness.mjs' await test('the catalogue covers every owned file, PTC and shell tool exactly once', () => { - assert.equal(CAPABILITIES.length, 18) + assert.equal(CAPABILITIES.length, 19) assert.equal(new Set(CAPABILITIES.map(item => item.rawName)).size, CAPABILITIES.length) - assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many']) + assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read']) assert.deepEqual(FILE_MUTATIONS, ['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) assert.deepEqual(PTC_TOOLS, ['batch', 'result']) assert.deepEqual(IN_PROCESS_TOOLS, ['bash']) @@ -55,7 +55,7 @@ const snapshot = (names, definition) => names.map(name => { await test('business file and batch definitions retain their original serialized contracts', () => { // Captured before this migration, covering every argument/output contract and published description. const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') - assert.equal(hash(snapshot(FILE_TOOLS.filter(name => !['glob', 'grep', 'read_many', 'edit_many'].includes(name)), fileDefinition)), 'f32e33306b4052fdb3d19a11e0fe9d65c255a87d2d485de08a9d7b1275e958fc') + assert.equal(hash(snapshot(FILE_TOOLS.filter(name => !['glob', 'grep', 'read_many', 'edit_many', 'table_read'].includes(name)), fileDefinition)), 'f32e33306b4052fdb3d19a11e0fe9d65c255a87d2d485de08a9d7b1275e958fc') const batch = ptcDefinition('batch', async () => null) assert.equal(batch.parameters, ptcParameters.batch); assert.ok(batch.description.startsWith(ptcDescriptions.batch)); assert.match(batch.description, /PTC returns Outcome/) assert.ok(batch.output.schema.oneOf.every(branch => branch.properties.status)) diff --git a/test/lib/document-host.mjs b/test/lib/document-host.mjs new file mode 100644 index 00000000..8160fad5 --- /dev/null +++ b/test/lib/document-host.mjs @@ -0,0 +1,49 @@ +/** Real host composition shared by document capability adapters. */ +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import Sandbox from '@deepseek-ai/dsh-sandbox-local' +import FileSystem from '@deepseek-ai/dsh-fs-local' +import NodePtc from '@deepseek-ai/dsh-ptc-runtime-node' +import { createScope } from '@deepseek-ai/dsh-scope' +import { bootHost } from './host.mjs' +import { WORKER } from './boundary.mjs' +import { assert } from './harness.mjs' +import { publicToolName } from '../../lib/policy.js' +import * as discovery from '../../lib/discovery/index.js' +import * as ptc from '../../lib/ptc/index.js' +let serial = 0 +export async function documentHost(root, names, { real = false } = {}) { + const { ctx } = await bootHost() + const agents = []; ctx.provide('agents', { list: () => agents }) + await ctx.plugin(Subprocess); await ctx.plugin(Sandbox); await ctx.plugin(FileSystem) + await ctx.plugin(inner => { + inner.provide('sandboxPolicy', { resolve: ({ session } = {}) => ({ mode: session?.mode ?? 'workspace-write', workspaceRoot: root, sessionId: session?.id }) }) + if (!real) inner.provide('ptcRuntime', { language: 'typescript' }) + }) + if (real) await ctx.plugin(NodePtc, {}) + await ctx.plugin(discovery, { binaryPath: WORKER, required: true, allowedTools: names }) + await ctx.plugin(ptc, { binaryPath: WORKER, required: true, allowedTools: names.filter(name => !['result', 'inspect_local_file'].includes(name)) }) + async function agent(mode = 'native', { policy = 'workspace-write', cwd = root, parent } = {}) { + const id = 'document-host-' + ++serial + const value = { id, session: { id, mode: policy, header: { cwd }, append() {}, read() { return [] } } } + value.ctx = createScope(parent?.ctx ?? ctx, value, parent ? { parent } : undefined).ctx + await value.ctx.plugin({ inject: ['tools'], apply(inner) { inner.tools.presentAs(mode) } }) + agents.push(value); await ctx.serial('agent/created', { agent: value }); return value + } + return { ctx, agent } +} +export const documentStep = (ctx, agent) => ctx.waterfall('agent/pre-step', { agent, messages: [], turn: 1, step: ++serial, signal: new AbortController().signal }, async () => ({ kind: 'continue' })) +export const documentNames = (ctx, agent) => ctx.get('tools').schemas(agent).map(tool => tool.name) +export const documentCall = (ctx, agent, name, args) => ctx.get('tools').get(publicToolName(name), agent).execute(args, { agent, signal: new AbortController().signal }) +export async function activateDocument(ctx, agent, name, mode = 'native') { + const result = await documentCall(ctx, agent, mode === 'native' ? 'discover' : 'sdk', mode === 'native' ? { query: 'select:' + publicToolName(name) } : { tools: [name] }) + if (mode === 'native') assert.deepEqual(result.activated, [publicToolName(name)]) + else assert.equal(result.status, 'completed', JSON.stringify(result)) + assert.equal(documentNames(ctx, agent).includes(publicToolName(name)), false) + await documentStep(ctx, agent) + assert.ok(documentNames(ctx, agent).includes(publicToolName(name))) +} +export async function documentCode(ctx, agent, code) { + const result = await ctx.get('tools').execute({ name: 'run_code', callId: 'documents-' + ++serial, agent, signal: new AbortController().signal, + arguments: { description: 'Read document data through the scoped SDK', code } }) + assert.equal(result.isError, false, JSON.stringify(result)); return result.value.result +} diff --git a/test/table-host.test.mjs b/test/table-host.test.mjs new file mode 100644 index 00000000..7bfe90f5 --- /dev/null +++ b/test/table-host.test.mjs @@ -0,0 +1,120 @@ +/** Document tools share live authority while preserving separate Native/PTC projections. */ +import fs from 'node:fs' +import path from 'node:path' +import { assertSupportedJsonSchema, jsonSchemaToTs } from '@deepseek-ai/dsh-tools' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { shutdown } from './lib/host.mjs' +import { requireWorker } from './lib/boundary.mjs' +import { documentHost, documentNames, documentCall, documentCode, activateDocument } from './lib/document-host.mjs' +import { capability } from '../lib/capabilities/registry.js' +import { publicToolName } from '../lib/policy.js' +import { canonicalOutput } from '../lib/modes/ptc/canonical-schema.js' +import { decodeContinuation, continuationFor } from '../lib/modes/ptc/continuation.js' +import { definition } from '../lib/file/contract.js' +requireWorker('table-host') +const TOOL = publicToolName('table_read') +const rendered = (args, value) => definition('table_read', async () => value).output.render(args, value)[0].text +await test('table catalogue is discoverable, read-only, strict and generates lexical row types', () => { + const entry = capability('table_read') + assert.equal(entry.availability, 'discoverable'); assert.equal(entry.mutates, false); assert.equal(entry.concurrencySafe, true) + assert.equal(entry.parameters.additionalProperties, false); assert.deepEqual(entry.parameters.required, ['path']) + for (const schema of [entry.parameters, canonicalOutput('table_read'), canonicalOutput('result')]) assertSupportedJsonSchema(schema) + const schema = canonicalOutput('table_read') + assert.equal(schema.properties.items.items.properties.cells.items.type, 'string') + assert.equal(schema.properties.source.properties.rowsObserved.type, 'integer') + assert.equal(canonicalOutput('result').properties.source.properties.rowsObserved.type, 'integer') + assert.match(jsonSchemaToTs(schema), /cells: string\[\]/) + const common = { scanComplete: true, revision: 'a'.repeat(64), nextOffset: 2, total: 3 } + assert.equal(decodeContinuation(continuationFor('table_read', { path: 'x' }, common)).args.offset, 2) + for (const change of [{ scanComplete: false }, { revision: undefined }, { nextOffset: 1 }, { nextOffset: 4 }]) { + assert.equal(continuationFor('table_read', { path: 'x' }, { ...common, ...change }), undefined) + } +}) +await test('Native table activation is next-step and owner-only; relative cwd and positional records survive rendering', () => withTempDir('table-native', async root => { + const cwd = path.join(root, 'nested'); fs.mkdirSync(cwd) + fs.writeFileSync(path.join(cwd, 'data.csv'), 'name,name,\n"中,文","two\nlines",0001\n') + const { ctx, agent } = await documentHost(root, ['table_read']) + try { + const owner = await agent('native', { cwd }), sibling = await agent(), child = await agent('native', { parent: owner }) + assert.equal(documentNames(ctx, owner).includes(TOOL), false) + const assembly = await ctx.get('systemPrompt').assemble({ scope: owner }) + assert.equal(assembly.tools.some(tool => tool.name === TOOL), false) + await activateDocument(ctx, owner, 'table_read') + for (const other of [sibling, child]) assert.equal(documentNames(ctx, other).includes(TOOL), false) + const data = await documentCall(ctx, owner, 'table_read', { path: 'data.csv' }) + assert.equal(data.status, 'completed', JSON.stringify(data)); assert.equal(data.complete, true) + assert.deepEqual(data.columns, ['name', 'name', '']); assert.deepEqual(data.items[0].cells, ['中,文', 'two\nlines', '0001']) + assert.equal(data.path, path.join(cwd, 'data.csv')) + const text = rendered({ path: 'data.csv' }, data) + assert.match(text, /Row 1 \(LF lines 2-3\)/); assert.match(text, /"two\\nlines"/); assert.match(text, /scanComplete=true/) + assert.doesNotMatch(text, /Mutation|receipt/) + } finally { await shutdown(ctx) } +})) +await test('real PTC SDK returns typed lexical rows and only full stable scans supply forward page cursors', () => withTempDir('table-ptc', async root => { + fs.writeFileSync(path.join(root, 'rows.tsv'), 'id\tvalue\n0001\tTRUE\n0002\tFALSE\n0003\tnull\n') + const { ctx, agent } = await documentHost(root, ['table_read'], { real: true }) + try { + const owner = await agent('ptc'), sibling = await agent('ptc') + await activateDocument(ctx, owner, 'table_read', 'ptc') + assert.equal(documentNames(ctx, sibling).includes(TOOL), false) + const first = await documentCode(ctx, owner, `return await tools.${TOOL}({path:'rows.tsv',limit:1});`) + assert.equal(first.status, 'partial', JSON.stringify(first)); assert.equal(first.data.scanComplete, true) + assert.deepEqual(first.data.items[0].cells, ['0001', 'TRUE']); assert.equal(first.data.total, 3) + const next = decodeContinuation(first.continuation) + assert.deepEqual(next, { version: 1, tool: 'table_read', args: { path: 'rows.tsv', limit: 1, offset: 2 } }) + const second = await documentCall(ctx, owner, next.tool, next.args) + assert.equal(second.status, 'partial'); assert.deepEqual(second.data.items[0].cells, ['0002', 'FALSE']) + assert.equal(second.data.revision, first.data.revision) + const tail = await documentCall(ctx, owner, 'table_read', { path: 'rows.tsv', offset: 3, limit: 1 }) + assert.equal(tail.status, 'completed'); assert.equal(tail.data.complete, true) + } finally { await shutdown(ctx) } +})) +await test('malformed late records retain safe prefix data in both projections without resumable scan-gap advice', () => withTempDir('table-gap-host', async root => { + fs.writeFileSync(path.join(root, 'bad.csv'), 'name\nsafe\n"unterminated') + const { ctx, agent } = await documentHost(root, ['table_read']) + try { + const native = await agent(), code = await agent('ptc') + await activateDocument(ctx, native, 'table_read'); await activateDocument(ctx, code, 'table_read', 'ptc') + const raw = await documentCall(ctx, native, 'table_read', { path: 'bad.csv' }) + const text = rendered({ path: 'bad.csv' }, raw) + assert.match(text, /TABLE_PARSE_ERROR/); assert.match(text, /Row 1.*"safe"/); assert.match(text, /not exhaustive/) + assert.equal(raw.scanComplete, false); assert.equal(raw.total, undefined) + const typed = await documentCall(ctx, code, 'table_read', { path: 'bad.csv' }) + assert.equal(typed.status, 'error'); assert.equal(typed.error.code, 'TABLE_PARSE_ERROR'); assert.equal(typed.error.effectState, 'none') + assert.equal(typed.error.retry, 'after-input-change'); assert.equal(typed.continuation, undefined) + assert.deepEqual(typed.data.items[0].cells, ['safe']); assert.equal(typed.data.revision, undefined) + } finally { await shutdown(ctx) } +})) +await test('read-only table calls create no files and current credentials remain denied', () => withTempDir('table-authority-host', async root => { + fs.writeFileSync(path.join(root, 'ok.csv'), 'key\nvalue\n'); fs.mkdirSync(path.join(root, '.ssh')); fs.writeFileSync(path.join(root, '.ssh', 'id_ed25519.csv'), 'SECRET') + const before = fs.readdirSync(root, { recursive: true }).sort() + const { ctx, agent } = await documentHost(root, ['table_read']) + try { + const owner = await agent('native', { policy: 'read-only' }); await activateDocument(ctx, owner, 'table_read') + const ok = await documentCall(ctx, owner, 'table_read', { path: 'ok.csv' }) + assert.equal(ok.complete, true); assert.deepEqual(ok.items[0].cells, ['value']) + const denied = await documentCall(ctx, owner, 'table_read', { path: '.ssh/id_ed25519.csv' }) + assert.equal(denied.error.code, 'CREDENTIAL_DENIED'); assert.equal(denied.error.effectState, 'none'); assert.doesNotMatch(JSON.stringify(denied), /SECRET/) + const invalid = await documentCall(ctx, owner, 'table_read', { path: 'ok.csv', limit: 0 }) + assert.equal(invalid.error.code, 'INVALID_ARGUMENT') + assert.deepEqual(fs.readdirSync(root, { recursive: true }).sort(), before) + } finally { await shutdown(ctx) } +})) +await test('large Native table rows spill with original coverage and recoverable item metadata', () => withTempDir('table-spill-host', async root => { + fs.writeFileSync(path.join(root, 'big.csv'), 'value\n' + '😀'.repeat(40000) + '\n') + const { ctx, agent } = await documentHost(root, ['table_read', 'result']) + try { + const owner = await agent(); await activateDocument(ctx, owner, 'table_read'); await activateDocument(ctx, owner, 'result') + const data = await documentCall(ctx, owner, 'table_read', { path: 'big.csv' }) + assert.equal(data.reference.producerTool, 'table_read'); assert.equal(data.scanComplete, true) + assert.equal(data.complete, false); assert.equal(data.rowsObserved, 1) + assert.equal(fs.realpathSync.native(data.referenceRoot), fs.realpathSync.native(root)) + const content = [{ type: 'text', text: rendered({ path: 'big.csv' }, data) }] + assert.ok(Buffer.byteLength(JSON.stringify(content)) <= 65536); assert.match(content[0].text, /ref:\/\//) + const page = await documentCall(ctx, owner, 'result', { action: 'page', id: data.reference.refId, root: data.referenceRoot, limit: 1 }) + assert.equal(page.source.rowsObserved, 1); assert.equal(page.source.scanComplete, true) + assert.equal(page.source.captureComplete, true); assert.equal(page.source.capturedItems, 1) + assert.equal(page.items[0].kind, 'item-json-chunk'); assert.equal(page.items[0].index, 0); assert.equal(page.items[0].byteOffset, 0) + } finally { await shutdown(ctx) } +})) +report('table-host') diff --git a/test/types/ptc-sdk.ts b/test/types/ptc-sdk.ts index dbb4bc39..61f76c46 100644 --- a/test/types/ptc-sdk.ts +++ b/test/types/ptc-sdk.ts @@ -39,3 +39,18 @@ void tools.ops_edit_many({ path: 'a.ts', edits: [{ oldText: 'a', newText: 'b' }] void tools.ops_unknown_tool({}) const completed: Outcome = { status: 'completed', data: undefined } void [read, metadata, completed] + +async function tables() { + const result = await tools.ops_table_read({ path: 'rows.csv', header: false, encoding: 'utf-16le' }) + if (result.status === 'completed' || result.status === 'partial') { + const cells: string[] | undefined = result.data.items?.[0]?.cells + const rows: number | undefined = result.data.source?.rowsObserved + // @ts-expect-error Cells preserve lexical text instead of guessing numbers. + const numeric: number[] | undefined = result.data.items?.[0]?.cells + return { cells, rows } + } + return result.data?.items?.[0]?.cells +} +// @ts-expect-error Encoding is a finite strict-decoding contract. +void tools.ops_table_read({ path: 'rows.csv', encoding: 'latin1' }) +void tables From 2f4845b04c3c369a6781d8a78358ddca19d36011 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:45:09 +0800 Subject: [PATCH 082/118] docs(documents): state table failure value semantics --- src/capabilities/documents.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/capabilities/documents.ts b/src/capabilities/documents.ts index a296d568..6453c6a9 100644 --- a/src/capabilities/documents.ts +++ b/src/capabilities/documents.ts @@ -18,7 +18,7 @@ export const DOCUMENT_PARAMETERS = Object.freeze({ }, ['path']), }) export const DOCUMENT_DESCRIPTIONS = Object.freeze({ - table_read: 'Read bounded CSV/TSV lexical string cells relative to session cwd. Preserves quoted multiline records and physical LF lineStart/lineEnd. Check scanComplete versus pageComplete; total/revision exist only after a stable full scan. Up to 32 MiB, 100000 data records, 1 MiB/record, 1024 columns and 30s; row paging cannot bypass scan gaps. Errors retain safe preceding rows with nextAction. Compare revisions before combining rescanned pages. No spreadsheet formulas or type coercion.', + table_read: 'Read bounded CSV/TSV lexical string cells relative to session cwd. Preserves quoted multiline records and physical LF lineStart/lineEnd. Check scanComplete versus pageComplete; total/revision exist only after a stable full scan. Up to 32 MiB, 100000 data records, 1 MiB/record, 1024 columns and 30s; row paging cannot bypass scan gaps. Errors retain safe preceding rows with nextAction. Compare revisions before combining rescanned pages. No spreadsheet formulas or type coercion. Failures return status:error|rejected and error.code; never throw.', }) export const DOCUMENT_FACTS: Record = { format: string, encoding: string, header: boolean, columns: strings, columnCount: integer, From b39b25c6ca1a0ce6adf092f4d3fc9e2b18fa56a0 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:38:42 +0800 Subject: [PATCH 083/118] refactor(types): type panel collection and persisted records --- docs/CHANGELOG.md | 5 + docs/CONTRIBUTING.md | 2 + ...41\347\214\256\346\214\207\345\215\227.md" | 2 + src/panel/{collect.js => collect.ts} | 232 ++++++++++-------- src/panel/types.ts | 59 +++++ test/panel-records.test.mjs | 70 ++++++ test/types/panel-collector.ts | 30 +++ test/types/tsconfig.json | 3 +- 8 files changed, 294 insertions(+), 109 deletions(-) rename src/panel/{collect.js => collect.ts} (74%) create mode 100644 src/panel/types.ts create mode 100644 test/panel-records.test.mjs create mode 100644 test/types/panel-collector.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e28c1dea..8873b8cb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -38,6 +38,11 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add an opt-in offline settings-carrier migration with exact host composition checks, original-file backups, atomic writes and recovery. The normal upgrade retains legacy file-row names and settings. - 新增可选离线设置承载行迁移:精确验证宿主配置合成结果,备份原文件,原子写入并支持恢复。常规升级仍保留旧文件行名称与配置。 +### Type panel capture and persisted records (#261, 16/16) + +- Migrate panel collection to strict TypeScript while preserving queued-stop, issued-I/O waiting and explicit sealing. Count malformed persisted fields and mark incomplete capture without corrupting valid text or usage totals. +- 将面板采集迁移为严格 TypeScript,保留排队停止、等待已发起 I/O 与显式封存;错误的持久化字段计入 malformed 并标记不完整,不破坏有效正文或用量总计。 + - Add discovery as the seventh bundle row; preserve existing named row overrides. Fresh defaults enable shell only on Windows and leave panel/TUI/replacements off. Enabling a fresh panel row starts capture; explicit legacy capture:false stays inert. Provide builtin validated file/PTC/TUI configuration. - bundle 新增第七行 discovery,保留已有带名称行覆盖。首次安装仅 Windows 默认启用 shell,panel/TUI/替换默认关闭;启用新 panel 行即采集,旧配置 capture:false 保持不采集。补充文件/PTC/TUI 内置校验配置。 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 0ff1bbeb..8e42a022 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -215,3 +215,5 @@ The optional carrier regression also uses development-only `@deepseek-ai/dsh-cli `0.2.0-rc.2` (published 2026-09-29) to assert actual client registry activation, teardown and restart against temporary installed profiles. `settings-carrier` covers exact composition, backups and atomic rollback; it never migrates a developer profile. + +`src/panel/collect.ts` uses typed filesystem seams and validates persisted event fields before folding. Valid capture documents keep their text, counters and archive behavior. Invalid persisted text/usage/timestamp fields are counted as malformed and mark the result degraded, with incomplete usage totals; they cannot be silently coerced. Stopping still cancels queued work, waits for issued I/O and retains the append log; explicit sealing remains separate. Run `node test/run.mjs ops-panel-collect panel-records panel-stop kernel-types` for recovery, bounded data, physical-stop and type checks. diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 9f489697..9b568bb7 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -147,3 +147,5 @@ React 由宿主提供。精确锁定且仅用于开发的 React 18.3.1、React 可选承载行回归测试还使用仅开发依赖 `@deepseek-ai/dsh-client-modules` `0.2.0-rc.2`(2026-09-29 发布),在临时安装 profile 中验证真实客户端注册表的启用、 卸载与重启。`settings-carrier` 覆盖精确配置合成、备份和原子回滚,不迁移开发者实际 profile。 + +`src/panel/collect.ts` 使用类型化文件系统接口,并在折叠前验证持久化事件字段。有效采集文档保留正文、计数和归档行为;错误的正文、用量或时间字段计入 malformed,结果标记为降级且用量不完整,不能静默转换。停止仍会取消排队工作、等待已发起 I/O 并保留追加日志,显式封存保持独立。运行 `node test/run.mjs ops-panel-collect panel-records panel-stop kernel-types` 验证恢复、数据边界、物理停止和类型契约。 diff --git a/src/panel/collect.js b/src/panel/collect.ts similarity index 74% rename from src/panel/collect.js rename to src/panel/collect.ts index 688e4094..18cf96cf 100644 --- a/src/panel/collect.js +++ b/src/panel/collect.ts @@ -30,6 +30,8 @@ import { createHash } from 'node:crypto' import { dshHome } from '../paths.js' import { renderPanelHtml } from './render.js' import { activityGate } from './activity.js' +import { object, field, finite, foldEvent, missingFile } from './types.js' +import type { Row, PanelConfig, PanelPaths, TextField, CoreBucket, UsageBucket, Usage, CaptureEvent, ArchiveMember, FoldedEvents, SessionState, FoldContext, CollectorOptions } from './types.js' /** Document format tag; a reader must reject a tag it does not know. */ export const PANEL_FORMAT = 'dsh-ops/panel-capture@1' @@ -38,7 +40,7 @@ export const PANEL_DIR = path.join('.dsh-ops', 'panel') /** The viewer file name inside {@link PANEL_DIR}. */ export const PANEL_HTML = 'panel.html' /** Recognised row keys and their defaults; `capture` is the user-facing switch. */ -export const PANEL_DEFAULTS = Object.freeze({ capture: true, textLimit: 2_000, maxSessionBytes: 8 * 1024 * 1024 }) +export const PANEL_DEFAULTS: PanelConfig = Object.freeze({ capture: true, textLimit: 2_000, maxSessionBytes: 8 * 1024 * 1024 }) // View guards: a pathological session must not turn the trace into the document. const TRACE_LIMIT = 20_000 @@ -48,21 +50,21 @@ const TOOL_NAME_LIMIT = 64 /** Characters of a tool name line kept in a trace preview. */ const PREVIEW_CHARS = 120 /** The four core usage buckets in `bench/run.schema.json` order, and the optional ones. */ -const USAGE_BUCKETS = Object.freeze(['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']) -const OPTIONAL_BUCKETS = Object.freeze(['reasoningTokens', 'totalTokens']) +const USAGE_BUCKETS: readonly CoreBucket[] = Object.freeze(['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']) +const OPTIONAL_BUCKETS: readonly UsageBucket[] = Object.freeze(['reasoningTokens', 'totalTokens']) /** Error raised for a panel configuration value this component cannot honour. */ export class PanelConfigError extends Error { /** @param {string} message - what is wrong and what is accepted. */ - constructor(message) { + constructor(message: string) { super(`dsh-ops panel config: ${message}`) this.name = 'PanelConfigError' } } /** Validate one row's config, applying every default; unknown or out-of-range values fail loud. */ -export function resolvePanelConfig(options = {}) { - if (options === null || typeof options !== 'object' || Array.isArray(options)) { +export function resolvePanelConfig(options: unknown = {}): PanelConfig { + if (!object(options) || Array.isArray(options)) { throw new PanelConfigError(`config must be an object, got ${JSON.stringify(options)}`) } const accepted = Object.keys(PANEL_DEFAULTS) @@ -75,14 +77,14 @@ export function resolvePanelConfig(options = {}) { if (typeof config.capture !== 'boolean') { throw new PanelConfigError(`capture must be a boolean, got ${JSON.stringify(config.capture)}`) } - if (!Number.isSafeInteger(config.textLimit) || config.textLimit < 64 || config.textLimit > 100_000) { + if (typeof config.textLimit !== 'number' || !Number.isSafeInteger(config.textLimit) || config.textLimit < 64 || config.textLimit > 100_000) { throw new PanelConfigError(`textLimit must be an integer from 64 to 100000, got ${JSON.stringify(config.textLimit)}`) } - if (!Number.isSafeInteger(config.maxSessionBytes) + if (typeof config.maxSessionBytes !== 'number' || !Number.isSafeInteger(config.maxSessionBytes) || config.maxSessionBytes < 64 * 1024 || config.maxSessionBytes > 256 * 1024 * 1024) { throw new PanelConfigError(`maxSessionBytes must be an integer from 65536 to 268435456, got ${JSON.stringify(config.maxSessionBytes)}`) } - return Object.freeze(config) + return Object.freeze({ capture: config.capture, textLimit: config.textLimit, maxSessionBytes: config.maxSessionBytes }) } /** @@ -91,12 +93,12 @@ export function resolvePanelConfig(options = {}) { * @param {unknown} value - the candidate id. * @returns {string|undefined} the id, or undefined when it cannot be a file name. */ -export function safeSessionId(value) { +export function safeSessionId(value: unknown): string | undefined { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value) ? value : undefined } /** Whether `child` is `parent` or lies below it, on resolved paths. */ -function within(child, parent) { +function within(child: string, parent: string): boolean { const relative = path.relative(parent, child) return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) } @@ -105,7 +107,7 @@ function within(child, parent) { * Resolve the three panel paths for one session, or refuse. * @returns {{dir: string, data: string, wal: string, html: string, sessionId: string}|undefined} the paths. */ -export function panelPaths(cwd, sessionId, options = {}) { +export function panelPaths(cwd: unknown, sessionId: unknown, options: { env?: NodeJS.ProcessEnv } = {}): PanelPaths | undefined { const id = safeSessionId(sessionId) if (typeof cwd !== 'string' || cwd === '' || !path.isAbsolute(cwd) || id === undefined) return undefined const root = path.resolve(cwd) @@ -120,39 +122,40 @@ export function panelPaths(cwd, sessionId, options = {}) { } /** Clamp one text field, naming what was dropped so a reader can tell truncation apart. */ -function clamp(value, limit) { +function clamp(value: unknown, limit: number) { const full = typeof value === 'string' ? value : '' if (full.length <= limit) return { text: full, chars: full.length, truncated: false, droppedChars: 0 } return { text: full.slice(0, limit), chars: full.length, truncated: true, droppedChars: full.length - limit } } /** Non-negative safe integer, or undefined. */ -const integer = (value) => (Number.isSafeInteger(value) && value >= 0 ? value : undefined) +const integer = (value: unknown) => (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined) /** Non-empty string, or undefined. */ -const string = (value) => (typeof value === 'string' && value !== '' ? value : undefined) +const string = (value: unknown) => (typeof value === 'string' && value !== '' ? value : undefined) /** Message content blocks of one type, in order; anything unrecognised is skipped. */ -function blocks(message, type) { - const content = message?.content +function blocks(message: unknown, type: string): Row[] { + const content = field(message, 'content') if (!Array.isArray(content)) return [] - return content.filter((block) => block !== null && typeof block === 'object' && block.type === type) + return content.filter((block: unknown): block is Row => object(block) && block.type === type) } /** Concatenate the text of one content-block type, and name the tool calls, in order. */ -function joined(message, type) { +function joined(message: unknown, type: string): string { return blocks(message, type).filter((block) => typeof block.text === 'string').map((block) => block.text).join('\n') } -function toolNames(message) { - return blocks(message, 'tool-call').filter((block) => typeof block.name === 'string').map((block) => block.name) +function toolNames(message: unknown): string[] { + return blocks(message, 'tool-call').flatMap(block => typeof block.name === 'string' ? [block.name] : []) } /** Project one usage report onto the core buckets plus the optional ones actually sent. */ -function projectUsage(usage) { - if (usage === null || typeof usage !== 'object') return { reported: false, usage: {}, buckets: [] } - const projected = {} - const buckets = [] +function projectUsage(usage: unknown) { + if (!object(usage)) return { reported: false, usage: {}, buckets: [] } + const projected: Usage = {} + const buckets: UsageBucket[] = [] for (const key of [...USAGE_BUCKETS, ...OPTIONAL_BUCKETS]) { - if (Number.isFinite(usage[key]) && usage[key] >= 0) { projected[key] = Math.round(usage[key]); buckets.push(key) } + const value = usage[key] + if (finite(value) && value >= 0) { projected[key] = Math.round(value); buckets.push(key) } } return { reported: USAGE_BUCKETS.every((key) => key in projected), usage: projected, buckets } } @@ -163,18 +166,18 @@ function projectUsage(usage) { * up in the document instead of vanishing. * @returns {object|undefined} the projected record, or undefined when it is unusable. */ -export function projectEvent(event, config = PANEL_DEFAULTS) { - if (event === null || typeof event !== 'object') return undefined +export function projectEvent(event: unknown, config: PanelConfig = PANEL_DEFAULTS): CaptureEvent | undefined { + if (!object(event)) return undefined const type = string(event.type) const seq = integer(event.seq) if (type === undefined || seq === undefined) return undefined - const time = Number.isFinite(event.time) && event.time >= 0 ? event.time : 0 + const time = finite(event.time) && event.time >= 0 ? event.time : 0 const base = { seq, time, type } const data = event.data - if (data === null || typeof data !== 'object') return base + if (!object(data)) return base switch (type) { case 'user/message': - return { ...base, role: 'user', source: string(data.source?.kind), payload: clamp(joined(data, 'text'), config.textLimit) } + return { ...base, role: 'user', source: string(field(data.source, 'kind')), payload: clamp(joined(data, 'text'), config.textLimit) } case 'assistant/message': { const usage = projectUsage(data.usage) return { ...base, role: 'assistant', turn: integer(data.turn), step: integer(data.step), @@ -190,21 +193,21 @@ export function projectEvent(event, config = PANEL_DEFAULTS) { } case 'tool/result': return { - ...base, callId: string(data.message?.source?.callId) ?? string(data.message?.toolCallId), - isError: data.message?.isError === true, errorCode: string(data.error?.code), step: integer(data.step), + ...base, callId: string(field(field(data.message, 'source'), 'callId')) ?? string(field(data.message, 'toolCallId')), + isError: field(data.message, 'isError') === true, errorCode: string(field(data.error, 'code')), step: integer(data.step), payload: clamp(joined(data.message, 'text'), config.textLimit), } case 'turn/start': case 'turn/end': case 'step/start': case 'step/end': return { ...base, turn: integer(data.turn), step: integer(data.step), - reason: string(data.reason?.kind) ?? string(data.reason?.message), + reason: string(field(data.reason, 'kind')) ?? string(field(data.reason, 'message')), } default: return base } } /** Byte length, so the append-log budget stays honest on multi-byte text. */ -const bytes = (text) => Buffer.byteLength(text, 'utf8') +const bytes = (text: string) => Buffer.byteLength(text, 'utf8') /** * Read one NDJSON append log, counting lines that are not objects instead of @@ -212,14 +215,14 @@ const bytes = (text) => Buffer.byteLength(text, 'utf8') * @param {string} text - the file contents. * @returns {{records: object[], malformed: number}} the parsed records. */ -export function parseAppendLog(text) { - const records = [] +export function parseAppendLog(text: string): { records: Row[]; malformed: number } { + const records: Row[] = [] let malformed = 0 for (const line of text.split('\n')) { if (line.trim() === '') continue try { - const parsed = JSON.parse(line) - if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) records.push(parsed) + const parsed: unknown = JSON.parse(line) + if (object(parsed) && !Array.isArray(parsed)) records.push(parsed) else malformed += 1 } catch { malformed += 1 } } @@ -227,9 +230,9 @@ export function parseAppendLog(text) { } /** A fresh four-bucket usage total. */ -const emptyUsage = () => ({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, grandTotal: 0 }) +const emptyUsage = (): Record & { grandTotal: number; reasoningTokens?: number } => ({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, grandTotal: 0 }) /** One conversation row: the fields every captured message shares. */ -function messageRow(record, role) { +function messageRow(record: CaptureEvent, role: string) { const payload = record.payload ?? {} return { seq: record.seq, time: record.time, at: new Date(record.time).toISOString(), role, @@ -240,7 +243,7 @@ function messageRow(record, role) { } /** One trace row, in the lane vocabulary the panel draws. */ -function traceRow(record, lane, kind, extra = {}) { +function traceRow(record: CaptureEvent, lane: string, kind: string, extra: Row = {}) { return { seq: record.seq, time: record.time, at: new Date(record.time).toISOString(), lane, kind, ...extra } } @@ -250,55 +253,57 @@ function traceRow(record, lane, kind, extra = {}) { * @param {{config?: object, meta?: object, droppedEvents?: number, malformed?: number}} [context] - fold inputs. * @returns {object} the panel document. */ -export function foldRecords(records, context = {}) { +export function foldRecords(records: readonly unknown[], context: FoldContext = {}) { const config = context.config ?? PANEL_DEFAULTS const supplied = { ...(context.meta ?? {}) } - const conversation = [] - const trace = [] - const pending = new Map() + const conversation: Row[] = [] + const trace: Row[] = [] + const pending = new Map() const usage = emptyUsage() - const bucketNames = new Set() - const eventTypes = {} - const toolCallsByName = {} + const bucketNames = new Set() + const eventTypes: Record = {} + const toolCallsByName: Record = {} const counters = { turns: 0, steps: 0, stepsWithUsage: 0, toolCalls: 0, toolErrors: 0, textChars: 0, thinkingChars: 0, eventCount: 0, usageReported: true, } - let sessionId - let cwd - let startedAt - let endedAt - let turnEndReason + let sessionId: string | undefined + let cwd: string | undefined + let startedAt: number | undefined + let endedAt: number | undefined + let turnEndReason: string | undefined let capped = false let conversationTruncated = false let traceTruncated = false let droppedChars = 0 + let malformedRecords = 0 - const mark = (ms) => { + const mark = (ms: number) => { if (!Number.isFinite(ms) || ms <= 0) return if (startedAt === undefined || ms < startedAt) startedAt = ms if (endedAt === undefined || ms > endedAt) endedAt = ms } - const pushConversation = (row) => { + const pushConversation = (row: Row) => { if (conversation.length >= CONVERSATION_LIMIT) conversationTruncated = true else conversation.push(row) } - const pushTrace = (row) => { + const pushTrace = (row: Row) => { if (trace.length >= TRACE_LIMIT) traceTruncated = true else trace.push(row) } - const preview = (value) => String(value ?? '').replace(/\s+/gu, ' ').slice(0, PREVIEW_CHARS) - const dropped = (field) => { if (field?.truncated === true) droppedChars += field.droppedChars ?? 0 } + const preview = (value: unknown) => String(value ?? '').replace(/\s+/gu, ' ').slice(0, PREVIEW_CHARS) + const dropped = (field: TextField | null | undefined) => { if (field?.truncated === true) droppedChars += field.droppedChars ?? 0 } for (const record of records) { - if (record === null || typeof record !== 'object' || Array.isArray(record)) continue + if (!object(record) || Array.isArray(record)) continue if (record.record === 'open') { sessionId = string(record.sessionId) ?? sessionId cwd = string(record.cwd) ?? cwd continue } if (record.record === 'capped') { capped = true; continue } - if (record.record !== 'event' || integer(record.seq) === undefined) continue + if (record.record !== 'event') continue + if (!foldEvent(record)) { malformedRecords += 1; counters.usageReported = false; continue } const time = Number.isFinite(record.time) && record.time >= 0 ? record.time : 0 counters.eventCount += 1 eventTypes[record.type] = (eventTypes[record.type] ?? 0) + 1 @@ -319,8 +324,8 @@ export function foldRecords(records, context = {}) { if (record.usageReported === true) { counters.stepsWithUsage += 1 for (const bucket of Array.isArray(record.usageBuckets) ? record.usageBuckets : []) bucketNames.add(bucket) - for (const key of USAGE_BUCKETS) usage[key] += Number.isFinite(record.usage?.[key]) ? record.usage[key] : 0 - if (Number.isFinite(record.usage?.reasoningTokens)) usage.reasoningTokens = (usage.reasoningTokens ?? 0) + record.usage.reasoningTokens + for (const key of USAGE_BUCKETS) usage[key] += record.usage?.[key] ?? 0 + if (finite(record.usage?.reasoningTokens)) usage.reasoningTokens = (usage.reasoningTokens ?? 0) + record.usage.reasoningTokens } else counters.usageReported = false pushConversation({ ...messageRow(record, 'assistant'), reasoning: record.reasoning?.text ?? '', toolCalls: calls, interrupted: record.interrupted === true }) pushTrace(traceRow(record, 'milestone', record.interrupted === true ? 'assistant_interrupted' : 'assistant_message', { @@ -376,7 +381,10 @@ export function foldRecords(records, context = {}) { } } usage.grandTotal = USAGE_BUCKETS.reduce((total, key) => total + usage[key], 0) - const at = (ms) => (ms === undefined ? undefined : new Date(ms).toISOString()) + const at = (ms: number | undefined) => (ms === undefined ? undefined : new Date(ms).toISOString()) + const events: FoldedEvents = { layout: 'merged', merged: true, mergedAt: undefined, + records: counters.eventCount, malformed: (context.malformed ?? 0) + malformedRecords, types: eventTypes, files: [] } + const archive: ArchiveMember[] = [] return { schemaVersion: 1, format: PANEL_FORMAT, @@ -390,20 +398,20 @@ export function foldRecords(records, context = {}) { format: PANEL_FORMAT, sessionId, startedAt: at(startedAt), endedAt: at(endedAt), durationMs: startedAt === undefined || endedAt === undefined ? 0 : Math.max(0, endedAt - startedAt), ...counters, toolCallsByName, usage, usageBucketsSeen: [...bucketNames].sort(), - turnEndReason, malformed: context.malformed ?? 0, + turnEndReason, malformed: (context.malformed ?? 0) + malformedRecords, }, conversation, trace, verify: { checks: 0, passed: 0, failed: 0, - degraded: capped || conversationTruncated || traceTruncated, + degraded: capped || conversationTruncated || traceTruncated || malformedRecords > 0, notes: [ '实时采集没有 verifier:「判卷」标签页只反映采集自身的完整性,不代表任何任务的通过情况。', '只有挂载之后产生的事件会被采集;挂载之前的历史与已恢复的会话前缀不回放。', ], }, - events: { layout: 'merged', merged: true, mergedAt: undefined, records: counters.eventCount, malformed: context.malformed ?? 0, types: eventTypes, files: [] }, - archive: [], + events, + archive, limits: { textLimit: config.textLimit ?? PANEL_DEFAULTS.textLimit, maxSessionBytes: config.maxSessionBytes ?? PANEL_DEFAULTS.maxSessionBytes, truncated: capped, droppedEvents: context.droppedEvents ?? 0, droppedChars, @@ -417,7 +425,7 @@ export function foldRecords(records, context = {}) { * Metrics and usage totals are never dropped: they stay the complete accounting. * @returns {{text: string, bytes: number, trimmedRows: number}} the fitted text. */ -export function fitDocument(document, cap) { +export function fitDocument(document: ReturnType, cap: number) { let text = JSON.stringify(document) let size = bytes(text) let trimmedRows = 0 @@ -441,62 +449,70 @@ export function fitDocument(document, cap) { * @param {object} [options] - `config`, `io`, `env`, `now`, `report` and `meta` seams. * Returns the collector for this activation. */ -export function createPanelCollector(options = {}) { +export function createPanelCollector(options: CollectorOptions = {}) { const config = options.config ?? PANEL_DEFAULTS const storage = options.io ?? fsp const activity = activityGate() // Fence each filesystem operation, including continuations after an awaited // read/write. Queued folds cannot start fresh writes once the row is stopped. - const io = Object.fromEntries(['readFile', 'mkdir', 'appendFile', 'writeFile', 'rename', 'stat', 'rm'] - .map(method => [method, (...args) => activity.run(() => storage[method](...args))])) + const io = { + readText: (file: string) => activity.run(() => storage.readFile(file, 'utf8')), + readFile: (file: string) => activity.run(() => storage.readFile(file)), + mkdir: (dir: string, options: { recursive: true }) => activity.run(() => storage.mkdir(dir, options)), + appendFile: (file: string, data: string) => activity.run(() => storage.appendFile(file, data)), + writeFile: (file: string, data: string) => activity.run(() => storage.writeFile(file, data)), + rename: (from: string, to: string) => activity.run(() => storage.rename(from, to)), + stat: (file: string) => activity.run(() => storage.stat(file)), + rm: (file: string, options: { force: true }) => activity.run(() => storage.rm(file, options)), + } const env = options.env ?? process.env const now = options.now ?? (() => Date.now()) const report = typeof options.report === 'function' ? options.report : () => {} const meta = options.meta ?? {} - const sessions = new Map() - const noted = new Set() + const sessions = new Map() + const noted = new Set() let disposed = false - let stopping + let stopping: Promise | undefined // Say something at most once per kind; reporting must never escape either. - function note(key, message, level = 'warn') { + function note(key: string, message: string, level = 'warn') { if (noted.has(key)) return noted.add(key) try { report(message, level) } catch { /* a logger failure is not the session's problem */ } } - function fail(key, error) { + function fail(key: string, error: unknown) { if (activity.cancelled(error)) return const reason = error instanceof Error ? error.message : String(error) note(key, `dsh-ops panel: capture failed (${key}): ${reason}; this session continues uncaptured.`, 'warn') } /** Read the append log's first record and size, or undefined when it is unusable. */ - async function readAppendLog(wal) { + async function readAppendLog(wal: string) { let text - try { text = await io.readFile(wal, 'utf8') } catch (error) { - if (error?.code === 'ENOENT') return undefined + try { text = await io.readText(wal) } catch (error) { + if (missingFile(error)) return undefined throw error } if (text.trim() === '') return undefined - let header + let header: Row | undefined try { - const parsed = JSON.parse(text.slice(0, text.indexOf('\n') === -1 ? text.length : text.indexOf('\n'))) - if (parsed?.record === 'open') header = parsed + const parsed: unknown = JSON.parse(text.slice(0, text.indexOf('\n') === -1 ? text.length : text.indexOf('\n'))) + if (object(parsed) && parsed.record === 'open') header = parsed } catch { /* a corrupted header is counted as malformed by the fold */ } return { header, size: bytes(text) } } /** Open (or resume) one session's append log; refuse with one log line, never a throw. */ - function open(sessionId, cwd) { + function open(sessionId: string, cwd: unknown): SessionState | undefined { const existing = sessions.get(sessionId) if (existing !== undefined) return existing const paths = panelPaths(cwd, sessionId, { env }) - if (paths === undefined) { + if (paths === undefined || typeof cwd !== 'string') { note(`path:${String(cwd)}`, `dsh-ops panel: not capturing session ${JSON.stringify(sessionId)}: ` + 'the session needs an absolute working directory outside the DSH home and a file-name-safe id.', 'warn') return undefined } - const state = { + const state: SessionState = { sessionId, paths, size: 0, droppedEvents: 0, capped: false, closed: false, header: undefined, chain: Promise.resolve(), } @@ -519,7 +535,7 @@ export function createPanelCollector(options = {}) { } /** Append one record, or count it as dropped once the session budget is spent. */ - function append(state, record) { + function append(state: SessionState, record: CaptureEvent & { record: string }) { if (state.closed) return let line try { line = `${JSON.stringify(record)}\n` } catch (error) { fail('serialize', error); return } @@ -543,11 +559,11 @@ export function createPanelCollector(options = {}) { } /** Write `panel.html` when it is absent or stale, and describe it for the archive. */ - async function writeViewer(state) { + async function writeViewer(state: SessionState) { const html = renderPanelHtml() let current - try { current = await io.readFile(state.paths.html, 'utf8') } catch (error) { - if (error?.code !== 'ENOENT') throw error + try { current = await io.readText(state.paths.html) } catch (error) { + if (!missingFile(error)) throw error } if (current !== html) { await io.mkdir(state.paths.dir, { recursive: true }) @@ -558,29 +574,29 @@ export function createPanelCollector(options = {}) { } /** Stat and hash one archive member, or undefined when it is not on disk. */ - async function describe(file, relative, role) { + async function describe(file: string, relative: string, role: string): Promise { try { const [info, contents] = await Promise.all([io.stat(file), io.readFile(file)]) return { path: relative, bytes: info.size, sha256: createHash('sha256').update(contents).digest('hex'), role } } catch (error) { - if (error?.code === 'ENOENT') return undefined + if (missingFile(error)) return undefined throw error } } /** Fold the append log into the JSON document and refresh the viewer. */ - async function materialise(state, final) { + async function materialise(state: SessionState, final: boolean): Promise { const { paths } = state let text = '' - try { text = await io.readFile(paths.wal, 'utf8') } catch (error) { - if (error?.code !== 'ENOENT') throw error + try { text = await io.readText(paths.wal) } catch (error) { + if (!missingFile(error)) throw error } const parsed = parseAppendLog(text) const document = foldRecords(parsed.records, { config, meta, droppedEvents: state.droppedEvents, malformed: parsed.malformed }) document.events.mergedAt = new Date(now()).toISOString() let viewer try { viewer = await writeViewer(state) } catch (error) { fail('viewer', error) } - const archive = [] + const archive: ArchiveMember[] = [] if (!final) { const log = await describe(paths.wal, `${state.sessionId}.ndjson`, 'events') if (log !== undefined) { @@ -598,7 +614,7 @@ export function createPanelCollector(options = {}) { } /** Queue a fold behind every append already scheduled for this session. */ - function merge(sessionId, final = false) { + function merge(sessionId: string, final = false): Promise { const state = sessions.get(sessionId) if (state === undefined) return Promise.resolve(undefined) state.chain = state.chain @@ -610,30 +626,30 @@ export function createPanelCollector(options = {}) { const collector = { config, /** Observe one committed session event; never throws, never rejects. */ - observe(session, event) { + observe(session: unknown, event: unknown): void { if (disposed) return try { - const sessionId = safeSessionId(session?.id) + const sessionId = safeSessionId(field(session, 'id')) if (sessionId === undefined) return - const state = open(sessionId, session?.header?.cwd ?? session?.cwd) + const state = open(sessionId, field(field(session, 'header'), 'cwd') ?? field(session, 'cwd')) if (state === undefined) return const projected = projectEvent(event, config) if (projected === undefined) return append(state, { record: 'event', ...projected }) // A turn end is the idle point: fold then, so the JSON is current without // a timer and without waiting for the session to be disposed. - if (event.type === 'turn/end') void merge(sessionId) + if (projected.type === 'turn/end') void merge(sessionId) } catch (error) { fail('observe', error) } }, /** Wait for every append scheduled for one session (or all of them). */ - flush(sessionId) { - const targets = sessionId === undefined ? [...sessions.values()] : [sessions.get(sessionId)].filter(Boolean) + flush(sessionId?: string): Promise { + const targets = sessionId === undefined ? [...sessions.values()] : [sessions.get(sessionId)].filter((state): state is SessionState => state !== undefined) return Promise.all(targets.map((state) => state.chain)).then(() => undefined) }, /** Fold one session's append log into its JSON document. */ - merge(sessionId) { return merge(sessionId, false) }, + merge(sessionId: string): Promise { return merge(sessionId, false) }, /** Seal one session: final fold that drops the append log. */ - close(sessionId) { + close(sessionId: string): Promise { const state = sessions.get(sessionId) if (state === undefined || state.closed) return Promise.resolve(undefined) state.closed = true @@ -641,7 +657,7 @@ export function createPanelCollector(options = {}) { }, /** Stop collection without starting a final fold. Await all issued I/O before * the loader reports this row disabled; retained append logs remain recoverable. */ - stop() { + stop(): Promise { if (stopping) return stopping disposed = true activity.stop() @@ -649,13 +665,13 @@ export function createPanelCollector(options = {}) { return stopping }, /** Explicitly seal every session. Component teardown uses stop() instead. */ - dispose() { + dispose(): Promise { if (disposed) return Promise.resolve(undefined) disposed = true return Promise.all([...sessions.keys()].map((sessionId) => collector.close(sessionId))).then(() => undefined) }, /** The paths one captured session writes to, for callers that must report them. */ - pathsFor(sessionId) { return sessions.get(sessionId)?.paths }, + pathsFor(sessionId: string): PanelPaths | undefined { return sessions.get(sessionId)?.paths }, /** How many sessions this activation has captured. */ get size() { return sessions.size }, } diff --git a/src/panel/types.ts b/src/panel/types.ts new file mode 100644 index 00000000..7c0019eb --- /dev/null +++ b/src/panel/types.ts @@ -0,0 +1,59 @@ +/** Persisted capture boundaries. JSON remains unknown until the fields used by the fold are checked. */ +import type fsp from 'node:fs/promises' +export type Row = Record +export interface PanelConfig { readonly capture: boolean; readonly textLimit: number; readonly maxSessionBytes: number } +export interface PanelPaths { dir: string; data: string; wal: string; html: string; sessionId: string } +export interface TextField { text?: string | null; chars?: number | null; truncated?: unknown; droppedChars?: number | null } +export type CoreBucket = 'inputTokens' | 'outputTokens' | 'cacheReadTokens' | 'cacheWriteTokens' +export type UsageBucket = CoreBucket | 'reasoningTokens' | 'totalTokens' +export type Usage = Partial> +export interface CaptureEvent { + seq: number; time: number; type: string; role?: string; source?: unknown; turn?: unknown; step?: unknown + callId?: string; name?: string; reason?: string; errorCode?: string; interrupted?: unknown; isError?: unknown + payload?: TextField | null; reasoning?: TextField | null; arguments?: TextField | null + toolCalls?: unknown[]; usage?: Usage | null; usageReported?: unknown; usageBuckets?: string[] +} +export interface ArchiveMember { path: string; bytes: number; sha256: string; role: string } +export interface EventFile { path: string; events: number; sha256: string } +export interface FoldedEvents { + layout: 'merged'; merged: boolean; mergedAt?: string; records: number; malformed: number + types: Record; files: EventFile[] +} +export interface SessionState { + sessionId: string; paths: PanelPaths; size: number; droppedEvents: number; capped: boolean; closed: boolean + header?: Row; chain: Promise +} +export interface PanelMeta { pluginVersion?: unknown; platform?: unknown; node?: unknown; argv?: unknown } +export interface FoldContext { config?: PanelConfig; meta?: PanelMeta; droppedEvents?: number; malformed?: number } +export type PanelIO = Pick +export interface CollectorOptions { + config?: PanelConfig; io?: PanelIO; env?: NodeJS.ProcessEnv; now?: () => number + report?: (message: string, level: string) => void; meta?: PanelMeta +} +export const object = (value: unknown): value is Row => value !== null && typeof value === 'object' +export const field = (value: unknown, key: string): unknown => object(value) ? value[key] : undefined +export const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) +const optionalString = (value: unknown): boolean => value === undefined || typeof value === 'string' +function textField(value: unknown): value is TextField | null | undefined { + if (value == null) return true + if (!object(value) || Array.isArray(value)) return false + return (value.text == null || typeof value.text === 'string') + && (value.chars == null || finite(value.chars)) && (value.droppedChars == null || finite(value.droppedChars)) +} +/** Invalid persisted fields are counted as malformed; never coerce them into text or token totals. */ +export function foldEvent(value: Row): value is Row & CaptureEvent { + if (typeof value.type !== 'string' || !value.type || typeof value.seq !== 'number' || !Number.isSafeInteger(value.seq) || value.seq < 0 + || !finite(value.time) || value.time < 0 || value.time > 8_640_000_000_000_000) return false + if (!['role', 'callId', 'name', 'reason', 'errorCode'].every(key => optionalString(value[key]))) return false + if (![value.payload, value.reasoning, value.arguments].every(textField)) return false + if (value.toolCalls !== undefined && !Array.isArray(value.toolCalls)) return false + if (value.usageBuckets !== undefined && (!Array.isArray(value.usageBuckets) || !value.usageBuckets.every(item => typeof item === 'string'))) return false + if (value.usage != null) { + if (!object(value.usage) || Array.isArray(value.usage)) return false + for (const key of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens', 'totalTokens']) { + if (value.usage[key] !== undefined && !finite(value.usage[key])) return false + } + } + return true +} +export const missingFile = (error: unknown): boolean => field(error, 'code') === 'ENOENT' diff --git a/test/panel-records.test.mjs b/test/panel-records.test.mjs new file mode 100644 index 00000000..cf86cf0f --- /dev/null +++ b/test/panel-records.test.mjs @@ -0,0 +1,70 @@ +/** Persisted JSON is untrusted: malformed field shapes must not corrupt text or totals. */ +import fs from 'node:fs' +import path from 'node:path' +import { tmpdir } from 'node:os' +import { createPanelCollector, foldRecords, parseAppendLog, projectEvent } from '../lib/panel/collect.js' +import { assert, report, test, withTempDir } from './lib/harness.mjs' +const message = { record: 'event', seq: 0, time: 1, type: 'assistant/message', + payload: { text: 'kept exactly', chars: 12, truncated: false, droppedChars: 0 }, + usageReported: true, usageBuckets: ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'], + usage: { inputTokens: 3, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0 } } + +await test('invalid persisted fields are counted and marked degraded without coercing text or usage', () => { + const broken = [ + { ...message, seq: 1, payload: { text: { hidden: 'must not stringify' }, chars: 3 } }, + { ...message, seq: 2, payload: { text: 'bad count', chars: '9000' } }, + { ...message, seq: 3, usage: { inputTokens: '6000' } }, + { ...message, seq: 4, time: 9e15 }, + ] + const parsed = parseAppendLog([...broken, message].map(value => JSON.stringify(value)).join('\n') + '\nnull\n[]\nnot json\n') + assert.deepEqual(parsed.records, [...broken, message], 'parsing preserves object values exactly') + assert.equal(parsed.malformed, 3) + const document = foldRecords(parsed.records, { malformed: parsed.malformed }) + assert.equal(document.events.malformed, 7) + assert.equal(document.metrics.malformed, 7) + assert.equal(document.metrics.eventCount, 1) + assert.equal(document.verify.degraded, true) + assert.equal(document.metrics.usageReported, false, 'omitted malformed events cannot imply complete totals') + assert.equal(document.metrics.usage.grandTotal, 5) + assert.equal(document.metrics.textChars, 12) + assert.deepEqual(document.conversation.map(row => row.text), ['kept exactly']) +}) + +await test('valid projected records retain partial usage, new event types and nullable text defaults', () => { + const event = projectEvent({ type: 'assistant/message', seq: 0, time: 1, data: { + message: { content: [{ type: 'text', text: 'first' }, null, { type: 'text', text: 'second' }] }, + usage: { inputTokens: 1.4, outputTokens: 2.6, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 3.6 }, + } }) + assert.equal(event.payload.text, 'first\nsecond') + const records = [{ record: 'event', ...event }, { record: 'event', seq: 1, time: 2, type: 'future/event', future: { kept: true } }, + { record: 'event', seq: 2, time: 3, type: 'user/message', payload: { text: null, chars: null } }] + const document = foldRecords(parseAppendLog(records.map(value => JSON.stringify(value)).join('\n')).records) + assert.equal(document.events.malformed, 0) + assert.equal(document.verify.degraded, false) + assert.equal(document.events.types['future/event'], 1) + assert.equal(document.metrics.eventCount, 3) + assert.deepEqual(document.metrics.usage, { inputTokens: 1, outputTokens: 3, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 4, grandTotal: 4 }) + assert.deepEqual(document.conversation.map(row => row.text), ['first\nsecond', '']) +}) + +await test('a malformed persisted event cannot prevent valid crash recovery or explicit sealing', async () => { + await withTempDir('panel-typed-recovery', async cwd => { + const dir = path.join(cwd, '.dsh-ops', 'panel'), wal = path.join(dir, 'typed.ndjson') + fs.mkdirSync(dir, { recursive: true }) + const records = [{ record: 'open', sessionId: 'typed', cwd }, { ...message, seq: 1, time: 9e15 }, message] + fs.writeFileSync(wal, records.map(value => JSON.stringify(value)).join('\n') + '\n') + const collector = createPanelCollector({ env: { DSH_HOME: path.join(tmpdir(), 'panel-typed-separate-home') } }) + collector.observe({ id: 'typed', header: { cwd } }, { type: 'turn/end', seq: 2, time: 3, data: {} }) + await collector.flush() + const document = JSON.parse(fs.readFileSync(path.join(dir, 'typed.json'), 'utf8')) + assert.equal(document.metrics.malformed, 1) + assert.equal(document.verify.degraded, true) + assert.deepEqual(document.conversation.map(row => row.text), ['kept exactly']) + assert.equal(fs.existsSync(wal), true) + await collector.close('typed') + assert.equal(fs.existsSync(wal), false) + assert.equal(collector.size, 0) + await collector.stop() + }) +}) +report('panel-records') diff --git a/test/types/panel-collector.ts b/test/types/panel-collector.ts new file mode 100644 index 00000000..d098e6bc --- /dev/null +++ b/test/types/panel-collector.ts @@ -0,0 +1,30 @@ +import fs from 'node:fs' +import { createPanelCollector, fitDocument, foldRecords, parseAppendLog, projectEvent, resolvePanelConfig } from '../../src/panel/collect.js' +import type { CaptureEvent, PanelIO } from '../../src/panel/types.js' + +declare const raw: unknown +const config = resolvePanelConfig(raw) +const io: PanelIO = fs.promises +const collector = createPanelCollector({ config, io }) +collector.observe(raw, raw) +const stopped: Promise = collector.stop() +const projected = projectEvent(raw) +if (projected) { + const seq: number = projected.seq + void seq +} +const parsed = parseAppendLog('{}\n') +// @ts-expect-error JSON object properties remain unknown until validated. +const unsafeCount: number = parsed.records[0]!.count +// @ts-expect-error A configured byte budget must be numeric. +createPanelCollector({ config: { capture: true, textLimit: 64, maxSessionBytes: '65536' } }) +// @ts-expect-error Configuration is immutable after validation. +config.capture = false +// @ts-expect-error Lifecycle identities are strings. +collector.close(12) +// @ts-expect-error A persisted event's usage totals must be numbers. +const badEvent: CaptureEvent = { seq: 0, time: 1, type: 'assistant/message', usage: { inputTokens: '4' } } +// @ts-expect-error An arbitrary object is not a folded panel document. +fitDocument({}, 65536) +fitDocument(foldRecords(parsed.records), 65536) +void [stopped, unsafeCount, badEvent] diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json index f665d0db..c00b15f3 100644 --- a/test/types/tsconfig.json +++ b/test/types/tsconfig.json @@ -11,6 +11,7 @@ "tui.ts", "authority.ts", "profile-temp.ts", - "host-entry.ts" + "host-entry.ts", + "panel-collector.ts" ] } From 151e9d2661305e90741354eb3abb95ad4f403d98 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:51:39 +0800 Subject: [PATCH 084/118] feat(documents): read bounded DOCX main-body paragraphs --- NOTICE | 6 + docs/CHANGELOG.md | 8 + docs/document-dependencies.md | 51 +++ docs/native-kernel.md | 40 ++ ...37\347\224\237\345\206\205\346\240\270.md" | 30 ++ ...43\346\236\220\344\276\235\350\265\226.md" | 40 ++ licenses/documents/THIRD-PARTY-NOTICES.txt | 313 ++++++++++++++ native/Cargo.lock | 95 +++++ native/crates/ops-documents/Cargo.toml | 2 + native/crates/ops-documents/src/archive.rs | 163 ++++++++ native/crates/ops-documents/src/docx.rs | 386 ++++++++++++++++++ native/crates/ops-documents/src/lib.rs | 4 + native/crates/ops-documents/src/xml.rs | 173 ++++++++ native/crates/ops-results/src/spill.rs | 2 +- native/src/lib.rs | 2 +- native/src/main.rs | 2 +- test/docx-worker.test.mjs | 152 +++++++ test/lib/document-fixture.mjs | 29 ++ test/package-source.test.mjs | 4 +- 19 files changed, 1498 insertions(+), 4 deletions(-) create mode 100644 docs/document-dependencies.md create mode 100644 "docs/\346\226\207\346\241\243\350\247\243\346\236\220\344\276\235\350\265\226.md" create mode 100644 licenses/documents/THIRD-PARTY-NOTICES.txt create mode 100644 native/crates/ops-documents/src/archive.rs create mode 100644 native/crates/ops-documents/src/docx.rs create mode 100644 native/crates/ops-documents/src/xml.rs create mode 100644 test/docx-worker.test.mjs create mode 100644 test/lib/document-fixture.mjs diff --git a/NOTICE b/NOTICE index ec456ec5..bbf8db23 100644 --- a/NOTICE +++ b/NOTICE @@ -101,3 +101,9 @@ DeepSeek Harness shell contract, approval and job adapters are adapted from @deepseek-ai/dsh-tool-bash 0.2.0-rc.2 (MIT), Copyright (c) 2026 DeepSeek. Upstream: https://github.com/deepseek-ai/deepseek-harness License: licenses/deepseek-harness/LICENSE + +The native worker's DOCX reader uses pinned third-party Rust ZIP/XML libraries. +Their exact versions and dependency rationale are recorded in docs/document-dependencies.md; +original license notices (MIT choices for dual-licensed packages, plus zlib-rs's Zlib license) +are reproduced in licenses/documents/THIRD-PARTY-NOTICES.txt. Existing upstream notices above +remain unchanged. No third-party code is relicensed by the plugin's own MIT grant. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f248e1f0..6b1e84b6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Add bounded DOCX main-body worker observations (#261, 15b/16) + +- Read strict DOCX main-body paragraphs and table coordinates with explicit excluded-content + scope, bounded ZIP/XML processing, honest partial results and lossless reference spill. +- Pin ZIP/XML dependencies with defaults disabled; preserve their original license notices and + exact release-date review. Legacy DOC, unsupported content and external resolution are refused. +- 新增有界 DOCX 正文段落/表格坐标读取,明确排除内容及部分扫描;固定经日期核验的 ZIP/XML + 依赖并保留许可,拒绝旧版 DOC 与不支持内容,复用权限、nofollow 和无损引用恢复。 ### Add bounded CSV/TSV worker observations (#261, 15a/16) diff --git a/docs/document-dependencies.md b/docs/document-dependencies.md new file mode 100644 index 00000000..114ddff8 --- /dev/null +++ b/docs/document-dependencies.md @@ -0,0 +1,51 @@ +# Document parser dependency review + +The 15b/16 DOCX worker uses format libraries only after byte/count preflight. It adds no +host dependencies, process execution, external fetches, archive extraction, OCR, or Office +integration. `zip = "=9.0.1"` disables defaults and enables only +`deflate-flate2-zlib-rs`; `quick-xml = "=0.42.0"` disables defaults. These libraries provide +CRC-checked ZIP decompression and namespace-aware XML parsing without implementing those +formats again. Every resolved addition is locked; verification date: 2026-10-19 UTC+08. + +| Locked package | Published UTC | Purpose | License | +| --- | --- | --- | --- | +| zip 9.0.1 | 2026-10-10 06:10:55 | Bounded ZIP entry access | MIT | +| quick-xml 0.42.0 | 2026-08-22 19:22:01 | XML events and namespaces | MIT | +| crc32fast 1.5.2 | 2026-09-12 18:41:46 | ZIP CRC validation | MIT OR Apache-2.0 | +| flate2 1.1.10 | 2026-08-28 04:35:22 | Deflate decoding | MIT OR Apache-2.0 | +| zlib-rs 0.6.8 | 2026-09-15 13:10:00 | Rust deflate implementation | Zlib | +| indexmap 2.14.2 | 2026-09-05 01:53:48 | ZIP metadata index | Apache-2.0 OR MIT | +| hashbrown 0.17.1 | 2026-05-09 04:35:04 | Index storage | MIT OR Apache-2.0 | +| equivalent 1.0.2 | 2025-02-14 22:14:18 | Index key comparison | Apache-2.0 OR MIT | +| typed-path 0.12.3 | 2026-02-11 02:30:37 | ZIP path representation | MIT OR Apache-2.0 | +| nix 0.31.3 | 2026-05-11 07:01:18 | ZIP macOS/BSD target dependency | MIT | +| cfg_aliases 0.2.2 | 2026-07-16 14:57:16 | nix build configuration | MIT | + +Publication dates and non-yanked status were checked against the publisher's +[crates.io version API](https://crates.io/api/v1/crates/zip/9.0.1) for each exact version; +replace the final name/version in that URL to reproduce another row. All are at least seven +days old. Cargo first selected equivalent 1.0.3 (2026-10-13); the lockfile deliberately uses +1.0.2 because 1.0.3 had not reached seven days. Do not update that lock entry casually. +Original license texts are in `licenses/documents/THIRD-PARTY-NOTICES.txt`; MIT is selected +where an alternative license is offered. Existing dependencies are unchanged. + +The checked upstream security fixes include quick-xml namespace allocation/attribute parsing +fixes present since 0.41 and ZIP extraction fixes present since 9.0.0. See the primary +[RustSec XML allocation advisory](https://rustsec.org/advisories/RUSTSEC-2026-0195.html), +[XML attribute advisory](https://rustsec.org/advisories/RUSTSEC-2026-0194.html), and +[ZIP advisory](https://rustsec.org/advisories/RUSTSEC-2026-0367.html). This is a targeted +source/version review, not a claim that a full dependency audit has found no vulnerabilities. +This reader never calls archive extraction APIs. + +The worker rejects ZIP64/multi-disk/encrypted archives; validates exact central-directory +bounds/counts, unique names, local-header agreement and nonoverlapping payloads before +the ZIP constructor; alternate EOCD/ZIP64 footer signatures are refused anywhere in the archive +(including coincidental payload bytes) because constructor recovery could reinterpret them. +The archive offset is fixed at zero. The reader then reads only fixed OPC metadata and word/document.xml into capped +buffers. Deflate output is stopped at the actual decoded limit plus one byte, independent +of advertised sizes; complete reads verify CRC. XML disables DTD/custom entities, checks +UTF-8 or BOM-marked UTF-16 without replacement, and bounds depth, attributes and namespace +bindings. Unsupported content yields explicit errors or partial observations. + +PDF parsing has a separate follow-up review. This slice neither adds a PDF dependency nor +claims PDF support; the table/DOCX readers are not substitutes for the PDF benchmark. diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 0e555b2a..258e1639 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -181,3 +181,43 @@ Malformed/changed input and physical scan limits retain prefix data and recovery continuation that would repeat the same gap. Native reference pages and PTC result pages preserve original table coverage separately from stored-row pagination. Shared admission, current authority, credential checks, cancellation and storage bindings apply unchanged. + +## Bounded DOCX main-body observations (15b/16) + +`doc_read` is a worker-only capability in this slice; later host/discovery integration remains +separate. Arguments are `{path, offset?, limit?}`: path must be absolute after host resolution +against current session cwd; offset is a 1-based paragraph index (1-100000, default 1), limit is +1-1000 (default 200). Each item is `{block,type:"paragraph",text}`; table paragraphs additionally +carry 1-based table/row/cell coordinates. Empty paragraphs are retained. Tabs, line breaks, +Unicode, entity references and lexical spaces are preserved. Formatting and rendered layout, +headers/footers, footnotes/endnotes, comments and package metadata are explicitly excluded in +`scope:"main-body-paragraphs"` and `excludedContent`. A complete result describes that scope, +not the full rendered document. There is no Office automation or external relationship fetch. + +The subset requires a standard internal word/document.xml main part, stored/deflated ZIP, +strict UTF-8 or BOM-marked UTF-16 and recognized transitional/strict Word namespaces. Binary DOC, +macros, encrypted/multi-disk/ZIP64 archives, nested tables, body drawings, fields, revisions, +foreign/unknown body content and malformed structures are explicitly refused. Ambiguous ZIP footer +signatures anywhere in the raw archive are conservatively refused before constructor recovery. A late content +failure returns preceding complete paragraphs with `status:partial`, `scanComplete:false`, no +full total/revision, and recovery guidance. XML never resolves DTD/custom/external entities. + +Bounds apply before library allocation/decompression: archive 32 MiB; central directory 1 MiB +and 4096 entries; each OPC metadata part 1 MiB; main XML 8 MiB actual decoded bytes; one paragraph +1 MiB UTF-8; 100000 observed paragraphs; XML depth 128, attributes 64 per element, namespace +bindings 512 in scope; 30 seconds between parse events plus the transport watchdog. Each fixed +XML part is CRC-checked after bounded decompression. ZIP local/central headers, uniqueness and +nonoverlap are preflighted; nothing is extracted to disk. Selected pages alone are retained. + +`blocksObserved`, `scanComplete`, `pageComplete` and `complete` separate scan coverage, requested +page coverage and remaining paragraphs. `total`, raw-file SHA-256 `revision` and `omitted` are +available only after a stable complete scan; `nextOffset` is always 1-based. Paging rescans the +same file, so callers must compare revisions before combining changing observations. It does +not bypass physical scan limits. The existing spill sink captures requested full paragraphs +before inline clipping, including long Unicode text. Reference pages retain scope/coverage and +captureComplete; their own complete flag describes stored-record pagination. Use referenceRoot +for paging. Reads repeat authorization/no-follow/credential checks and create no artifacts in +read-only sessions; transport or cache failures never imply a business mutation. + +Exact dependency versions, release dates, rationale and original notices are recorded in +[document-dependencies.md](document-dependencies.md). diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index c17331e4..eb1cb269 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -146,3 +146,33 @@ Native 用带物理 LF 行范围的原位置 JSON 单元格展示,并明确提 须比较 revision。非法输入、文件变化和物理扫描上限保留前缀数据与恢复提示,不提供会重复 原缺口的续取。Native/PTC 引用页均区分原表格覆盖率与已存记录分页;既有排队、实时权限、 凭据检查、取消与存储绑定继续适用。 + +## 有界 DOCX 正文观察(15b/16) + +本切片仅在 worker 暴露 `doc_read`,宿主与发现注册另行接入。参数 `{path, offset?, limit?}`: +path 由宿主按当前会话 cwd 解析为绝对路径;offset 为从 1 开始的段落序号(1-100000,默认 1), +limit 为 1-1000(默认 200)。每项为 `{block,type:"paragraph",text}`;表格内段落另有从 1 开始的 + table/row/cell 坐标。保留空段落、制表符、换行、Unicode、实体字符及原文本空白。明确排除格式与 +渲染布局、页眉页脚、脚注尾注、批注和包元数据;`scope:"main-body-paragraphs"` 与 +excludedContent 显示该范围。complete 仅表示该范围完整,不表示整个渲染文档;无 Office 自动化或 +外部关系抓取。 + +支持标准内部 word/document.xml 正文、存储/Deflate ZIP、严格 UTF-8 或 BOM 标记 UTF-16,以及 +过渡/严格 Word 命名空间。二进制 DOC、宏、加密/多磁盘/ZIP64、嵌套表格、正文绘图、域、修订、 +未知/外部命名空间内容和结构错误均明确拒绝。整个原包中的歧义 ZIP 尾记录签名字节也会在构造器恢复 +逻辑之前保守拒绝。后段失败保留此前完整段落,返回 partial、 +scanComplete:false、恢复指引,不给出全量 total/revision。XML 不解析 DTD 或自定义/外部实体。 + +限制在库分配/解压之前施加:原包 32 MiB;中央目录 1 MiB/4096 条目;每个 OPC 元数据部分 1 MiB; +正文 XML 实际解码字节 8 MiB;单段落 UTF-8 1 MiB;100000 段;XML 深度 128、每元素 64 属性、 +作用域内 512 命名空间绑定;解析事件之间 30 秒期限并有传输 watchdog。固定 XML 部分有界解压后 +完整校验 CRC;预检 ZIP 本地/中央头一致性、唯一性及不重叠;不解压到磁盘,仅保留所请求的段落页。 + +blocksObserved、scanComplete、pageComplete 和 complete 分别描述已观察段落、扫描、所请求页和 +剩余段落。仅稳定完整扫描后才有 total、原包 SHA-256 revision 及 omitted;nextOffset 从 1 开始。 +分页会重新扫描同一文件,合并观察前需比较 revision;分页不能绕过物理扫描上限。既有 spill 在内联 +裁剪前捕获请求的完整段落,包括长 Unicode 文本。引用页保留范围、覆盖率与 captureComplete;页本身 +complete 仅表示存储记录分页结束。按 referenceRoot 读取。每次读取重复权限、nofollow 与凭据检查; +只读会话不创建产物,传输或缓存失败不暗示业务写入。 + +精确依赖、发布日期、理由与原始许可记录见[文档解析依赖.md](文档解析依赖.md)。 diff --git "a/docs/\346\226\207\346\241\243\350\247\243\346\236\220\344\276\235\350\265\226.md" "b/docs/\346\226\207\346\241\243\350\247\243\346\236\220\344\276\235\350\265\226.md" new file mode 100644 index 00000000..e961b593 --- /dev/null +++ "b/docs/\346\226\207\346\241\243\350\247\243\346\236\220\344\276\235\350\265\226.md" @@ -0,0 +1,40 @@ +# 文档解析依赖审查 + +15b/16 DOCX worker 仅在字节数、条目数预检后调用格式库。不增加宿主依赖、进程执行、外部请求、 +解压到磁盘、OCR 或 Office 集成。zip 精确固定为 9.0.1、关闭默认功能,只开启 +`deflate-flate2-zlib-rs`;quick-xml 精确固定为 0.42.0、关闭默认功能。分别负责 CRC 校验的 +ZIP 解压及 XML 命名空间事件解析,避免重新实现通用格式。核验日期:2026-10-19(UTC+08)。 + +| 锁定包 | 发布时间(UTC) | 用途 | 许可证 | +| --- | --- | --- | --- | +| zip 9.0.1 | 2026-10-10 06:10:55 | 有界 ZIP 条目访问 | MIT | +| quick-xml 0.42.0 | 2026-08-22 19:22:01 | XML 事件及命名空间 | MIT | +| crc32fast 1.5.2 | 2026-09-12 18:41:46 | ZIP CRC 校验 | MIT OR Apache-2.0 | +| flate2 1.1.10 | 2026-08-28 04:35:22 | Deflate 解码 | MIT OR Apache-2.0 | +| zlib-rs 0.6.8 | 2026-09-15 13:10:00 | Rust Deflate 实现 | Zlib | +| indexmap 2.14.2 | 2026-09-05 01:53:48 | ZIP 元数据索引 | Apache-2.0 OR MIT | +| hashbrown 0.17.1 | 2026-05-09 04:35:04 | 索引存储 | MIT OR Apache-2.0 | +| equivalent 1.0.2 | 2025-02-14 22:14:18 | 索引键比较 | Apache-2.0 OR MIT | +| typed-path 0.12.3 | 2026-02-11 02:30:37 | ZIP 路径表示 | MIT OR Apache-2.0 | +| nix 0.31.3 | 2026-05-11 07:01:18 | ZIP 的 macOS/BSD 平台依赖 | MIT | +| cfg_aliases 0.2.2 | 2026-07-16 14:57:16 | nix 构建配置 | MIT | + +精确版本的日期及未撤回状态均通过发布者的 +[crates.io 版本 API](https://crates.io/api/v1/crates/zip/9.0.1) 核验;将 URL 末尾的包名和版本替换 +即可复查其他行。所有新增版本均已发布至少七天。Cargo 最初解析到 equivalent 1.0.3(10 月 13 日), +因不足七天而将锁文件固定为 1.0.2;不要随意更新。原始许可文本保存在 +`licenses/documents/THIRD-PARTY-NOTICES.txt`,双许可包选择 MIT;既有依赖不变。 + +审查包含 quick-xml 0.41 起修复的命名空间分配与属性解析问题,以及 ZIP 9.0.0 起的解压 API 修复。 +原始公告:[XML 分配](https://rustsec.org/advisories/RUSTSEC-2026-0195.html)、 +[XML 属性](https://rustsec.org/advisories/RUSTSEC-2026-0194.html)、 +[ZIP](https://rustsec.org/advisories/RUSTSEC-2026-0367.html)。这是针对性源码和版本审查, +并不声明完整依赖审计无漏洞。worker 从不调用解压到文件系统的 API。 + +在调用 ZIP 构造器之前,worker 拒绝 ZIP64、多磁盘和加密包,验证中央目录的精确边界、 +数量、唯一名称、本地头一致性与非重叠载荷;仅以限定大小缓冲读取固定 OPC 元数据和 +word/document.xml。解压以实际输出字节数限额加一为界,不信任声明长度;完整读取校验 CRC。 +XML 禁用 DTD 与自定义实体、严格 UTF-8 或 BOM 标记 UTF-16 解码、限制深度、属性数和命名空间绑定数。 +不支持的内容返回明确错误或部分观察结果。 + +PDF 解析另有后续审查。本切片不增加 PDF 依赖、不声明 PDF 支持;CSV/DOCX 不能替代 PDF 基准。 diff --git a/licenses/documents/THIRD-PARTY-NOTICES.txt b/licenses/documents/THIRD-PARTY-NOTICES.txt new file mode 100644 index 00000000..c639454d --- /dev/null +++ b/licenses/documents/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,313 @@ + +================================================================ +cfg_aliases-0.2.2 — LICENSE +https://crates.io/crates/cfg_aliases/0.2.2 +================================================================ + +MIT License + +Copyright (c) 2020 Katharos Technology + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +================================================================ +crc32fast-1.5.2 — LICENSE-MIT +https://crates.io/crates/crc32fast/1.5.2 +================================================================ + +MIT License + +Copyright (c) 2018 Sam Rijs, Alex Crichton and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +================================================================ +equivalent-1.0.2 — LICENSE-MIT +https://crates.io/crates/equivalent/1.0.2 +================================================================ + +Copyright (c) 2016--2023 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +================================================================ +flate2-1.1.10 — LICENSE-MIT +https://crates.io/crates/flate2/1.1.10 +================================================================ + +Copyright (c) 2014-2026 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +================================================================ +hashbrown-0.17.1 — LICENSE-MIT +https://crates.io/crates/hashbrown/0.17.1 +================================================================ + +Copyright (c) 2016 Amanieu d'Antras + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +================================================================ +indexmap-2.14.2 — LICENSE-MIT +https://crates.io/crates/indexmap/2.14.2 +================================================================ + +Copyright (c) 2016--2017 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +================================================================ +nix-0.31.3 — LICENSE +https://crates.io/crates/nix/0.31.3 +================================================================ + +The MIT License (MIT) + +Copyright (c) 2015 Carl Lerche + nix-rust Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +================================================================ +quick-xml-0.42.0 — LICENSE-MIT.md +https://crates.io/crates/quick-xml/0.42.0 +================================================================ + +The MIT License (MIT) + +Copyright (c) 2016 Johann Tuffe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +================================================================ +typed-path-0.12.3 — LICENSE-MIT +https://crates.io/crates/typed-path/0.12.3 +================================================================ + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +================================================================ +zip-9.0.1 — LICENSE +https://crates.io/crates/zip/9.0.1 +================================================================ + +The MIT License (MIT) + +Copyright (c) 2014 Mathijs van de Nes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +================================================================ +zlib-rs-0.6.8 — LICENSE +https://crates.io/crates/zlib-rs/0.6.8 +================================================================ + +(C) 2024 Trifecta Tech Foundation + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution. diff --git a/native/Cargo.lock b/native/Cargo.lock index d5af1de9..77ca47e6 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -92,6 +92,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -101,6 +107,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -149,6 +164,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -159,6 +180,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "zlib-rs", +] + [[package]] name = "fs-set-times" version = "0.20.3" @@ -225,6 +255,22 @@ dependencies = [ "r-efi", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "io-extras" version = "0.18.4" @@ -288,6 +334,18 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -312,9 +370,11 @@ version = "0.3.1" dependencies = [ "ops-files", "ops-protocol", + "quick-xml", "serde", "serde_json", "sha2", + "zip", ] [[package]] @@ -388,6 +448,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -532,6 +601,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.1" @@ -736,6 +811,26 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "zip" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "nix", + "typed-path", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/native/crates/ops-documents/Cargo.toml b/native/crates/ops-documents/Cargo.toml index 49ad6617..f684e78a 100644 --- a/native/crates/ops-documents/Cargo.toml +++ b/native/crates/ops-documents/Cargo.toml @@ -12,3 +12,5 @@ ops-files = { path = "../ops-files" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10.9" +zip = { version = "=9.0.1", default-features = false, features = ["deflate-flate2-zlib-rs"] } +quick-xml = { version = "=0.42.0", default-features = false } diff --git a/native/crates/ops-documents/src/archive.rs b/native/crates/ops-documents/src/archive.rs new file mode 100644 index 00000000..097f08ab --- /dev/null +++ b/native/crates/ops-documents/src/archive.rs @@ -0,0 +1,163 @@ +//! ZIP metadata is bounded before ZipArchive can allocate from attacker-controlled counts. +use ops_protocol::{Failure, Result}; +use std::{ + collections::HashSet, + io::{Cursor, Read}, +}; +use zip::ZipArchive; +pub const MAX_ARCHIVE: usize = 32 * 1024 * 1024; +fn error(message: impl Into) -> Failure { + Failure::new("DOCUMENT_PARSE_ERROR", message.into()) +} +fn unsupported(message: &str) -> Failure { + Failure::new("UNSUPPORTED_DOCUMENT", message) +} +fn u16le(data: &[u8], at: usize) -> usize { + u16::from_le_bytes([data[at], data[at + 1]]) as usize +} +fn u32le(data: &[u8], at: usize) -> usize { + u32::from_le_bytes(data[at..at + 4].try_into().unwrap()) as usize +} +fn extra(data: &[u8]) -> Result<()> { + let mut at = 0; + while at < data.len() { + if data.len() - at < 4 { + return Err(error("Truncated ZIP extra field")); + } + let kind = u16le(data, at); + let size = u16le(data, at + 2); + at += 4; + if kind == 1 { + return Err(unsupported("ZIP64 documents are outside the bounded DOCX subset")); + } + if size > data.len() - at { + return Err(error("ZIP extra field exceeds its declared header")); + } + at += size; + } + Ok(()) +} +fn preflight(data: &[u8]) -> Result { + if data.starts_with(&[0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) { + return Err(unsupported("Legacy binary DOC is unsupported; supply DOCX or plain text")); + } + if data.len() < 22 || data.len() > MAX_ARCHIVE { + return Err(error("DOCX must be a ZIP archive of at most 32 MiB")); + } + let end = (data.len().saturating_sub(65557)..=data.len() - 22) + .rev() + .find(|&at| &data[at..at + 4] == b"PK\x05\x06" && at + 22 + u16le(data, at + 20) == data.len()) + .ok_or_else(|| error("ZIP end record is missing or has trailing data"))?; + // zip retries earlier EOCDs after metadata errors. Forbid alternate footer interpretations + // before its constructor can follow an unvalidated count or a ZIP64 allocation path. + if data + .windows(4) + .enumerate() + .any(|(at, magic)| (magic == b"PK\x05\x06" && at != end) || magic == b"PK\x06\x06" || magic == b"PK\x06\x07") + { + return Err(unsupported("Ambiguous ZIP footer signatures and ZIP64 structures are unsupported")); + } + let count = u16le(data, end + 10); + let size = u32le(data, end + 12); + let start = u32le(data, end + 16); + if u16le(data, end + 4) != 0 + || u16le(data, end + 6) != 0 + || u16le(data, end + 8) != count + || count == 65535 + || size == u32::MAX as usize + || start == u32::MAX as usize + { + return Err(unsupported("Multi-disk and ZIP64 documents are unsupported")); + } + if count > 4096 || size > 1024 * 1024 { + return Err(Failure::new("OUTPUT_LIMIT", "DOCX ZIP exceeds 4096 entries or 1 MiB central metadata")); + } + if start.checked_add(size) != Some(end) { + return Err(error("ZIP central-directory bounds are inconsistent")); + } + let mut at = start; + let mut names = HashSet::new(); + let mut ranges = Vec::new(); + for _ in 0..count { + if end - at < 46 || &data[at..at + 4] != b"PK\x01\x02" { + return Err(error("Truncated ZIP central-directory entry")); + } + let (name, extras, comment) = (u16le(data, at + 28), u16le(data, at + 30), u16le(data, at + 32)); + let next = at + 46 + name + extras + comment; + if next > end || u16le(data, at + 34) != 0 { + return Err(error("Invalid ZIP central-directory entry bounds")); + } + let filename = &data[at + 46..at + 46 + name]; + if !names.insert(filename.to_vec()) { + return Err(error("Duplicate ZIP entry names are ambiguous")); + } + extra(&data[at + 46 + name..at + 46 + name + extras])?; + let flags = u16le(data, at + 8); + let method = u16le(data, at + 10); + let compressed = u32le(data, at + 20); + if flags & (1 | 64 | 8192) != 0 { + return Err(unsupported("Encrypted ZIP entries are unsupported")); + } + if compressed == u32::MAX as usize || u32le(data, at + 24) == u32::MAX as usize { + return Err(unsupported("ZIP64 entries are unsupported")); + } + let local = u32le(data, at + 42); + if local > start || start - local < 30 || &data[local..local + 4] != b"PK\x03\x04" { + return Err(error("ZIP local-header bounds are invalid")); + } + let (ln, le) = (u16le(data, local + 26), u16le(data, local + 28)); + let body = local + 30 + ln + le; + if body > start + || compressed > start - body + || &data[local + 30..local + 30 + ln] != filename + || u16le(data, local + 6) != flags + || u16le(data, local + 8) != method + { + return Err(error("ZIP local and central entries disagree")); + } + extra(&data[local + 30 + ln..body])?; + ranges.push((local, body + compressed)); + at = next; + } + if at != end { + return Err(error("ZIP entry count does not match central-directory bytes")); + } + ranges.sort_unstable(); + if ranges.windows(2).any(|r| r[0].1 > r[1].0) { + return Err(error("Overlapping ZIP entries are unsupported")); + } + Ok(count) +} +pub struct Archive<'a>(ZipArchive>); +impl<'a> Archive<'a> { + pub fn new(data: &'a [u8]) -> Result { + let count = preflight(data)?; + let archive = ZipArchive::with_config( + zip::read::Config { archive_offset: zip::read::ArchiveOffset::Known(0) }, + Cursor::new(data), + ) + .map_err(|e| error(format!("Invalid DOCX ZIP: {e}")))?; + if archive.len() != count { + return Err(error("ZIP entry identities differ from preflight")); + } + Ok(Self(archive)) + } + pub fn xml(&mut self, name: &str, limit: usize) -> Result { + let mut file = self.0.by_name(name).map_err(|_| error(format!("Required DOCX part is missing: {name}")))?; + if !file.is_file() || file.unix_mode().is_some_and(|mode| mode & 0o170000 != 0 && mode & 0o170000 != 0o100000) { + return Err(unsupported("DOCX XML parts must be regular ZIP entries")); + } + if !matches!(file.compression(), zip::CompressionMethod::Stored | zip::CompressionMethod::Deflated) { + return Err(unsupported("Only stored or deflated DOCX XML parts are supported")); + } + let mut bytes = Vec::new(); + file.by_ref() + .take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|e| error(format!("DOCX part failed decompression or CRC validation: {e}")))?; + if bytes.len() > limit { + return Err(Failure::new("OUTPUT_LIMIT", format!("DOCX part {name} exceeds its decoded byte limit"))); + } + super::xml::decode(&bytes) + } +} diff --git a/native/crates/ops-documents/src/docx.rs b/native/crates/ops-documents/src/docx.rs new file mode 100644 index 00000000..10568f4b --- /dev/null +++ b/native/crates/ops-documents/src/docx.rs @@ -0,0 +1,386 @@ +//! DOCX main-body text observations. Paragraphs retain table coordinates, never rendered-layout claims. +use crate::{ + archive::{Archive, MAX_ARCHIVE}, + xml::{self, Token}, +}; +use ops_protocol::{Failure, Request, Result, capture::json_size}; +use serde::Deserialize; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{io::Read, path::Path, time::Instant}; +const WORD: [&str; 2] = [ + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "http://purl.oclc.org/ooxml/wordprocessingml/main", +]; +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Args { + path: String, + #[serde(default = "super::one")] + offset: usize, + #[serde(default = "super::two_hundred")] + limit: usize, +} +fn unsupported(name: &str) -> Failure { + Failure::new( + "UNSUPPORTED_DOCUMENT", + format!("Unsupported DOCX main-body content: {name}; preceding complete paragraphs remain observations"), + ) +} +fn metadata( + text: &str, + started: Instant, + namespace: &str, + root: &str, + mut visit: impl FnMut(xml::Tag) -> Result<()>, +) -> Result<()> { + let mut depth = 0usize; + xml::walk(text, started, |token| { + match token { + Token::Start(tag) => { + if tag.ns != namespace || (depth == 0 && tag.name != root) || depth > 1 { + return Err(xml::bad("Invalid DOCX package XML structure")); + } + if depth == 1 { + visit(tag)?; + } + depth += 1; + } + Token::End(_) => { + depth = depth.checked_sub(1).ok_or_else(|| xml::bad("Invalid package XML nesting"))?; + } + Token::Text(text) => { + if !text.trim().is_empty() { + return Err(xml::bad("Unexpected text in DOCX package XML")); + } + } + } + Ok(()) + }) +} +fn package(archive: &mut Archive<'_>, started: Instant) -> Result<()> { + let types = archive.xml("[Content_Types].xml", 1024 * 1024)?; + let mut main = 0; + metadata(&types, started, "http://schemas.openxmlformats.org/package/2006/content-types", "Types", |tag| { + { + if !matches!(tag.name.as_str(), "Override" | "Default") { + return Err(xml::bad("Unexpected content-type element")); + } + if tag.ns != "http://schemas.openxmlformats.org/package/2006/content-types" { + return Err(xml::bad("Invalid DOCX content-type namespace")); + } + if tag.attrs.get("ContentType").is_some_and(|t| { + t.to_ascii_lowercase().contains("macroenabled") || t.to_ascii_lowercase().contains("vbaproject") + }) { + return Err(unsupported("macro-enabled package")); + } + if tag.name == "Override" && tag.attrs.get("PartName").is_some_and(|p| p == "/word/document.xml") { + if tag.attrs.get("ContentType").map(String::as_str) + != Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml") + { + return Err(unsupported("non-DOCX main content type")); + } + main += 1; + } + } + Ok(()) + })?; + if main != 1 { + return Err(xml::bad("DOCX must declare exactly one word/document.xml main part")); + } + let rels = archive.xml("_rels/.rels", 1024 * 1024)?; + let mut main = 0; + metadata(&rels, started, "http://schemas.openxmlformats.org/package/2006/relationships", "Relationships", |tag| { + { + if tag.name != "Relationship" { + return Err(xml::bad("Unexpected relationship element")); + } + if tag.ns != "http://schemas.openxmlformats.org/package/2006/relationships" { + return Err(xml::bad("Invalid package relationship namespace")); + } + if tag.name == "Relationship" + && tag.attrs.get("Type").is_some_and(|t| { + t == "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" + || t == "http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument" + }) + { + if tag.attrs.get("Target").map(String::as_str) != Some("word/document.xml") + || tag.attrs.get("TargetMode").is_some_and(|m| m != "Internal") + { + return Err(unsupported("external or nonstandard main-document relationship")); + } + main += 1; + } + } + Ok(()) + })?; + if main != 1 { + return Err(xml::bad("DOCX must have exactly one internal main-document relationship")); + } + Ok(()) +} +struct Page<'a> { + request: &'a Request, + args: &'a Args, + items: Vec, + observed: usize, + selected: usize, + bytes: usize, + cut: bool, +} +impl Page<'_> { + fn push(&mut self, mut item: Value) -> Result<()> { + if self.observed >= 100_000 { + return Err(Failure::new("OUTPUT_LIMIT", "DOCX exceeds 100000 observed paragraphs")); + } + self.observed += 1; + item["block"] = json!(self.observed); + if self.observed < self.args.offset || self.observed >= self.args.offset + self.args.limit { + return Ok(()); + } + self.selected += 1; + if let Some(sink) = self.request.output_sink.as_deref() { + sink.push(&item); + } + let bytes = json_size(&item) + 1; + if self.cut || self.bytes + bytes > 80 * 1024 { + self.cut = true; + if let Some(sink) = self.request.output_sink.as_deref() { + sink.clipped(); + } + } else { + self.bytes += bytes; + self.items.push(item); + } + Ok(()) + } +} +fn paragraphs(text: &str, started: Instant, page: &mut Page<'_>) -> Result<()> { + let mut stack = Vec::::new(); + let (mut body, mut document) = (0, 0); + let (mut paragraph, mut content, mut table, mut row, mut cell) = (None::, String::new(), 0, 0, 0); + let mut in_table = false; + xml::walk(text, started, |token| { + match token { + Token::Start(tag) => { + if !WORD.contains(&tag.ns.as_str()) { + return Err(unsupported(&format!("foreign namespace {}", tag.name))); + } + let parent = stack.last().map(String::as_str); + let property = stack.iter().any(|n| { + matches!(n.as_str(), "pPr" | "rPr" | "tblPr" | "trPr" | "tcPr" | "sectPr" | "tblGrid" | "sdtPr") + }); + if property { + if matches!(tag.name.as_str(), "document" | "body" | "p" | "r" | "t" | "tbl" | "tr" | "tc") { + return Err(xml::bad("Content nested inside formatting properties")); + } + stack.push(tag.name); + return Ok(()); + } + match tag.name.as_str() { + "document" if stack.is_empty() => document += 1, + "body" if parent == Some("document") => body += 1, + "tbl" => { + if in_table || paragraph.is_some() || !stack.iter().any(|n| n == "body") { + return Err(unsupported("nested table")); + } + in_table = true; + table += 1; + row = 0; + cell = 0; + } + "tr" => { + if !in_table || parent != Some("tbl") { + return Err(xml::bad("Table row outside table")); + } + row += 1; + cell = 0; + } + "tc" => { + if parent != Some("tr") { + return Err(xml::bad("Table cell outside row")); + } + cell += 1; + } + "p" => { + if paragraph.is_some() + || !stack.iter().any(|n| n == "body") + || !matches!(parent, Some("body" | "tc" | "sdtContent" | "customXml")) + { + return Err(unsupported("paragraph placement")); + } + let mut item = json!({"type":"paragraph","text":""}); + if in_table { + item["table"] = json!(table); + item["row"] = json!(row); + item["cell"] = json!(cell); + } + paragraph = Some(item); + content.clear(); + } + "t" => { + if paragraph.is_none() || parent != Some("r") { + return Err(xml::bad("Text outside a paragraph run")); + } + } + "tab" | "br" | "cr" => { + if paragraph.is_none() || parent != Some("r") { + return Err(xml::bad("Text control outside a paragraph run")); + } + content.push(if tag.name == "tab" { '\t' } else { '\n' }); + } + "drawing" | "pict" | "object" | "fldSimple" | "fldChar" | "instrText" | "altChunk" | "del" + | "ins" | "moveFrom" | "moveTo" | "sym" | "footnoteReference" | "endnoteReference" => { + return Err(unsupported(&tag.name)); + } + "document" | "body" => return Err(xml::bad("Invalid document/body nesting")), + "r" + | "hyperlink" + | "bookmarkStart" + | "bookmarkEnd" + | "proofErr" + | "commentRangeStart" + | "commentRangeEnd" + | "commentReference" + | "pPr" + | "rPr" + | "tblPr" + | "trPr" + | "tcPr" + | "sectPr" + | "tblGrid" + | "sdt" + | "sdtPr" + | "sdtContent" + | "customXml" + | "lastRenderedPageBreak" => {} + "noBreakHyphen" | "softHyphen" => { + if paragraph.is_none() || parent != Some("r") { + return Err(xml::bad("Text control outside a paragraph run")); + } + content.push(if tag.name == "noBreakHyphen" { '\u{2011}' } else { '\u{ad}' }); + } + _ => return Err(unsupported(&tag.name)), + } + if content.len() > 1024 * 1024 { + return Err(Failure::new("OUTPUT_LIMIT", "One DOCX paragraph exceeds 1 MiB")); + } + stack.push(tag.name); + } + Token::End(name) => { + if stack.pop().as_deref() != Some(&name) { + return Err(xml::bad("DOCX element nesting differs")); + } + if stack.iter().any(|n| { + matches!(n.as_str(), "pPr" | "rPr" | "tblPr" | "trPr" | "tcPr" | "sectPr" | "tblGrid" | "sdtPr") + }) { + return Ok(()); + } + if name == "p" { + let mut item = paragraph.take().ok_or_else(|| xml::bad("Unmatched paragraph"))?; + item["text"] = json!(std::mem::take(&mut content)); + page.push(item)?; + } + if name == "tbl" { + in_table = false; + } + } + Token::Text(text) => { + if stack.last().is_some_and(|n| n == "t") { + if content.len().saturating_add(text.len()) > 1024 * 1024 { + return Err(Failure::new("OUTPUT_LIMIT", "One DOCX paragraph exceeds 1 MiB")); + } + content.push_str(&text); + } else if !text.trim().is_empty() { + return Err(unsupported("text outside w:t")); + } + } + } + Ok(()) + })?; + if document != 1 || body != 1 { + return Err(xml::bad("DOCX main part requires one document and one body")); + } + Ok(()) +} +pub fn read(request: &Request) -> Result { + let args: Args = serde_json::from_value(request.args.clone())?; + if args.offset == 0 || args.offset > 100_000 || args.limit == 0 || args.limit > 1000 { + return Err(Failure::new("INVALID_ARGUMENT", "doc_read offset is 1-100000 paragraphs and limit is 1-1000")); + } + request.authorize_path(Path::new(&args.path))?; + let target = ops_files::file_path::checked(&args.path)?; + request.authorize_path(&target.display)?; + let mut file = ops_files::file_path::read_file(&target)?.into_std(); + let before = file.metadata()?; + let started = Instant::now(); + if before.len() > MAX_ARCHIVE as u64 { + return Err(Failure::new("OUTPUT_LIMIT", "DOCX archive exceeds 32 MiB")); + } + let mut bytes = Vec::new(); + file.by_ref().take(MAX_ARCHIVE as u64 + 1).read_to_end(&mut bytes)?; + if bytes.len() > MAX_ARCHIVE { + return Err(Failure::new("OUTPUT_LIMIT", "DOCX archive grew beyond 32 MiB")); + } + let mut archive = Archive::new(&bytes)?; + package(&mut archive, started)?; + let text = archive.xml("word/document.xml", 8 * 1024 * 1024)?; + let mut page = Page { request, args: &args, items: Vec::new(), observed: 0, selected: 0, bytes: 0, cut: false }; + let mut failure = paragraphs(&text, started, &mut page).err(); + let after = file.metadata()?; + if before.len() != after.len() || before.modified().ok() != after.modified().ok() { + failure = Some(Failure::new( + "FILE_CHANGED", + "DOCX metadata changed while reading; observed paragraphs are not a stable snapshot", + )); + } + let scan = failure.is_none(); + let next = args.offset + page.selected; + let more = !scan || next <= page.observed; + let cause = if let Some(error) = &failure { + match error.code.as_str() { + "DEADLINE_EXCEEDED" => "time", + "FILE_CHANGED" => "changed", + "OUTPUT_LIMIT" => "scan-budget", + _ => "parse-error", + } + } else if more { + "row-limit" + } else { + "none" + }; + let mut output = json!({"status":if scan {"completed"} else {"partial"},"phase":"verify","changed":0,"effectState":"none","path":target.display,"format":"docx","scope":"main-body-paragraphs", + "excludedContent":["formatting and rendered layout","headers and footers","footnotes and endnotes","comments","package metadata"], + "bytes":before.len(),"blocksObserved":page.observed,"skipped":args.offset-1,"scanComplete":scan,"pageComplete":scan || page.observed>=args.offset+args.limit-1, + "complete":scan&&!more,"truncated":more,"truncatedBy":cause,"nextOffset":next,"items":[]}); + if scan { + output["total"] = json!(page.observed); + output["omitted"] = json!(page.observed.saturating_sub(next - 1)); + output["revision"] = json!(format!("{:x}", Sha256::digest(&bytes))); + } + if let Some(mut error) = failure { + error.next_action="Use a dedicated DOCX reader for unsupported content or parse limits; main-body paragraph paging cannot recover an unobserved suffix. Do not treat this scope as the full rendered document.".into(); + output["nextAction"] = json!(error.next_action); + output["error"] = json!(error); + } + if let Some(sink) = request.output_sink.as_deref() { + sink.source(output.clone()); + } + if page.cut { + output["complete"] = json!(false); + output["pageComplete"] = json!(false); + output["truncated"] = json!(true); + output["nextOffset"] = json!(args.offset + page.items.len()); + if scan { + output["truncatedBy"] = json!("output-budget"); + output["omitted"] = json!(page.observed.saturating_sub(args.offset - 1 + page.items.len())); + } + if output.get("nextAction").is_none() { + output["nextAction"] = json!( + "Read the stored reference when available; otherwise reduce limit or use a dedicated DOCX reader for a large paragraph." + ); + } + } + output["items"] = json!(page.items); + Ok(output) +} diff --git a/native/crates/ops-documents/src/lib.rs b/native/crates/ops-documents/src/lib.rs index 713576a9..0d3fd695 100644 --- a/native/crates/ops-documents/src/lib.rs +++ b/native/crates/ops-documents/src/lib.rs @@ -1,5 +1,8 @@ //! Bounded table observations; cells stay strings, and incomplete traversals never invent totals. #![forbid(unsafe_code)] +mod archive; +mod xml; +mod docx; mod decode; mod parse; use decode::{Decoder, Encoding}; @@ -36,6 +39,7 @@ fn recovery(error: &mut Failure) { } struct Source { path: PathBuf, file: File, before: Metadata, started: Instant } pub fn dispatch(request: &Request) -> Result { + if request.tool == "doc_read" { return docx::read(request); } if request.tool != "table_read" { return Err(Failure::new("INVALID_ARGUMENT", "Unknown document capability")); } let args: Args = serde_json::from_value(request.args.clone())?; if args.offset == 0 || args.offset > MAX_ROWS || args.limit == 0 || args.limit > 1000 { diff --git a/native/crates/ops-documents/src/xml.rs b/native/crates/ops-documents/src/xml.rs new file mode 100644 index 00000000..0aad9e59 --- /dev/null +++ b/native/crates/ops-documents/src/xml.rs @@ -0,0 +1,173 @@ +//! Strict bounded XML events; no DTD, custom entity expansion, or external resolution. +use ops_protocol::{Failure, Result}; +use quick_xml::{events::Event, name::ResolveResult, reader::NsReader}; +use std::{ + collections::BTreeMap, + time::{Duration, Instant}, +}; +pub fn bad(message: impl Into) -> Failure { + Failure::new("DOCUMENT_PARSE_ERROR", message.into()) +} +fn valid(text: &str) -> bool { + text.chars() + .all(|c| matches!(c, '\t'|'\n'|'\r'|'\u{20}'..='\u{d7ff}'|'\u{e000}'..='\u{fffd}'|'\u{10000}'..='\u{10ffff}')) +} +pub fn decode(bytes: &[u8]) -> Result { + let (text, encoding) = if bytes.starts_with(&[0xff, 0xfe]) || bytes.starts_with(&[0xfe, 0xff]) { + if !bytes.len().is_multiple_of(2) { + return Err(Failure::new("ERR_INVALID_ENCODING", "Odd DOCX UTF-16 byte count")); + } + let le = bytes[0] == 0xff; + let units = bytes[2..] + .as_chunks::<2>() + .0 + .iter() + .map(|b| if le { u16::from_le_bytes([b[0], b[1]]) } else { u16::from_be_bytes([b[0], b[1]]) }); + let text = char::decode_utf16(units) + .collect::>() + .map_err(|_| Failure::new("ERR_INVALID_ENCODING", "Invalid DOCX UTF-16 surrogate"))?; + (text, if le { "utf-16le" } else { "utf-16be" }) + } else { + ( + std::str::from_utf8(bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(bytes)) + .map_err(|_| { + Failure::new("ERR_INVALID_ENCODING", "DOCX XML must be strict UTF-8 or BOM-marked UTF-16") + })? + .to_owned(), + "utf-8", + ) + }; + if !valid(&text) { + return Err(bad("Invalid XML 1.0 character")); + } + let mut reader = NsReader::from_str(&text); + if let Event::Decl(decl) = reader.read_event().map_err(|e| bad(e.to_string()))? { + if decl.version().map_err(|e| bad(e.to_string()))? != "1.0" { + return Err(bad("Only XML 1.0 is supported")); + } + if let Some(declared) = decl.encoding() { + let declared = declared.map_err(|e| bad(e.to_string()))?.to_ascii_lowercase(); + if declared != encoding && !(declared == "utf-16" && encoding.starts_with("utf-16")) { + return Err(Failure::new( + "ERR_INVALID_ENCODING", + "DOCX XML encoding declaration disagrees with actual bytes", + )); + } + } + } + Ok(text) +} +pub struct Tag { + pub ns: String, + pub name: String, + pub attrs: BTreeMap, +} +pub enum Token { + Start(Tag), + End(String), + Text(String), +} +pub fn walk(text: &str, started: Instant, mut visit: impl FnMut(Token) -> Result<()>) -> Result<()> { + let mut reader = NsReader::from_str(text); + reader.config_mut().check_comments = true; + reader.resolver_mut().set_max_namespace_bindings(512); + let (mut depth, mut roots, mut declaration, mut events) = (0usize, 0usize, false, 0usize); + loop { + if started.elapsed() > Duration::from_secs(30) { + return Err(Failure::new("DEADLINE_EXCEEDED", "Document parse reached its 30-second deadline")); + } + let event = reader.read_event().map_err(|e| bad(format!("Invalid DOCX XML: {e}")))?; + match event { + Event::Start(ref tag) | Event::Empty(ref tag) => { + if depth == 0 { + roots += 1; + if roots > 1 { + return Err(bad("XML has multiple roots")); + } + } + if depth >= 128 { + return Err(Failure::new("OUTPUT_LIMIT", "DOCX XML exceeds 128 nesting levels")); + } + let (ns, name) = reader.resolver().resolve_element(tag.name()); + let ns = match ns { + ResolveResult::Bound(ns) => ns.as_ref().to_owned(), + _ => return Err(bad("DOCX XML element has no recognized namespace binding")), + }; + let name = name.as_ref().to_owned(); + let mut attrs = BTreeMap::new(); + for (index, attr) in tag.attributes().enumerate() { + if index >= 64 { + return Err(Failure::new("OUTPUT_LIMIT", "DOCX element exceeds 64 attributes")); + } + let attr = attr.map_err(|e| bad(e.to_string()))?; + let value = attr + .normalized_value(quick_xml::XmlVersion::Implicit1_0) + .map_err(|e| bad(e.to_string()))? + .into_owned(); + if !valid(&value) { + return Err(bad("Invalid XML attribute character reference")); + } + if attr.key.as_ref() != "xmlns" + && !attr.key.as_ref().starts_with("xmlns:") + && matches!(reader.resolver().resolve_attribute(attr.key).0, ResolveResult::Unknown(_)) + { + return Err(bad("Unbound XML attribute namespace")); + } + attrs.insert(attr.key.as_ref().to_owned(), value); + } + visit(Token::Start(Tag { ns: ns.clone(), name: name.clone(), attrs }))?; + if matches!(event, Event::Empty(_)) { + visit(Token::End(name))?; + } else { + depth += 1; + } + } + Event::End(tag) => { + let (ns, name) = reader.resolver().resolve_element(tag.name()); + if !matches!(ns, ResolveResult::Bound(_)) { + return Err(bad("Unbound DOCX XML closing element")); + } + depth = depth.checked_sub(1).ok_or_else(|| bad("Unexpected XML closing element"))?; + visit(Token::End(name.as_ref().to_owned()))?; + } + Event::Text(value) => { + if depth == 0 && !value.trim().is_empty() { + return Err(bad("Text outside XML root")); + } + visit(Token::Text(value.xml10_content().into_owned()))?; + } + Event::CData(value) => { + if depth == 0 { + return Err(bad("CDATA outside XML root")); + } + visit(Token::Text(value.xml10_content().into_owned()))?; + } + Event::GeneralRef(value) => { + if depth == 0 { + return Err(bad("Entity outside XML root")); + } + let escaped = format!("&{};", value.as_ref()); + let expanded = quick_xml::escape::unescape(&escaped).map_err(|e| bad(e.to_string()))?; + if !valid(&expanded) { + return Err(bad("Invalid XML character reference")); + } + visit(Token::Text(expanded.into_owned()))?; + } + Event::DocType(_) => return Err(bad("DTD and custom entities are unsupported")), + Event::Decl(_) => { + if declaration || roots != 0 || events != 0 { + return Err(bad("Misplaced XML declaration")); + } + declaration = true; + } + Event::Eof => { + if roots != 1 || depth != 0 { + return Err(bad("Incomplete XML document")); + } + return Ok(()); + } + _ => {} + } + events += 1; + } +} diff --git a/native/crates/ops-results/src/spill.rs b/native/crates/ops-results/src/spill.rs index de56e43e..a3b25fe0 100644 --- a/native/crates/ops-results/src/spill.rs +++ b/native/crates/ops-results/src/spill.rs @@ -19,7 +19,7 @@ fn mark_head(request: &Request, value: &mut Value) { let skipped = if request.tool == "replace" { 0 } else { value["skipped"].as_u64().unwrap_or(0) }; if let Some(total) = value["total"].as_u64() { value["omitted"] = json!(total.saturating_sub(skipped)); } if value.get("nextOffset").is_some() { - value["nextOffset"] = json!(if request.tool == "table_read" { skipped + 1 } else if request.tool == "inspect_local_file" { + value["nextOffset"] = json!(if matches!(request.tool.as_str(), "table_read" | "doc_read") { skipped + 1 } else if request.tool == "inspect_local_file" { if request.args["view"] == "hex" { skipped / 16 + 1 } else { skipped + 1 } } else { skipped }); } diff --git a/native/src/lib.rs b/native/src/lib.rs index f6fe2c4a..e4a49714 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -33,7 +33,7 @@ pub fn dispatch(request: Request) -> Result { Ok(ops_results::spill::finish(&request, value)) } fn execute(dir: &Dir, request: &Request) -> Result { - if request.tool == "table_read" { return ops_documents::dispatch(request); } + if matches!(request.tool.as_str(), "table_read" | "doc_read") { return ops_documents::dispatch(request); } // File reads/searches do not create or consult the durable audit store. // This is important for read-only sessions and keeps observation separate // from mutation audit records. diff --git a/native/src/main.rs b/native/src/main.rs index 5191b642..6465cefd 100644 --- a/native/src/main.rs +++ b/native/src/main.rs @@ -4,7 +4,7 @@ use serde_json::json; use std::io::{Read, Write}; fn may_mutate(request: &Request) -> bool { - !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "table_read" | "glob" | "grep" | "diff" | "stat") + !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "table_read" | "doc_read" | "glob" | "grep" | "diff" | "stat") && !(request.tool == "result" && request.args.get("action").and_then(serde_json::Value::as_str) == Some("page")) } fn main() { diff --git a/test/docx-worker.test.mjs b/test/docx-worker.test.mjs new file mode 100644 index 00000000..e0e331a9 --- /dev/null +++ b/test/docx-worker.test.mjs @@ -0,0 +1,152 @@ +/** Real one-shot DOCX reads: bounded ZIP/XML, honest scope and durable spill recovery. */ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { callWorker, request, envelopeOf, requireWorker } from './lib/boundary.mjs' +import { docx, zip, parts, document, paragraph, word, types, rels } from './lib/document-fixture.mjs' +requireWorker('docx-worker') +const fileAt = (root, bytes, name = 'document.docx') => { const file = path.join(root, name); fs.writeFileSync(file, bytes); return file } +async function run(root, args, options = {}) { + const payload = { version: 1, requestId: 'docx-test', component: 'file', budget: { timeoutMs: 30000, maxBytes: options.maxBytes ?? 131072 }, + ...(options.cache ? { storage: { root: options.cache, authorityId: 'a'.repeat(64), allowedTools: ['doc_read', 'result'] } } : {}), + request: { ...request(options.tool ?? 'doc_read', args, { root, readOnly: options.readOnly, writeRoots: [] }), ...options.authority } } + const raw = await callWorker(JSON.stringify(payload)); assert.ok(Buffer.byteLength(raw.stdout) <= payload.budget.maxBytes) + return envelopeOf(raw, assert) +} +function failure(body, code) { assert.equal(body.error.code, code, JSON.stringify(body)); assert.equal(body.error.effectState, 'none'); assert.notEqual(body.complete, true); assert.equal(body.revision, undefined); assert.equal(body.total, undefined) } +await test('stored and deflated DOCX preserve lexical Unicode paragraphs, controls and table coordinates', async () => { + await withTempDir('docx-text', async root => { + const content = paragraph(' 中😀 & < A ') + 'ABC' + '' + paragraph('one') + paragraph('two') + '' + for (const deflate of [false, true]) { + const bytes = docx(content, { deflate }), body = await run(root, { path: fileAt(root, bytes) }) + assert.equal(body.status, 'completed'); assert.equal(body.complete, true); assert.equal(body.scanComplete, true) + assert.equal(body.scope, 'main-body-paragraphs'); assert.ok(body.excludedContent.includes('headers and footers')) + assert.deepEqual(body.items, [{ block: 1, type: 'paragraph', text: ' 中😀 & < A ' }, { block: 2, type: 'paragraph', text: 'A\tB\nC\u2011\u00ad' }, + { block: 3, type: 'paragraph', text: 'one', table: 1, row: 1, cell: 1 }, { block: 4, type: 'paragraph', text: 'two', table: 1, row: 1, cell: 1 }, { block: 5, type: 'paragraph', text: '', table: 1, row: 1, cell: 2 }]) + assert.equal(body.total, 5); assert.equal(body.revision, createHash('sha256').update(bytes).digest('hex')); assert.equal(body.nextOffset, 6) + } + }) +}) +await test('namespace prefixes, strict Word namespace and BOM-marked UTF-16 remain exact', async () => { + await withTempDir('docx-encoding', async root => { + for (const be of [false, true]) { + const xml = '\ufeff' + document(paragraph('中😀')).replaceAll('UTF-8', 'UTF-16').replaceAll('w:', 'x:').replace('xmlns:w', 'xmlns:x').replaceAll(word, 'http://purl.oclc.org/ooxml/wordprocessingml/main') + const bytes = Buffer.from(xml, 'utf16le'); if (be) bytes.swap16() + const body = await run(root, { path: fileAt(root, zip(parts(bytes))) }); assert.equal(body.complete, true); assert.equal(body.items[0].text, '中😀') + } + for (const xml of [Buffer.concat([Buffer.from(document(paragraph('ok'))), Buffer.from([0xff])]), Buffer.from(document(paragraph('ok')).replace('UTF-8', 'UTF-16')), Buffer.from([0xff, 0xfe, 0, 0xd8])]) { + failure(await run(root, { path: fileAt(root, zip(parts(xml))) }), 'ERR_INVALID_ENCODING') + } + }) +}) +await test('paragraph paging reports full totals separately and empty documents have no phantom item', async () => { + await withTempDir('docx-pages', async root => { + const file = fileAt(root, docx(Array.from({ length: 205 }, (_, i) => paragraph(String(i + 1))).join(''))) + const first = await run(root, { path: file }); assert.equal(first.items.length, 200); assert.equal(first.nextOffset, 201); assert.equal(first.total, 205); assert.equal(first.scanComplete, true); assert.equal(first.pageComplete, true); assert.equal(first.complete, false) + const last = await run(root, { path: file, offset: 201, limit: 5 }); assert.deepEqual(last.items.map(i => i.text), ['201', '202', '203', '204', '205']); assert.equal(last.complete, true) + const empty = await run(root, { path: fileAt(root, docx('')) }); assert.equal(empty.total, 0); assert.deepEqual(empty.items, []); assert.equal(empty.complete, true) + for (const args of [{ offset: 0 }, { offset: 100001 }, { limit: 0 }, { limit: 1001 }, { unknown: true }]) failure(await run(root, { path: file, ...args }), 'INVALID_ARGUMENT') + }) +}) +await test('unsupported body content and malformed suffix preserve only safe preceding paragraphs without totals', async () => { + await withTempDir('docx-partial', async root => { + for (const suffix of ['', '' + paragraph('revision') + '', '', '', '']) { + const body = await run(root, { path: fileAt(root, docx(paragraph('safe') + suffix)), limit: 1 }); failure(body, 'UNSUPPORTED_DOCUMENT'); assert.equal(body.status, 'partial'); assert.equal(body.scanComplete, false); assert.equal(body.pageComplete, true); assert.equal(body.items[0].text, 'safe') + } + const body = await run(root, { path: fileAt(root, zip(parts(document(paragraph('safe')).replace('', '')))) }) + failure(body, 'DOCUMENT_PARSE_ERROR'); assert.deepEqual(body.items.map(i => i.text), ['safe']) + }) +}) +await test('DTD, custom entities, invalid numeric references and XML roots/depth/attributes cannot claim completeness', async () => { + await withTempDir('docx-xml', async root => { + for (const xml of [document(paragraph('&unknown;')), ']>' + document(paragraph('&secret;')).replace(/^<\?xml.*?\?>/, ''), document(paragraph('�')), document('') + document(''), document(paragraph('x')).replace(''.repeat(129) + ''.repeat(129))) }), 'OUTPUT_LIMIT') + failure(await run(root, { path: fileAt(root, docx(' `a${i}="x"`).join(' ') + '/>')) }), 'OUTPUT_LIMIT') + }) +}) +await test('ZIP preflight rejects counts, overlapping/duplicate identities, ZIP64, encryption and bad offsets', async () => { + await withTempDir('docx-zip', async root => { + const source = docx(paragraph('safe')); const mutations = [bytes => { bytes.writeUInt16LE(4097, bytes.length - 14); bytes.writeUInt16LE(4097, bytes.length - 12) }, bytes => bytes.writeUInt32LE(0xffffffff, bytes.length - 6), bytes => bytes.writeUInt32LE(0, bytes.length - 6), bytes => bytes.writeUInt16LE(1, 6)] + for (const mutate of mutations) { const bytes = Buffer.from(source); mutate(bytes); const body = await run(root, { path: fileAt(root, bytes) }); assert.ok(body.error); assert.equal(body.error.effectState, 'none'); assert.equal(body.items, undefined) } + failure(await run(root, { path: fileAt(root, zip([...parts(document('')), ['word/document.xml', document('')]])) }), 'DOCUMENT_PARSE_ERROR') + const corrupt = Buffer.from(source); corrupt[corrupt.indexOf(Buffer.from('safe'))] = 120 + failure(await run(root, { path: fileAt(root, corrupt) }), 'DOCUMENT_PARSE_ERROR') + }) +}) +await test('legacy DOC, macros, external or nonstandard main relationships are explicitly refused', async () => { + await withTempDir('docx-types', async root => { + failure(await run(root, { path: fileAt(root, Buffer.from('d0cf11e0a1b11ae1', 'hex')) }), 'UNSUPPORTED_DOCUMENT') + for (const list of [[['[Content_Types].xml', types.replace('wordprocessingml.document', 'wordprocessingml.macroEnabled')], ...parts(document('')).slice(1)], [parts(document(''))[0], ['_rels/.rels', rels.replace('Target="word/document.xml"', 'Target="https://example.invalid/file" TargetMode="External"')], parts(document(''))[2]]]) { + failure(await run(root, { path: fileAt(root, zip(list)) }), 'UNSUPPORTED_DOCUMENT') + } + }) +}) +await test('archive, decompressed XML and paragraph limits are bounded with honest gaps', async () => { + await withTempDir('docx-bounds', async root => { + failure(await run(root, { path: fileAt(root, Buffer.alloc(32 * 1024 * 1024 + 1)) }), 'OUTPUT_LIMIT') + failure(await run(root, { path: fileAt(root, docx(paragraph('x'.repeat(8 * 1024 * 1024)), { deflate: true })) }), 'OUTPUT_LIMIT') + const body = await run(root, { path: fileAt(root, docx(paragraph('safe') + paragraph('x'.repeat(1024 * 1024 + 1)), { deflate: true })) }) + failure(body, 'OUTPUT_LIMIT'); assert.equal(body.scanComplete, false); assert.deepEqual(body.items.map(i => i.text), ['safe']) + }) +}) +await test('read-only DOCX reads create nothing and repeat credential and no-follow checks', async () => { + await withTempDir('docx-authority', async root => { + const file = fileAt(root, docx(paragraph('safe'))); const before = fs.readdirSync(root) + assert.equal((await run(root, { path: file }, { readOnly: true })).complete, true); assert.deepEqual(fs.readdirSync(root), before) + failure(await run(root, { path: file }, { authority: { deny_roots: [root] } }), 'CREDENTIAL_DENIED') + const target = path.join(root, 'target'); fs.mkdirSync(target); const hidden = fileAt(target, docx(paragraph('secret'))) + const alias = path.join(root, 'alias'); fs.symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + const denied = await run(root, { path: path.join(alias, path.basename(hidden)) }); assert.ok(denied.error); assert.equal(denied.error.effectState, 'none'); assert.equal(denied.items, undefined) + }) +}) +await test('large paragraph spill reconstructs every Unicode byte and retains main-body scope', async () => { + await withTempDir('docx-spill', async root => { + const cache = path.join(root, 'cache'); fs.mkdirSync(cache, { mode: 0o700 }); const text = '中😀'.repeat(20000) + const file = fileAt(root, docx(paragraph(text) + paragraph('last'), { deflate: true })); const head = await run(root, { path: file }, { cache, maxBytes: 4096 }) + assert.ok(head.reference); assert.equal(head.nextOffset, 1); assert.equal(head.referenceRoot, process.platform === 'win32' ? path.toNamespacedPath(fs.realpathSync.native(root)) : fs.realpathSync(root)); assert.equal(head.complete, false) + let offset = 0, source; const records = [] + for (;;) { const page = await run(head.referenceRoot, { action: 'page', id: head.reference.refId, offset, limit: 512, maxBytes: 65536 }, { cache, tool: 'result', readOnly: true }); assert.equal(page.error, undefined); records.push(...page.items); source = page.source; assert.ok(page.nextOffset > offset); offset = page.nextOffset; if (page.complete) break } + assert.equal(source.scope, 'main-body-paragraphs'); assert.equal(source.captureComplete, true); assert.equal(source.scanComplete, true); assert.equal(source.total, 2) + const rows = []; let chunks = [], byteOffset = 0 + for (const item of records) { assert.equal(item.index, rows.length); if (item.kind === 'item') { rows.push(item.value); continue } assert.equal(item.chunk, chunks.length); assert.equal(item.byteOffset, byteOffset); chunks.push(item.text); byteOffset += Buffer.byteLength(item.text); if (item.complete) { rows.push(JSON.parse(chunks.join(''))); chunks = []; byteOffset = 0 } } + assert.equal(chunks.length, 0); assert.deepEqual(rows.map(row => row.text), [text, 'last']) + const noCache = await run(root, { path: file }, { maxBytes: 4096, cache, readOnly: true }); assert.equal(noCache.reference, undefined); assert.equal(noCache.storageError.code, 'POLICY_DENIED'); assert.equal(noCache.complete, false) + }) +}) +await test('DOCX transport overflow remains read-only even in writable sessions', async () => { + await withTempDir('docx-transport', async root => { const body = await run(root, { path: fileAt(root, docx(paragraph('x'.repeat(40000)))) }, { maxBytes: 1024 }); failure(body, 'OUTPUT_LIMIT') }) +}) + +await test('DOCX count limits and malformed package/content placement never invent complete observations', async () => { + await withTempDir('docx-structure', async root => { + const capped = await run(root, { path: fileAt(root, docx(''.repeat(100001), { deflate: true })), limit: 1 }) + failure(capped, 'OUTPUT_LIMIT'); assert.equal(capped.blocksObserved, 100000); assert.equal(capped.items.length, 1) + for (const xml of [document('hidden'), document(''), document('').replace('', '' + paragraph('outside') + '')]) { + const bad = await run(root, { path: fileAt(root, zip(parts(xml))) }); assert.ok(bad.error); assert.notEqual(bad.complete, true); assert.equal(bad.total, undefined) + } + const malformed = [['[Content_Types].xml', types.replace('', '')], ...parts(document('')).slice(1)] + failure(await run(root, { path: fileAt(root, zip(malformed)) }), 'DOCUMENT_PARSE_ERROR') + }) +}) + +await test('alternate ZIP footers and macro Default content types are refused before archive construction', async () => { + await withTempDir('docx-footer', async root => { + for (const magic of [0x06064b50, 0x07064b50, 0x06054b50]) { + const source = docx(paragraph('safe')); const end = source.length - 22 + const comment = Buffer.alloc(30, 120); comment.writeUInt32LE(magic, 2) + source.writeUInt16LE(comment.length, end + 20) + failure(await run(root, { path: fileAt(root, Buffer.concat([source, comment])) }), 'UNSUPPORTED_DOCUMENT') + } + const source = docx(paragraph('safe')), end = source.length - 22, footer = Buffer.alloc(76) + footer.writeUInt32LE(0x06064b50); footer.writeBigUInt64LE(44n, 4); footer.writeUInt32LE(0x07064b50, 56) + // Classic fields remain ordinary values: this still cannot open a ZIP64 interpretation. + failure(await run(root, { path: fileAt(root, Buffer.concat([source.subarray(0, end), footer, source.subarray(end)])) }), 'UNSUPPORTED_DOCUMENT') + const macro = types.replace('', '') + failure(await run(root, { path: fileAt(root, zip([['[Content_Types].xml', macro], ...parts(document('')).slice(1)])) }), 'UNSUPPORTED_DOCUMENT') + }) +}) + +report('docx-worker') diff --git a/test/lib/document-fixture.mjs b/test/lib/document-fixture.mjs new file mode 100644 index 00000000..e25ad16f --- /dev/null +++ b/test/lib/document-fixture.mjs @@ -0,0 +1,29 @@ +/** Small deterministic OPC fixtures, with real ZIP headers/CRC and optional raw deflate. */ +import { deflateRawSync } from 'node:zlib' +export const word = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' +export const paragraph = text => `${text}` +export const document = body => `${body}` +export function crc32(bytes) { + let crc = 0xffffffff + for (const byte of bytes) { crc ^= byte; for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)) } + return (crc ^ 0xffffffff) >>> 0 +} +export function zip(entries, { deflate = false } = {}) { + const locals = [], central = []; let offset = 0 + for (const [name, value] of entries) { + const bytes = Buffer.from(value), filename = Buffer.from(name), packed = deflate ? deflateRawSync(bytes) : bytes + const header = Buffer.alloc(30); header.writeUInt32LE(0x04034b50); header.writeUInt16LE(20, 4) + header.writeUInt16LE(deflate ? 8 : 0, 8); header.writeUInt32LE(crc32(bytes), 14) + header.writeUInt32LE(packed.length, 18); header.writeUInt32LE(bytes.length, 22); header.writeUInt16LE(filename.length, 26) + const entry = Buffer.alloc(46); entry.writeUInt32LE(0x02014b50); entry.writeUInt16LE(20, 4); entry.writeUInt16LE(20, 6) + header.copy(entry, 8, 6, 28); entry.writeUInt32LE(offset, 42) + locals.push(header, filename, packed); central.push(entry, filename); offset += header.length + filename.length + packed.length + } + const directory = Buffer.concat(central), end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50) + end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(directory.length, 12); end.writeUInt32LE(offset, 16) + return Buffer.concat([...locals, directory, end]) +} +export const types = '' +export const rels = '' +export const parts = main => [['[Content_Types].xml', types], ['_rels/.rels', rels], ['word/document.xml', main]] +export const docx = (body, options) => zip(parts(document(body)), options) diff --git a/test/package-source.test.mjs b/test/package-source.test.mjs index 281ab153..84f5f71e 100644 --- a/test/package-source.test.mjs +++ b/test/package-source.test.mjs @@ -17,7 +17,9 @@ await test('npm pack includes compiled host code and native sources, never host 'native/darwin-metadata/Cargo.toml', 'native/darwin-metadata/src/lib.rs', 'native/crates/ops-documents/Cargo.toml', 'native/crates/ops-documents/src/lib.rs', 'native/crates/ops-documents/src/decode.rs', 'native/crates/ops-documents/src/parse.rs', - 'native/crates/ops-documents/src/tests.rs'] + 'native/crates/ops-documents/src/tests.rs', 'native/crates/ops-documents/src/archive.rs', + 'native/crates/ops-documents/src/xml.rs', 'native/crates/ops-documents/src/docx.rs', + 'licenses/documents/THIRD-PARTY-NOTICES.txt'] for (const name of required) assert.ok(files.includes(name), `missing source-build dependency: ${name}`) assert.equal(files.some(name => name.startsWith('src/')), false, 'host source must not ship beside compiled lib/') assert.equal(files.some(name => /^native\/(?:target[^/]*|(?:windows|darwin)-metadata\/target)\//u.test(name)), false) From d3309f5a20298aa3918d15120f790d6268ce1616 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:48:08 +0800 Subject: [PATCH 085/118] feat(documents): expose scoped DOCX paragraph readers --- docs/CHANGELOG.md | 7 ++ docs/native-kernel.md | 11 ++- ...37\347\224\237\345\206\205\346\240\270.md" | 7 +- dsh-plugin.json | 3 +- native/crates/ops-documents/src/docx.rs | 18 +++- src/capabilities/discovery.ts | 1 + src/capabilities/documents.ts | 15 ++- src/capabilities/registry.ts | 2 +- src/kernel/errors.ts | 2 + src/modes/native/documents.ts | 9 +- src/modes/ptc/continuation.ts | 4 +- test/capabilities.test.mjs | 6 +- test/docx-host.test.mjs | 93 +++++++++++++++++++ test/docx-worker.test.mjs | 1 + test/types/ptc-sdk.ts | 16 ++++ 15 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 test/docx-host.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6b1e84b6..3569edc3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +### Expose scoped DOCX paragraphs (#261, 15b/16) + +- Add discoverable Native main-body paragraphs and typed PTC outcomes with explicit excluded content, + safe prefixes on unsupported content, stable-scan-only continuation and scoped reference recovery. +- DOCX 的 Native/PTC 按需启用,明确正文范围与排除内容;缺口保留安全前缀且不伪造续取。 + ### Add bounded DOCX main-body worker observations (#261, 15b/16) - Read strict DOCX main-body paragraphs and table coordinates with explicit excluded-content diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 258e1639..c560e379 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -184,8 +184,8 @@ credential checks, cancellation and storage bindings apply unchanged. ## Bounded DOCX main-body observations (15b/16) -`doc_read` is a worker-only capability in this slice; later host/discovery integration remains -separate. Arguments are `{path, offset?, limit?}`: path must be absolute after host resolution +`doc_read` is discoverable-only: Native selects it through `ops_discover`, PTC through +`ops_sdk`; activation starts at the owning agent's next step. Arguments are `{path, offset?, limit?}`: path must be absolute after host resolution against current session cwd; offset is a 1-based paragraph index (1-100000, default 1), limit is 1-1000 (default 200). Each item is `{block,type:"paragraph",text}`; table paragraphs additionally carry 1-based table/row/cell coordinates. Empty paragraphs are retained. Tabs, line breaks, @@ -221,3 +221,10 @@ read-only sessions; transport or cache failures never imply a business mutation. Exact dependency versions, release dates, rationale and original notices are recorded in [document-dependencies.md](document-dependencies.md). + +Native displays paragraph indices, table coordinates and explicit scope/excludedContent notices, +including safe prefixes beside parse errors. PTC returns typed paragraphs inside Outcome; physical +gaps never get a looping continuation. Stable full scans with remaining paragraphs permit forward +read-only pages; compare revisions across calls. Skipped paragraphs never exceed observed blocks, +and FILE_CHANGED always invalidates requested-page completeness. Generated source metadata on +reference pages retains the declared main-body scope independently of stored-row completeness. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index eb1cb269..e2cd3257 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -149,7 +149,7 @@ Native 用带物理 LF 行范围的原位置 JSON 单元格展示,并明确提 ## 有界 DOCX 正文观察(15b/16) -本切片仅在 worker 暴露 `doc_read`,宿主与发现注册另行接入。参数 `{path, offset?, limit?}`: +`doc_read` 仅按需发现:Native 经 `ops_discover`、PTC 经 `ops_sdk` 选择,于所属代理下一步骤启用。参数 `{path, offset?, limit?}`: path 由宿主按当前会话 cwd 解析为绝对路径;offset 为从 1 开始的段落序号(1-100000,默认 1), limit 为 1-1000(默认 200)。每项为 `{block,type:"paragraph",text}`;表格内段落另有从 1 开始的 table/row/cell 坐标。保留空段落、制表符、换行、Unicode、实体字符及原文本空白。明确排除格式与 @@ -176,3 +176,8 @@ complete 仅表示存储记录分页结束。按 referenceRoot 读取。每次 只读会话不创建产物,传输或缓存失败不暗示业务写入。 精确依赖、发布日期、理由与原始许可记录见[文档解析依赖.md](文档解析依赖.md)。 + +Native 显示段落序号、表格坐标以及明确的 scope/excludedContent 范围提示,并在解析错误旁保留 +安全前缀。PTC 在 Outcome 中返回类型化段落;物理缺口不生成循环续取。稳定完整扫描且尚有后续 +段落时可只读前进分页,跨调用须比较 revision。skipped 不超过实际观察的 blocksObserved, +FILE_CHANGED 始终使所请求页不完整。引用页生成的 source 类型保留正文范围,独立于存储记录分页。 diff --git a/dsh-plugin.json b/dsh-plugin.json index 406d2e35..6f385f64 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -86,6 +86,7 @@ "read_many", "edit_many", "table_read", + "doc_read", "result" ], "shellPermission": "Per-call DSH sandbox policy and native subprocess" @@ -111,7 +112,7 @@ "targets": ["win32-x64", "linux-x64", "darwin-arm64"], "presentation": "PTC-only; reader/result/SDK loader resident, other APIs become visible on the next agent step", "runtime": "independent native Rust file worker", - "tools": ["ops_inspect_local_file","ops_grep","ops_glob","ops_replace","ops_write_file","ops_edit_file","ops_diff","ops_stat","ops_copy","ops_move","ops_remove","ops_read_many","ops_edit_many","ops_table_read","ops_batch","ops_result","ops_sdk"], + "tools": ["ops_inspect_local_file","ops_grep","ops_glob","ops_replace","ops_write_file","ops_edit_file","ops_diff","ops_stat","ops_copy","ops_move","ops_remove","ops_read_many","ops_edit_many","ops_table_read","ops_doc_read","ops_batch","ops_result","ops_sdk"], "permissions": "Per-call original DSH sandboxPolicy and sandbox.confine (host full/partial enforcement reported); full access bypasses confinement and permits explicit operation roots; subprocess owns cancellation and credential scrub" }, { diff --git a/native/crates/ops-documents/src/docx.rs b/native/crates/ops-documents/src/docx.rs index 10568f4b..0e17decf 100644 --- a/native/crates/ops-documents/src/docx.rs +++ b/native/crates/ops-documents/src/docx.rs @@ -21,6 +21,10 @@ struct Args { #[serde(default = "super::two_hundred")] limit: usize, } +fn page_complete(failure: Option<&Failure>, observed: usize, offset: usize, limit: usize) -> bool { + !failure.is_some_and(|error| error.code == "FILE_CHANGED") + && (failure.is_none() || observed >= offset + limit - 1) +} fn unsupported(name: &str) -> Failure { Failure::new( "UNSUPPORTED_DOCUMENT", @@ -351,7 +355,7 @@ pub fn read(request: &Request) -> Result { }; let mut output = json!({"status":if scan {"completed"} else {"partial"},"phase":"verify","changed":0,"effectState":"none","path":target.display,"format":"docx","scope":"main-body-paragraphs", "excludedContent":["formatting and rendered layout","headers and footers","footnotes and endnotes","comments","package metadata"], - "bytes":before.len(),"blocksObserved":page.observed,"skipped":args.offset-1,"scanComplete":scan,"pageComplete":scan || page.observed>=args.offset+args.limit-1, + "bytes":before.len(),"blocksObserved":page.observed,"skipped":(args.offset-1).min(page.observed),"scanComplete":scan,"pageComplete":page_complete(failure.as_ref(), page.observed, args.offset, args.limit), "complete":scan&&!more,"truncated":more,"truncatedBy":cause,"nextOffset":next,"items":[]}); if scan { output["total"] = json!(page.observed); @@ -384,3 +388,15 @@ pub fn read(request: &Request) -> Result { output["items"] = json!(page.items); Ok(output) } + +#[cfg(test)] +mod completeness_tests { + #[test] + fn changed_files_never_claim_the_requested_page_is_complete() { + let changed = ops_protocol::Failure::new("FILE_CHANGED", "fixture"); + assert!(!super::page_complete(Some(&changed), 10, 1, 2)); + let gap = ops_protocol::Failure::new("UNSUPPORTED_DOCUMENT", "fixture"); + assert!(super::page_complete(Some(&gap), 10, 1, 2)); + assert!(!super::page_complete(Some(&gap), 1, 1, 2)); + } +} diff --git a/src/capabilities/discovery.ts b/src/capabilities/discovery.ts index 87d5e0c7..91b1cd28 100644 --- a/src/capabilities/discovery.ts +++ b/src/capabilities/discovery.ts @@ -3,6 +3,7 @@ import type { ParameterSchema } from './types.js' /** Reviewed, currently implemented advanced operations. Future formats are absent. */ export const DISCOVERY_TERMS = Object.freeze({ + doc_read: 'docx word document paragraphs body text tables zip xml unicode', table_read: 'table csv tsv delimiter quoted records columns rows encoding utf16', inspect_local_file: 'read inspect single file text hex binary bounded line windows', edit_many: 'edit patch multiple files atomic per file revision cas', diff --git a/src/capabilities/documents.ts b/src/capabilities/documents.ts index 6453c6a9..659e13c7 100644 --- a/src/capabilities/documents.ts +++ b/src/capabilities/documents.ts @@ -8,6 +8,11 @@ const boolean: JsonSchemaNode = { type: 'boolean' } const strings: JsonSchemaNode = { type: 'array', items: string } const object = (properties: Record, required: string[] = []): ParameterSchema => ({ type: 'object', properties, required, additionalProperties: false }) export const DOCUMENT_PARAMETERS = Object.freeze({ + doc_read: object({ + path: { ...string, description: 'Regular DOCX file path relative to the current session cwd. Binary DOC is unsupported.' }, + offset: { type: 'integer', default: 1, description: '1-100000 main-body paragraphs, 1-based.' }, + limit: { type: 'integer', default: 200, description: '1-1000 paragraphs.' }, + }, ['path']), table_read: object({ path: { ...string, description: 'Regular file path relative to the current session cwd.' }, format: { type: 'string', enum: ['csv', 'tsv'], description: 'Default tsv for .tsv paths, otherwise csv.' }, @@ -19,8 +24,10 @@ export const DOCUMENT_PARAMETERS = Object.freeze({ }) export const DOCUMENT_DESCRIPTIONS = Object.freeze({ table_read: 'Read bounded CSV/TSV lexical string cells relative to session cwd. Preserves quoted multiline records and physical LF lineStart/lineEnd. Check scanComplete versus pageComplete; total/revision exist only after a stable full scan. Up to 32 MiB, 100000 data records, 1 MiB/record, 1024 columns and 30s; row paging cannot bypass scan gaps. Errors retain safe preceding rows with nextAction. Compare revisions before combining rescanned pages. No spreadsheet formulas or type coercion. Failures return status:error|rejected and error.code; never throw.', + doc_read: 'Read bounded DOCX main-body paragraphs relative to session cwd, preserving Unicode text and table/row/cell coordinates. Scope excludes layout, headers/footers, notes, comments and metadata; complete applies only to that scope. Standard stored/deflated ZIP, strict UTF-8/BOM UTF-16; unsupported content and parse errors retain safe preceding paragraphs with nextAction. Check scanComplete versus pageComplete; total/revision require a stable full scan. 32 MiB archive, 8 MiB main XML, 1 MiB/paragraph, 100000 paragraphs, 30s. Paging cannot bypass physical gaps; compare revisions across calls. No binary DOC, macros, external fetch or extraction to disk. Failures return status:error|rejected and error.code; never throw.', }) export const DOCUMENT_FACTS: Record = { + scope: string, excludedContent: strings, blocksObserved: integer, format: string, encoding: string, header: boolean, columns: strings, columnCount: integer, bytes: integer, bytesRead: integer, rowsObserved: integer, skipped: integer, scanComplete: boolean, pageComplete: boolean, complete: boolean, truncated: boolean, @@ -28,7 +35,7 @@ export const DOCUMENT_FACTS: Record = { } export const DOCUMENT_SOURCE: JsonSchemaNode = { type: 'object', properties: { ...DOCUMENT_FACTS, root: string, itemEncoding: string, capturedItems: integer, captureComplete: boolean } } -export const DOCUMENT_OUTPUT: JsonSchemaNode = { +const TABLE_OUTPUT: JsonSchemaNode = { type: 'object', properties: { ...DOCUMENT_FACTS, status: string, phase: string, changed: integer, effectState: string, path: string, items: { type: 'array', items: object({ row: integer, cells: strings, lineStart: integer, lineEnd: integer }, ['row', 'cells', 'lineStart', 'lineEnd']) }, @@ -36,5 +43,9 @@ export const DOCUMENT_OUTPUT: JsonSchemaNode = { source: DOCUMENT_SOURCE, storageError: { type: 'object' }, }, } +const DOCX_OUTPUT: JsonSchemaNode = { ...TABLE_OUTPUT, properties: { ...TABLE_OUTPUT.properties, + items: { type: 'array', items: object({ block: integer, type: { type: 'string', enum: ['paragraph'] }, text: string, + table: integer, row: integer, cell: integer }, ['block', 'type', 'text']) }, +} } export function isDocumentTool(tool: string): tool is keyof typeof DOCUMENT_PARAMETERS { return Object.hasOwn(DOCUMENT_PARAMETERS, tool) } -export function documentOutput(tool: string): JsonSchemaNode | undefined { return isDocumentTool(tool) ? DOCUMENT_OUTPUT : undefined } +export function documentOutput(tool: string): JsonSchemaNode | undefined { return tool === 'doc_read' ? DOCX_OUTPUT : tool === 'table_read' ? TABLE_OUTPUT : undefined } diff --git a/src/capabilities/registry.ts b/src/capabilities/registry.ts index 9d1f9647..c03e6a53 100644 --- a/src/capabilities/registry.ts +++ b/src/capabilities/registry.ts @@ -10,7 +10,7 @@ import type { CapabilityMetadata, Component, SchemaCapability } from './types.js export type FileToolName = keyof typeof fileParameters export type PtcToolName = keyof typeof ptcParameters // Preserve the published registration order independently of schema object key order. -const fileOrder = ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read'] as const satisfies readonly FileToolName[] +const fileOrder = ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read', 'doc_read'] as const satisfies readonly FileToolName[] const fileMutations = new Set(['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) const allPresentations = Object.freeze(['native', 'ptc', 'both'] as const) diff --git a/src/kernel/errors.ts b/src/kernel/errors.ts index bd489c6f..f9c2d12c 100644 --- a/src/kernel/errors.ts +++ b/src/kernel/errors.ts @@ -23,6 +23,8 @@ export const ERROR_POLICIES: Readonly> = Object.free CROSS_DEVICE: invalid, ERR_INVALID_ENCODING: invalid, TABLE_PARSE_ERROR: invalid, + DOCUMENT_PARSE_ERROR: invalid, + UNSUPPORTED_DOCUMENT: settled, FILE_CHANGED: changed, REFERENCE_EXPIRED: changed, AUTHORITY_CHANGED: changed, diff --git a/src/modes/native/documents.ts b/src/modes/native/documents.ts index 49551e53..1a14b18a 100644 --- a/src/modes/native/documents.ts +++ b/src/modes/native/documents.ts @@ -3,15 +3,18 @@ type Row = Record const object = (value: unknown): value is Row => !!value && typeof value === 'object' && !Array.isArray(value) const show = (value: unknown): string => (JSON.stringify(value) ?? 'null').replace(/[\u0085\u2028\u2029]/gu, char => '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0')) const facts = (value: Row, keys: string[]): string => keys.filter(key => value[key] !== undefined).map(key => key + '=' + show(value[key])).join('; ') -export function renderDocument(_tool: string, args: Row, value: Row): string[] { +export function renderDocument(tool: string, args: Row, value: Row): string[] { const items = Array.isArray(value.items) ? value.items.filter(object) : [] const lines = ['=== DOCUMENT: ' + show(value.path ?? args.path ?? '(path unavailable)') + ' ===', '[Observation: ' + facts(value, ['status', 'format', 'encoding', 'header', 'bytes', 'bytesRead', 'revision', 'columnCount']) + '.]', - '[Coverage: ' + facts(value, ['scanComplete', 'pageComplete', 'complete', 'rowsObserved', 'skipped', 'total', 'omitted', 'nextOffset', 'truncatedBy']) + '.]'] + '[Coverage: ' + facts(value, ['scanComplete', 'pageComplete', 'complete', 'rowsObserved', 'blocksObserved', 'skipped', 'total', 'omitted', 'nextOffset', 'truncatedBy']) + '.]'] + if (typeof value.scope === 'string') lines.push('[Scope: ' + show(value.scope) + '; excludedContent=' + show(value.excludedContent) + '. Completeness applies only to this scope.]') if (Array.isArray(value.columns)) lines.push('Columns: ' + show(value.columns)) if (object(value.error)) lines.push('Error: ' + facts(value.error, ['code', 'message', 'effectState', 'nextAction'])) if (value.scanComplete === false || object(value.error)) lines.push('[Notice: This observation is not exhaustive. Only preceding complete records are shown; page offsets do not recover a physical scan gap.]') - for (const item of items) lines.push('Row ' + show(item.row) + ' (LF lines ' + show(item.lineStart) + '-' + show(item.lineEnd) + ') | ' + show(item.cells)) + for (const item of items) lines.push(tool === 'table_read' + ? 'Row ' + show(item.row) + ' (LF lines ' + show(item.lineStart) + '-' + show(item.lineEnd) + ') | ' + show(item.cells) + : 'Paragraph ' + show(item.block) + ' [' + facts(item, ['table', 'row', 'cell']) + '] | ' + show(item.text)) if (!items.length) lines.push('(no records in this displayed range)') lines.push('[Notice: displayedRecords=' + items.length + '. Scan, requested range, and displayed content have separate completeness.]') if (typeof value.nextAction === 'string') lines.push('Next: ' + show(value.nextAction)) diff --git a/src/modes/ptc/continuation.ts b/src/modes/ptc/continuation.ts index 608b4e5d..a199c03b 100644 --- a/src/modes/ptc/continuation.ts +++ b/src/modes/ptc/continuation.ts @@ -2,7 +2,7 @@ import type { CanonicalData, JsonValue } from './outcome.js' const object = (value: unknown): CanonicalData | undefined => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as CanonicalData : undefined const integer = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0 -const readers = ['inspect_local_file', 'read_many', 'glob', 'grep', 'diff', 'result', 'table_read'] as const +const readers = ['inspect_local_file', 'read_many', 'glob', 'grep', 'diff', 'result', 'table_read', 'doc_read'] as const export type ContinuationTool = typeof readers[number] export interface RetrievalContinuation { version: 1, tool: ContinuationTool, args: CanonicalData } const PREFIX = 'ops-next-v1:' @@ -53,7 +53,7 @@ export function continuationFor(tool: string, rawArgs: unknown, data: CanonicalD if ((tool === 'grep' || tool === 'glob') && data.scanComplete === true && typeof data.continuation === 'string') { return encode(tool, { ...args, continuation: data.continuation, ...(integer(data.nextOffset) ? { offset: data.nextOffset } : {}) }) } - if (tool === 'table_read') { + if (tool === 'table_read' || tool === 'doc_read') { const offset = args.offset ?? 1 return data.scanComplete === true && typeof data.revision === 'string' && /^[a-f0-9]{64}$/u.test(data.revision) && integer(offset) && offset > 0 && integer(data.nextOffset) && data.nextOffset > offset diff --git a/test/capabilities.test.mjs b/test/capabilities.test.mjs index 4ea6ed71..267d4c3c 100644 --- a/test/capabilities.test.mjs +++ b/test/capabilities.test.mjs @@ -10,9 +10,9 @@ import { BASH_TOOL_NAME } from '../lib/shells/constants.js' import { assert, test, report } from './lib/harness.mjs' await test('the catalogue covers every owned file, PTC and shell tool exactly once', () => { - assert.equal(CAPABILITIES.length, 19) + assert.equal(CAPABILITIES.length, 20) assert.equal(new Set(CAPABILITIES.map(item => item.rawName)).size, CAPABILITIES.length) - assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read']) + assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read', 'doc_read']) assert.deepEqual(FILE_MUTATIONS, ['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) assert.deepEqual(PTC_TOOLS, ['batch', 'result']) assert.deepEqual(IN_PROCESS_TOOLS, ['bash']) @@ -55,7 +55,7 @@ const snapshot = (names, definition) => names.map(name => { await test('business file and batch definitions retain their original serialized contracts', () => { // Captured before this migration, covering every argument/output contract and published description. const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') - assert.equal(hash(snapshot(FILE_TOOLS.filter(name => !['glob', 'grep', 'read_many', 'edit_many', 'table_read'].includes(name)), fileDefinition)), 'f32e33306b4052fdb3d19a11e0fe9d65c255a87d2d485de08a9d7b1275e958fc') + assert.equal(hash(snapshot(FILE_TOOLS.filter(name => !['glob', 'grep', 'read_many', 'edit_many', 'table_read', 'doc_read'].includes(name)), fileDefinition)), 'f32e33306b4052fdb3d19a11e0fe9d65c255a87d2d485de08a9d7b1275e958fc') const batch = ptcDefinition('batch', async () => null) assert.equal(batch.parameters, ptcParameters.batch); assert.ok(batch.description.startsWith(ptcDescriptions.batch)); assert.match(batch.description, /PTC returns Outcome/) assert.ok(batch.output.schema.oneOf.every(branch => branch.properties.status)) diff --git a/test/docx-host.test.mjs b/test/docx-host.test.mjs new file mode 100644 index 00000000..9ca6dca1 --- /dev/null +++ b/test/docx-host.test.mjs @@ -0,0 +1,93 @@ +/** Scoped DOCX adapters retain main-body scope and safe prefix data. */ +import fs from 'node:fs' +import path from 'node:path' +import { assertSupportedJsonSchema, jsonSchemaToTs } from '@deepseek-ai/dsh-tools' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { shutdown } from './lib/host.mjs' +import { requireWorker } from './lib/boundary.mjs' +import { docx, paragraph } from './lib/document-fixture.mjs' +import { documentHost, documentNames, documentCall, documentCode, activateDocument } from './lib/document-host.mjs' +import { capability } from '../lib/capabilities/registry.js' +import { publicToolName } from '../lib/policy.js' +import { canonicalOutput } from '../lib/modes/ptc/canonical-schema.js' +import { decodeContinuation } from '../lib/modes/ptc/continuation.js' +import { definition } from '../lib/file/contract.js' +requireWorker('docx-host') +const TOOL = publicToolName('doc_read') +const render = value => definition('doc_read', async () => value).output.render({ path: 'body.docx' }, value)[0].text +await test('DOCX catalogue and generated schema preserve strict paragraph types and declared main-body limits', () => { + const entry = capability('doc_read'); assert.equal(entry.availability, 'discoverable'); assert.equal(entry.mutates, false) + assert.equal(entry.parameters.additionalProperties, false); assert.deepEqual(entry.parameters.required, ['path']) + const schema = canonicalOutput('doc_read'); assertSupportedJsonSchema(schema) + assert.equal(schema.properties.items.items.properties.text.type, 'string'); assert.equal(schema.properties.items.items.properties.cells, undefined) + assert.equal(schema.properties.source.properties.blocksObserved.type, 'integer') + assert.match(jsonSchemaToTs(schema), /type: "paragraph"/); assert.match(entry.description, /main-body|Scope excludes/) +}) +await test('real Native and PTC DOCX discovery remain scoped and typed pages retain main-body coverage', () => withTempDir('docx-host-page', async root => { + const cwd = path.join(root, 'nested'); fs.mkdirSync(cwd) + fs.writeFileSync(path.join(cwd, 'body.docx'), docx(paragraph('中😀') + '' + paragraph('0001') + '', { deflate: true })) + const { ctx, agent } = await documentHost(root, ['doc_read'], { real: true }) + try { + const native = await agent('native', { cwd }), sibling = await agent(), code = await agent('ptc', { cwd }) + assert.equal(documentNames(ctx, native).includes(TOOL), false) + await activateDocument(ctx, native, 'doc_read'); await activateDocument(ctx, code, 'doc_read', 'ptc') + assert.equal(documentNames(ctx, sibling).includes(TOOL), false) + const raw = await documentCall(ctx, native, 'doc_read', { path: 'body.docx', limit: 1 }) + assert.equal(raw.format, 'docx'); assert.equal(raw.scope, 'main-body-paragraphs'); assert.equal(raw.scanComplete, true) + assert.equal(raw.complete, false); assert.equal(raw.total, 2); assert.equal(raw.items[0].text, '中😀') + const text = render(raw); assert.match(text, /Paragraph 1/); assert.match(text, /main-body-paragraphs/) + assert.match(text, /headers and footers/); assert.match(text, /Completeness applies only to this scope/) + const result = await documentCode(ctx, code, `return await tools.${TOOL}({path:'body.docx',limit:1});`) + assert.equal(result.status, 'partial'); assert.equal(result.data.scope, 'main-body-paragraphs') + const next = decodeContinuation(result.continuation); assert.equal(next.tool, 'doc_read'); assert.equal(next.args.offset, 2) + const tail = await documentCall(ctx, code, next.tool, next.args) + assert.equal(tail.status, 'completed'); assert.equal(tail.data.revision, result.data.revision) + assert.deepEqual(tail.data.items[0], { block: 2, type: 'paragraph', text: '0001', table: 1, row: 1, cell: 1 }) + } finally { await shutdown(ctx) } +})) +await test('unsupported late DOCX content preserves paragraphs and scope in both projections without looping cursors', () => withTempDir('docx-host-gap', async root => { + fs.writeFileSync(path.join(root, 'body.docx'), docx(paragraph('safe prefix') + '')) + const { ctx, agent } = await documentHost(root, ['doc_read']) + try { + const native = await agent(), code = await agent('ptc') + await activateDocument(ctx, native, 'doc_read'); await activateDocument(ctx, code, 'doc_read', 'ptc') + const raw = await documentCall(ctx, native, 'doc_read', { path: 'body.docx', limit: 1 }) + assert.equal(raw.scanComplete, false); assert.equal(raw.pageComplete, true); assert.equal(raw.total, undefined) + assert.match(render(raw), /UNSUPPORTED_DOCUMENT/); assert.match(render(raw), /Paragraph 1.*safe prefix/); assert.match(render(raw), /not exhaustive/) + const result = await documentCall(ctx, code, 'doc_read', { path: 'body.docx', limit: 1 }) + assert.equal(result.status, 'error'); assert.equal(result.error.code, 'UNSUPPORTED_DOCUMENT'); assert.equal(result.error.effectState, 'none') + assert.equal(result.error.retry, 'never'); assert.equal(result.continuation, undefined); assert.equal(result.data.items[0].text, 'safe prefix') + assert.match(result.error.nextAction, /dedicated DOCX reader/) + } finally { await shutdown(ctx) } +})) +await test('read-only DOCX reads retain credential refusal and explicit unsupported binary DOC values', () => withTempDir('docx-host-authority', async root => { + fs.writeFileSync(path.join(root, 'body.docx'), docx(paragraph('readonly'))) + fs.writeFileSync(path.join(root, 'id_ed25519.docx'), docx(paragraph('SECRET'))) + fs.writeFileSync(path.join(root, 'old.doc'), Buffer.from('d0cf11e0a1b11ae1', 'hex')) + const before = fs.readdirSync(root).sort(), { ctx, agent } = await documentHost(root, ['doc_read']) + try { + const owner = await agent('ptc', { policy: 'read-only' }); await activateDocument(ctx, owner, 'doc_read', 'ptc') + assert.equal((await documentCall(ctx, owner, 'doc_read', { path: 'body.docx' })).status, 'completed') + const denied = await documentCall(ctx, owner, 'doc_read', { path: 'id_ed25519.docx' }) + assert.equal(denied.error.code, 'CREDENTIAL_DENIED'); assert.equal(denied.error.effectState, 'none'); assert.doesNotMatch(JSON.stringify(denied), /SECRET/) + const old = await documentCall(ctx, owner, 'doc_read', { path: 'old.doc' }) + assert.equal(old.error.code, 'UNSUPPORTED_DOCUMENT'); assert.equal(old.continuation, undefined) + assert.deepEqual(fs.readdirSync(root).sort(), before) + } finally { await shutdown(ctx) } +})) +await test('large DOCX paragraphs spill with exact paragraph chunks and independently stated source scope', () => withTempDir('docx-host-spill', async root => { + fs.writeFileSync(path.join(root, 'body.docx'), docx(paragraph('😀'.repeat(40000)), { deflate: true })) + const { ctx, agent } = await documentHost(root, ['doc_read', 'result']) + try { + const owner = await agent(); await activateDocument(ctx, owner, 'doc_read'); await activateDocument(ctx, owner, 'result') + const raw = await documentCall(ctx, owner, 'doc_read', { path: 'body.docx' }) + assert.equal(raw.reference.producerTool, 'doc_read'); assert.equal(raw.complete, false); assert.equal(raw.scanComplete, true) + const content = render(raw); assert.ok(Buffer.byteLength(JSON.stringify([{ type: 'text', text: content }])) <= 65536) + assert.match(content, /ref:\/\//); assert.match(content, /main-body-paragraphs/) + const page = await documentCall(ctx, owner, 'result', { action: 'page', id: raw.reference.refId, root: raw.referenceRoot, limit: 1 }) + assert.equal(page.source.scope, 'main-body-paragraphs'); assert.equal(page.source.blocksObserved, 1) + assert.equal(page.source.captureComplete, true); assert.ok(page.source.excludedContent.includes('headers and footers')) + assert.equal(page.items[0].kind, 'item-json-chunk'); assert.equal(page.items[0].byteOffset, 0) + } finally { await shutdown(ctx) } +})) +report('docx-host') diff --git a/test/docx-worker.test.mjs b/test/docx-worker.test.mjs index e0e331a9..c26f55b0 100644 --- a/test/docx-worker.test.mjs +++ b/test/docx-worker.test.mjs @@ -45,6 +45,7 @@ await test('paragraph paging reports full totals separately and empty documents const file = fileAt(root, docx(Array.from({ length: 205 }, (_, i) => paragraph(String(i + 1))).join(''))) const first = await run(root, { path: file }); assert.equal(first.items.length, 200); assert.equal(first.nextOffset, 201); assert.equal(first.total, 205); assert.equal(first.scanComplete, true); assert.equal(first.pageComplete, true); assert.equal(first.complete, false) const last = await run(root, { path: file, offset: 201, limit: 5 }); assert.deepEqual(last.items.map(i => i.text), ['201', '202', '203', '204', '205']); assert.equal(last.complete, true) + const beyond = await run(root, { path: file, offset: 999 }); assert.equal(beyond.skipped, 205); assert.deepEqual(beyond.items, []); assert.equal(beyond.complete, true) const empty = await run(root, { path: fileAt(root, docx('')) }); assert.equal(empty.total, 0); assert.deepEqual(empty.items, []); assert.equal(empty.complete, true) for (const args of [{ offset: 0 }, { offset: 100001 }, { limit: 0 }, { limit: 1001 }, { unknown: true }]) failure(await run(root, { path: file, ...args }), 'INVALID_ARGUMENT') }) diff --git a/test/types/ptc-sdk.ts b/test/types/ptc-sdk.ts index 61f76c46..3d8bdb05 100644 --- a/test/types/ptc-sdk.ts +++ b/test/types/ptc-sdk.ts @@ -54,3 +54,19 @@ async function tables() { // @ts-expect-error Encoding is a finite strict-decoding contract. void tools.ops_table_read({ path: 'rows.csv', encoding: 'latin1' }) void tables + +async function paragraphs() { + const value = await tools.ops_doc_read({ path: 'body.docx', offset: 1, limit: 10 }) + if (value.status === 'completed' || value.status === 'partial') { + const text: string | undefined = value.data.items?.[0]?.text + const kind: 'paragraph' | undefined = value.data.items?.[0]?.type + const scope: string | undefined = value.data.scope + // @ts-expect-error Document paragraphs are not table cell arrays. + void value.data.items?.[0]?.cells + return { text, kind, scope } + } + return value.data?.items?.[0]?.text +} +// @ts-expect-error DOCX decoding comes from the package, not a caller-supplied encoding. +void tools.ops_doc_read({ path: 'body.docx', encoding: 'utf-8' }) +void paragraphs From cbf4442270980124e61c063f5bb52643ebc2d0fd Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:46:16 +0800 Subject: [PATCH 086/118] refactor(config): type validation and component lifecycle boundaries --- AGENTS.md | 2 +- docs/CHANGELOG.md | 3 ++ docs/CONTRIBUTING.md | 7 ++- ...41\347\214\256\346\214\207\345\215\227.md" | 4 +- .../background/{index.js => index.ts} | 3 +- src/components/tui/{index.js => index.ts} | 13 +++++- src/{config-updates.js => config-updates.ts} | 24 ++++++---- src/{config.js => config.ts} | 46 +++++++++---------- test/types/config.ts | 30 ++++++++++++ test/types/tsconfig.json | 3 +- 10 files changed, 92 insertions(+), 43 deletions(-) rename src/components/background/{index.js => index.ts} (84%) rename src/components/tui/{index.js => index.ts} (72%) rename src/{config-updates.js => config-updates.ts} (86%) rename src/{config.js => config.ts} (81%) create mode 100644 test/types/config.ts diff --git a/AGENTS.md b/AGENTS.md index 83042cdd..b2e1475f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ node scripts/verify-gate.mjs --skip-install # the full release gate - English for code, comments, CLI output and commit messages; user-facing docs in both languages, updated in the same PR. - Tool names always come from `publicToolName()` in `src/policy.ts`; never hand-write `ops_`. -- Config is validated in `src/config.js`; unknown keys are errors that name the key. Defaults in +- Config is validated in `src/config.ts`; unknown keys are errors that name the key. Defaults in `cordis.patch.yml` must equal the defaults in code. - The host half must not import `@deepseek-ai/*` values at load time; use `ctx.inject` / `ctx.get`. Publish tools only through this plugin's own scoped `ctx.tools.register`. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e28c1dea..e6340dc1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,9 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Type central configuration validation, deferred profile migrations, and the remaining TUI/background entries strictly; retain their emitted paths, defaults, validation errors and lifecycle behavior. +- 将中央配置校验、延迟 profile 迁移及剩余 TUI/background 入口迁为严格 TypeScript;保留产物路径、默认值、校验错误及生命周期行为。 + ### Type the component host entry (#261, 16/16) - Migrate the root component entry to strict TypeScript using public Cordis and Agent types; preserve configuration validation, scoped registration, shell denial and teardown behavior. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 0ff1bbeb..d82d066f 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -65,9 +65,8 @@ be edited by hand. Package exports, component entries and tests continue to load Run `npm run build` after changing source or assets and before invoking a suite directly with `node test/run.mjs `. `npm test`, `npm run test:ptc` and `npm run verify` build first; `npm run typecheck` checks the compiler configuration and migrated TypeScript without emitting -files. JavaScript type checking remains disabled during the migration. The shell execution handle, -host contract, approvals, jobs, session executor and runtime manager use strict TypeScript; older -resolver, IPC and mount modules remain JavaScript with typed boundaries while their migration is staged. +files. JavaScript type checking remains disabled during the migration. Host execution boundaries, central configuration validation and migration, and component entry modules +use strict TypeScript. The compiler accepts remaining JavaScript until the final migration audit. For a local or Git-source installation, build and pack a checkout before installing it into DSH: @@ -158,7 +157,7 @@ catalogue, retry rules and positive/negative TypeScript contracts. Node declarat `test/lib/` layout. - **Tool names** always come from `publicToolName()` in `src/policy.ts`; never hand-write the `ops_` prefix. -- **Config validation** lives in `src/config.js`; an unknown key is an error that names the key. +- **Config validation** lives in `src/config.ts`; an unknown key is an error that names the key. Defaults in `cordis.patch.yml` must match the defaults in code. - **Parsing and probing** happen synchronously in `apply`; `required: true` must refuse activation at load time. diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 9f489697..9126d25e 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -49,7 +49,7 @@ npm run verify 插件源码在 `src/` 中修改。迁移期间 TypeScript 编译器兼容现有 JavaScript,并将 JavaScript 产物输出到 `lib/`;`npm run build` 会重建该目录,并复制组件 manifest、语言文件、图标和面板 HTML。`lib/` 已被 Git 忽略,不得手动修改。包导出、组件入口和测试仍然加载 `lib/`。 -修改源码或资源后,先运行 `npm run build`,再通过 `node test/run.mjs <过滤词>` 直接运行套件。`npm test`、`npm run test:ptc` 与 `npm run verify` 会先构建;`npm run typecheck` 检查编译器配置和已迁移的 TypeScript,不生成产物。迁移期间暂不启用 JavaScript 类型检查。Shell 执行句柄、宿主契约、审批、jobs、会话执行器和运行时管理器已使用严格 TypeScript;旧解析器、IPC 和挂载模块暂保留 JavaScript,并通过类型化边界分阶段迁移。 +修改源码或资源后,先运行 `npm run build`,再通过 `node test/run.mjs <过滤词>` 直接运行套件。`npm test`、`npm run test:ptc` 与 `npm run verify` 会先构建;`npm run typecheck` 检查编译器配置和已迁移的 TypeScript,不生成产物。迁移期间暂不启用 JavaScript 类型检查。宿主执行边界、中央配置校验与迁移及组件入口模块已使用严格 TypeScript。最终迁移审计完成前,编译器仍兼容剩余 JavaScript。 从本地目录或 Git 源码安装时,先在检出的仓库中构建并打包,再安装到 DSH: @@ -103,7 +103,7 @@ npm run build:bash-ipc # 仅 Windows:Bash IPC - **文档成对**:`docs/` 下同一文档中英各一份,例如 `docs/usage.md` 与 `docs/使用指南.md`,同一个 PR 里一起更新。 - **文件规模**:新文件保持在约 500 行以内。文件超过这个规模时,拆成模块,并把相关模块归到一个文件夹中,参照现有的 `src/components/*` 与 `test/lib/` 布局。 - **工具名**:一律经 `src/policy.ts` 的 `publicToolName()` 生成,不要手写 `ops_` 前缀。 -- **配置校验**:放在 `src/config.js`,未知键必须报错并点名;`cordis.patch.yml` 里的默认值必须与代码默认值一致。 +- **配置校验**:放在 `src/config.ts`,未知键必须报错并点名;`cordis.patch.yml` 里的默认值必须与代码默认值一致。 - **解析与探测**:同步放在 `apply` 里;`required: true` 必须在加载期拒绝激活。 ## 提交与 PR diff --git a/src/components/background/index.js b/src/components/background/index.ts similarity index 84% rename from src/components/background/index.js rename to src/components/background/index.ts index bf984f99..c8707d73 100644 --- a/src/components/background/index.js +++ b/src/components/background/index.ts @@ -5,9 +5,10 @@ * Existing profiles still carry this row, so it keeps loading: it accepts any config without * validating it, publishes nothing, and says once that the row can be deleted. */ +import type { Context } from '@deepseek-ai/cordis' export const name = "dsh-ops-background" export const REMOVED_MESSAGE = "dsh-ops: the dsh-ops/background component (FastCtx background jobs) was removed in this version; it publishes no tools. Delete this row from the profile configuration." -export function apply(ctx, _config) { +export function apply(ctx: Context | null | undefined, _config?: unknown): void { const logger = ctx?.logger if (typeof logger?.warn === "function") logger.warn(REMOVED_MESSAGE) else console.warn(REMOVED_MESSAGE) diff --git a/src/components/tui/index.js b/src/components/tui/index.ts similarity index 72% rename from src/components/tui/index.js rename to src/components/tui/index.ts index ae65ad33..b33c9c70 100644 --- a/src/components/tui/index.js +++ b/src/components/tui/index.ts @@ -1,4 +1,7 @@ /** Optional read-only panel. Missing tuiPanels leaves only this component pending. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-session' +import type { AttentionLevel } from '../../tui/attention.js' import { createActivityStore, resolveActivityConfig } from '../../tui/activity.js' import { createOpsPanel } from '../../tui/panel.js' import { createAttentionStore } from '../../tui/attention.js' @@ -8,9 +11,15 @@ export const name = 'dsh-ops-tui' export const inject = ['tuiPanels'] export { resolveActivityConfig as resolveTuiConfig } -export function apply(ctx, rawConfig) { +/** Narrow injected API-v1 surface, independent of a host TUI/React installation. */ +export interface TuiPanels { + register(descriptor: { apiVersion: 1; id: string; title: string; minColumns: number; component: ReturnType }): unknown + list(): readonly { id: string }[] + badge(id: string, value: { level: AttentionLevel | undefined; unread: number } | null): void +} +export function apply(ctx: Context, rawConfig?: unknown): void { const config = resolveActivityConfig(rawConfig) - const panels = ctx.get('tuiPanels') + const panels = ctx.get('tuiPanels') as TuiPanels const store = createActivityStore(config) const attention = createAttentionStore() const dispose = panels.register({ apiVersion: 1, id: 'overview', title: 'OPS', minColumns: 28, diff --git a/src/config-updates.js b/src/config-updates.ts similarity index 86% rename from src/config-updates.js rename to src/config-updates.ts index c10e3ce5..fceddbaf 100644 --- a/src/config-updates.js +++ b/src/config-updates.ts @@ -7,14 +7,20 @@ * profile or rename dsh-ops/file: name-qualified overrides would be skipped. * Explicit legacy panel capture:false is retained; no migration may start capture. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-config-editor' import { LEGACY_FASTCTX_KEYS } from './config.js' export const CONFIG_UPDATE_VERSION = 2 /** Apply every pending step in order; each step runs exactly once per row. */ -export function updatedConfig(raw = {}, disableEvaluatedFallback = false) { - const version = Number.isSafeInteger(raw?.configUpdateVersion) ? raw.configUpdateVersion : 0 - if (version >= CONFIG_UPDATE_VERSION) return raw +export function updatedConfig(value: unknown = {}, disableEvaluatedFallback = false): Record { + // The component validates effective config first; persisted raw rows can still + // contain loader expressions, so preserve their unknown values until the editor validates. + const raw = value as Record | null | undefined + const marker = raw?.configUpdateVersion + const version = typeof marker === 'number' && Number.isSafeInteger(marker) ? marker : 0 + if (version >= CONFIG_UPDATE_VERSION) return raw! const next = { ...raw } // 1 (0.2.5): retire the old implicit system-bash opt-in. Later manual opt-ins are kept. if (version < 1 && (raw?.allowSystemShellFallback === true || disableEvaluatedFallback)) next.allowSystemShellFallback = false @@ -35,7 +41,7 @@ const pending = new WeakSet() const notedInMemory = new WeakSet() const warnedSaveFailure = new WeakSet() /** True only the first time this logger is seen, so a notice is per process, not per row. */ -function firstSight(set, logger) { +function firstSight(set: WeakSet, logger: unknown): boolean { // `WeakSet` keys must be objects; a logger that is missing or not one cannot be tracked, and // there is nothing to log to anyway. if (!logger || (typeof logger !== 'object' && typeof logger !== 'function')) return false @@ -43,11 +49,11 @@ function firstSight(set, logger) { set.add(logger) return true } -function noteInMemoryOnly(ctx, reason) { +function noteInMemoryOnly(ctx: Context, reason: string): void { if (!firstSight(notedInMemory, ctx.logger)) return ctx.logger.info?.(`dsh-ops: config update ${CONFIG_UPDATE_VERSION} applied in memory only (${reason}); the migration is in effect for this activation.`) } -function warnSaveFailure(ctx, error) { +function warnSaveFailure(ctx: Context, error: unknown): void { if (!firstSight(warnedSaveFailure, ctx.logger)) return // Anything can be thrown: `error.message` on `undefined`/`null` would raise from inside the very // handler that promises to report a failed save, turning it into an unhandled rejection. @@ -80,8 +86,10 @@ function warnSaveFailure(ctx, error) { * it never writes anything, never surfaces an error into the boot path, and never * needs an owning row to be correct — while the save itself is #138's channel. */ -export function mountConfigUpdates(ctx, raw) { - if ((raw?.configUpdateVersion ?? 0) >= CONFIG_UPDATE_VERSION) return +export function mountConfigUpdates(ctx: Context, value: unknown): void { + // The entry point already validated this effective configuration. + const raw = value as Record | null | undefined + if (((raw?.configUpdateVersion ?? 0) as number) >= CONFIG_UPDATE_VERSION) return const entry = ctx.fiber?.entry // A row this plugin does not own is none of our business: say nothing (#138's scope guard keeps // the no-op `dsh-ops/background` stub out of the save path). A context with no owning row at all diff --git a/src/config.js b/src/config.ts similarity index 81% rename from src/config.js rename to src/config.ts index db78dc20..bbfab175 100644 --- a/src/config.js +++ b/src/config.ts @@ -38,7 +38,7 @@ export { ConfigError } from './config-error.js' * @param {string[]} known - accepted keys. * @returns {void} */ -function rejectUnknownKeys(raw, known) { +function rejectUnknownKeys(raw: Record, known: readonly string[]): void { const unknown = Object.keys(raw).filter((key) => !known.includes(key)) if (unknown.length > 0) { throw new ConfigError( @@ -54,7 +54,7 @@ function rejectUnknownKeys(raw, known) { * @param {string} key - the key to read. * @returns {string|undefined} the value, or undefined when absent. */ -function optionalString(raw, key) { +function optionalString(raw: Record, key: string): string | undefined { const value = raw[key] if (value === undefined || value === null) return undefined if (typeof value !== 'string' || value.trim() === '') { @@ -70,7 +70,7 @@ function optionalString(raw, key) { * @param {boolean} fallback - the value when the key is absent. * @returns {boolean} the value. */ -function optionalBoolean(raw, key, fallback) { +function optionalBoolean(raw: Record, key: string, fallback: boolean): boolean { const value = raw[key] if (value === undefined || value === null) return fallback if (typeof value !== 'boolean') { @@ -86,7 +86,7 @@ function optionalBoolean(raw, key, fallback) { * @param {string[]} fallback - the value when the key is absent. * @returns {string[]} the value. */ -function optionalStringList(raw, key, fallback) { +function optionalStringList(raw: Record, key: string, fallback: string[]): string[] { const value = raw[key] if (value === undefined || value === null) return fallback if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string' || entry.trim() === '')) { @@ -95,34 +95,32 @@ function optionalStringList(raw, key, fallback) { return [...value] } -/** - * The resolved configuration, with every default applied. - * @typedef {object} ResolvedConfig - * @property {number} configUpdateVersion - per-row completed configuration migration version. - * @property {string|undefined} binaryPath - explicit native worker executable. - * @property {boolean} required - fail activation when the native worker is unavailable. - * @property {boolean} denyCredentialReads - refuse reads of credential locations. - * @property {'advise'|'deny-host-shell'} shellPolicy - treatment of host shell tools. - * @property {string[]} deniedHostTools - tool names refused by `deny-host-shell`. - * @property {boolean} promptPolicy - inject the repository-tooling prompt section. - * @property {string} extraGuidance - extra text appended to that section. - * @property {string|undefined} bashPath - explicit executable for the plugin's bash layer. - * @property {boolean} publishBashTool - publish ops_bash in full-access sessions with a usable bash and subprocess service. - * @property {boolean} allowSystemShellFallback - let the bash rung use a - * system-installed bash when the plugin carries no copy of its own. - */ +/** The resolved configuration, with every default applied. */ +export interface ResolvedConfig { + configUpdateVersion: number + binaryPath: string | undefined + required: boolean + denyCredentialReads: boolean + shellPolicy: 'advise' | 'deny-host-shell' + deniedHostTools: string[] + promptPolicy: boolean + extraGuidance: string + bashPath: string | undefined + publishBashTool: boolean + allowSystemShellFallback: boolean +} /** * Validate one plugin row's config. * @param {unknown} rawConfig - the `config` value from the loader row. * @returns {ResolvedConfig} the resolved configuration. */ -export function resolveConfig(rawConfig) { +export function resolveConfig(rawConfig?: unknown): ResolvedConfig { if (rawConfig === undefined || rawConfig === null) rawConfig = {} if (typeof rawConfig !== 'object' || Array.isArray(rawConfig)) { throw new ConfigError(`config must be an object, got ${JSON.stringify(rawConfig)}`) } - const raw = /** @type {Record} */ (rawConfig) + const raw = rawConfig as Record rejectUnknownKeys(raw, [ 'binaryPath', 'serverName', @@ -141,7 +139,7 @@ export function resolveConfig(rawConfig) { ]) const configUpdateVersion = raw.configUpdateVersion ?? 0 - if (!Number.isSafeInteger(configUpdateVersion) || configUpdateVersion < 0) { + if (typeof configUpdateVersion !== 'number' || !Number.isSafeInteger(configUpdateVersion) || configUpdateVersion < 0) { throw new ConfigError('configUpdateVersion must be a non-negative safe integer') } @@ -171,7 +169,7 @@ export function resolveConfig(rawConfig) { configUpdateVersion, binaryPath: optionalString(raw, 'binaryPath'), required: optionalBoolean(raw, 'required', false), - shellPolicy: /** @type {'advise'|'deny-host-shell'} */ (shellPolicy), + shellPolicy: shellPolicy as ResolvedConfig['shellPolicy'], deniedHostTools: optionalStringList(raw, 'deniedHostTools', DEFAULT_DENIED_HOST_TOOLS), promptPolicy: optionalBoolean(raw, 'promptPolicy', true), extraGuidance: optionalString(raw, 'extraGuidance') ?? '', diff --git a/test/types/config.ts b/test/types/config.ts new file mode 100644 index 00000000..a58558a9 --- /dev/null +++ b/test/types/config.ts @@ -0,0 +1,30 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { ResolvedConfig } from '../../src/config.js' +import type { TuiPanels } from '../../src/components/tui/index.js' +import { resolveConfig } from '../../src/config.js' +import { updatedConfig, mountConfigUpdates } from '../../src/config-updates.js' +import { apply as applyTui } from '../../src/components/tui/index.js' +import { apply as applyBackground } from '../../src/components/background/index.js' + +declare const raw: unknown, ctx: Context, panels: TuiPanels +const config: ResolvedConfig = resolveConfig(raw) +const required: boolean = config.required +const executable: string | undefined = config.bashPath +const policy: 'advise' | 'deny-host-shell' = config.shellPolicy +const migrated: Record = updatedConfig(raw) +mountConfigUpdates(ctx, raw) +applyTui(ctx, raw) +applyBackground(ctx, raw) +// @ts-expect-error Validated booleans cannot be confused with string values. +const invalid: string = config.denyCredentialReads +// @ts-expect-error A shell policy must remain one of the validated alternatives. +config.shellPolicy = 'allow' +// @ts-expect-error A raw migration does not validate unknown saved expressions. +const unvalidated: boolean = migrated.allowSystemShellFallback +// @ts-expect-error Evaluated fallback control must be a boolean. +updatedConfig({}, 'false') +// @ts-expect-error Component mounting requires the host context contract. +applyTui({}, raw) +// @ts-expect-error Panel badges cannot carry an undeclared alert level. +panels.badge('overview', { level: 'critical', unread: 1 }) +void [required, executable, policy, invalid, unvalidated] diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json index f665d0db..b26d7ea3 100644 --- a/test/types/tsconfig.json +++ b/test/types/tsconfig.json @@ -11,6 +11,7 @@ "tui.ts", "authority.ts", "profile-temp.ts", - "host-entry.ts" + "host-entry.ts", + "config.ts" ] } From 88ced474935f7bda42d34ffd9b5a5ac04341234e Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:12:55 +0800 Subject: [PATCH 087/118] feat(documents): validate bounded classic PDF containers --- docs/CHANGELOG.md | 9 + docs/document-dependencies.md | 18 + docs/native-kernel.md | 21 ++ ...37\347\224\237\345\206\205\346\240\270.md" | 17 + ...43\346\236\220\344\276\235\350\265\226.md" | 15 + native/Cargo.lock | 1 + native/crates/ops-documents/Cargo.toml | 1 + native/crates/ops-documents/src/lib.rs | 2 + .../crates/ops-documents/src/pdf_container.rs | 329 ++++++++++++++++ .../ops-documents/src/pdf_container_tests.rs | 313 +++++++++++++++ native/crates/ops-documents/src/pdf_syntax.rs | 357 ++++++++++++++++++ test/package-source.test.mjs | 2 + 12 files changed, 1085 insertions(+) create mode 100644 native/crates/ops-documents/src/pdf_container.rs create mode 100644 native/crates/ops-documents/src/pdf_container_tests.rs create mode 100644 native/crates/ops-documents/src/pdf_syntax.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3569edc3..5f41fd03 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,15 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm safe prefixes on unsupported content, stable-scan-only continuation and scoped reference recovery. - DOCX 的 Native/PTC 按需启用,明确正文范围与排除内容;缺口保留安全前缀且不伪造续取。 +### Add bounded PDF container foundation (#261, 15c/16) + +- Validate a restricted classic-xref PDF grammar, exact object boundaries and bounded + values/references before extraction; verify actual Flate output, checksum and terminator. +- Reuse the reviewed exact compression dependency without new packages. Worker text extraction + follows separately; this foundation makes no public PDF-tool or complete-text claim. +- 新增有界传统 xref PDF 容器基础,校验对象边界、引用与实际解压;复用已核验压缩依赖, + 不新增依赖包,工具提取能力由下一切片接入。 + ### Add bounded DOCX main-body worker observations (#261, 15b/16) - Read strict DOCX main-body paragraphs and table coordinates with explicit excluded-content diff --git a/docs/document-dependencies.md b/docs/document-dependencies.md index 114ddff8..65cc2bf5 100644 --- a/docs/document-dependencies.md +++ b/docs/document-dependencies.md @@ -49,3 +49,21 @@ bindings. Unsupported content yields explicit errors or partial observations. PDF parsing has a separate follow-up review. This slice neither adds a PDF dependency nor claims PDF support; the table/DOCX readers are not substitutes for the PDF benchmark. + +## PDF foundation review (15c/16, 2026-10-19) + +The original bounded container grammar adds a direct exact dependency on flate2 1.1.10 +with defaults disabled and zlib-rs selected. Both versions were already locked, licensed +and reviewed above; this adds no resolved package or version. The low-level decoder +checks actual decoded bytes, the zlib checksum/terminator and trailing input. + +A general parser was evaluated and declined for this narrow slice. lopdf 0.42 predates +the configurable stream limits added in 0.44; newer releases also bring a mandatory +crypto dependency tree despite disabled default features. The reviewed crypto buffer +conversion required the [upstream inout correction](https://github.com/RustCrypto/utils/pull/1556), +whose follow-up 0.2.4 release was younger than seven days at review. No affected older +version or vendored patch was selected to evade the age rule. None of the evaluated +lopdf/crypto packages remains in this lockfile. See the publisher's +[lopdf changelog](https://github.com/J-F-Liu/lopdf/blob/main/CHANGELOG.md) and +[inout 0.2.4 release metadata](https://crates.io/api/v1/crates/inout/0.2.4). +This is a targeted source review, not a general PDF compliance or security-audit claim. diff --git a/docs/native-kernel.md b/docs/native-kernel.md index c560e379..aff0723c 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -228,3 +228,24 @@ gaps never get a looping continuation. Stable full scans with remaining paragrap read-only pages; compare revisions across calls. Skipped paragraphs never exceed observed blocks, and FILE_CHANGED always invalidates requested-page completeness. Generated source metadata on reference pages retains the declared main-body scope independently of stored-row completeness. + +## PDF parser foundation (15c/16) + +The internal PDF container parser accepts a restricted PDF 1.0-1.7 subset: one classic +xref table, an exact final startxref/EOF footer, unique dictionary keys and object +identities, and directly declared stream lengths. Every active xref offset must point +exactly at its object header. Objects cannot overlap; gaps must contain only whitespace +or comments. This deliberately rejects incremental updates, encryption, object/xref +streams, recovery scans and ambiguous duplicate data, including decoded forbidden names. + +Bounds are checked before indexed allocation: 16 MiB per input, 10,000 xref entries, +100,000 metadata values, depth 64, 4,096 keys per dictionary, 256 bytes per name/token, +and 1 MiB per string or encoded/decoded stream. Reference chains have cycle detection +and a depth bound. Streams support no filter or one FlateDecode only; external streams, +filter chains and DecodeParms are declined. Actual decompression stops at the decoded +limit plus one byte and requires a valid checksum, zlib terminator and exact consumption +of the input. No size advertised by a PDF author bypasses these limits. + +This slice exposes no tool and makes no text-coverage claim. Page-tree interpretation, +the supported font/encoding subset, partial results and worker authority checks are +introduced together in the following PDF extraction slice. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index e2cd3257..2ee05844 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -181,3 +181,20 @@ Native 显示段落序号、表格坐标以及明确的 scope/excludedContent 安全前缀。PTC 在 Outcome 中返回类型化段落;物理缺口不生成循环续取。稳定完整扫描且尚有后续 段落时可只读前进分页,跨调用须比较 revision。skipped 不超过实际观察的 blocksObserved, FILE_CHANGED 始终使所请求页不完整。引用页生成的 source 类型保留正文范围,独立于存储记录分页。 + +## PDF 解析基础(15c/16) + +内部 PDF 容器解析器仅接受受限的 PDF 1.0–1.7 子集:单个传统 xref 表、 +准确的末尾 startxref/EOF、唯一字典键及对象标识,以及直接整数形式的流长度。 +每个有效 xref 偏移必须准确指向对象头;对象不可重叠,间隙只能包含空白或注释。 +增量更新、加密、对象流/xref 流、恢复扫描及重复歧义数据均被拒绝,名称转义后 +仍会检查不支持的结构名称。 + +在索引分配前实施边界:输入 16 MiB、xref 条目 10,000、元数据值 100,000、 +嵌套深度 64、每字典 4,096 个键、名称/词元 256 字节、字符串及编码/解码流 +各 1 MiB。间接引用检查循环及深度。流仅支持无过滤或单个 FlateDecode; +外部流、过滤链及 DecodeParms 被拒绝。解压按实际输出限制加一字节停止, +并核验校验和、zlib 结束标记及完整输入消费;文档声明长度不能绕过实际边界。 + +本切片不暴露工具,也不宣称提取了完整文本。页面树、支持的字体/编码子集、 +部分结果及 worker 权限检查将在下一 PDF 提取切片中一并接入。 diff --git "a/docs/\346\226\207\346\241\243\350\247\243\346\236\220\344\276\235\350\265\226.md" "b/docs/\346\226\207\346\241\243\350\247\243\346\236\220\344\276\235\350\265\226.md" index e961b593..f0f73fd9 100644 --- "a/docs/\346\226\207\346\241\243\350\247\243\346\236\220\344\276\235\350\265\226.md" +++ "b/docs/\346\226\207\346\241\243\350\247\243\346\236\220\344\276\235\350\265\226.md" @@ -38,3 +38,18 @@ XML 禁用 DTD 与自定义实体、严格 UTF-8 或 BOM 标记 UTF-16 解码、 不支持的内容返回明确错误或部分观察结果。 PDF 解析另有后续审查。本切片不增加 PDF 依赖、不声明 PDF 支持;CSV/DOCX 不能替代 PDF 基准。 + +## PDF 基础依赖核验(15c/16,2026-10-19) + +自主实现的有界容器语法仅直接引用已精确锁定的 flate2 1.1.10,关闭默认功能并 +选择 zlib-rs。两者版本、许可及日期已在上文核验,不新增任何解析依赖包或版本。 +底层解码同时核验实际输出大小、zlib 校验和/结束标记及尾随输入。 + +本切片评估后未采用通用解析库。lopdf 0.42 尚无 0.44 才加入的可配置流限制; +较新版本即使关闭默认功能仍引入必选加密依赖树。核验发现相关缓冲区转换需 +[上游 inout 修复](https://github.com/RustCrypto/utils/pull/1556),其后续 0.2.4 +在核验时发布不足七日;没有为满足日期规则选用受影响旧版,也没有维护私有补丁。 +候选 lopdf/加密依赖均未保留于锁文件。可复核 +[lopdf 更新日志](https://github.com/J-F-Liu/lopdf/blob/main/CHANGELOG.md)及 +[inout 0.2.4 发布信息](https://crates.io/api/v1/crates/inout/0.2.4)。 +这属于针对性源码核验,并非全面 PDF 合规或安全审计结论。 diff --git a/native/Cargo.lock b/native/Cargo.lock index 77ca47e6..e900770f 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -368,6 +368,7 @@ dependencies = [ name = "ops-documents" version = "0.3.1" dependencies = [ + "flate2", "ops-files", "ops-protocol", "quick-xml", diff --git a/native/crates/ops-documents/Cargo.toml b/native/crates/ops-documents/Cargo.toml index f684e78a..e2498636 100644 --- a/native/crates/ops-documents/Cargo.toml +++ b/native/crates/ops-documents/Cargo.toml @@ -14,3 +14,4 @@ serde_json = "1.0" sha2 = "0.10.9" zip = { version = "=9.0.1", default-features = false, features = ["deflate-flate2-zlib-rs"] } quick-xml = { version = "=0.42.0", default-features = false } +flate2 = { version = "=1.1.10", default-features = false, features = ["zlib-rs"] } diff --git a/native/crates/ops-documents/src/lib.rs b/native/crates/ops-documents/src/lib.rs index 0d3fd695..90674bd2 100644 --- a/native/crates/ops-documents/src/lib.rs +++ b/native/crates/ops-documents/src/lib.rs @@ -1,6 +1,8 @@ //! Bounded table observations; cells stay strings, and incomplete traversals never invent totals. #![forbid(unsafe_code)] mod archive; +mod pdf_syntax; +pub mod pdf_container; mod xml; mod docx; mod decode; diff --git a/native/crates/ops-documents/src/pdf_container.rs b/native/crates/ops-documents/src/pdf_container.rs new file mode 100644 index 00000000..3936e2c3 --- /dev/null +++ b/native/crates/ops-documents/src/pdf_container.rs @@ -0,0 +1,329 @@ +//! A deliberately restricted PDF container: one classic xref table, direct stream lengths, no recovery. +use crate::pdf_syntax::{Dict, Lexer, Value, bad, limit, unsupported, white}; +use ops_protocol::Result; +use std::collections::{BTreeMap, BTreeSet}; +pub const MAX_FILE: usize = 16 * 1024 * 1024; +pub const MAX_OBJECTS: usize = 10_000; +pub const MAX_STREAM: usize = 1024 * 1024; +pub type ObjectId = (u32, u16); +#[derive(Debug)] +pub struct Object<'a> { + pub value: Value, + pub stream: Option<&'a [u8]>, +} +#[derive(Debug)] +pub struct Document<'a> { + pub objects: BTreeMap>, + pub root: ObjectId, +} +impl<'a> Document<'a> { + pub fn object(&self, id: ObjectId) -> Result<&Object<'a>> { + self.objects + .get(&id) + .ok_or_else(|| bad("PDF reference does not name an active object")) + } + pub fn resolve<'b>(&'b self, value: &'b Value) -> Result<&'b Value> { + let mut current = value; + let mut seen = BTreeSet::new(); + while let Value::Ref(number, generation) = current { + if seen.len() >= 64 { + return Err(limit("PDF reference chain exceeds 64")); + } + if !seen.insert((*number, *generation)) { + return Err(bad("Cyclic PDF reference")); + } + let object = self.object((*number, *generation))?; + if object.stream.is_some() { + return Err(bad("A PDF stream cannot be used as a plain value")); + } + current = &object.value; + } + Ok(current) + } +} +pub fn get<'a>(dict: &'a Dict, key: &[u8]) -> Result<&'a Value> { + dict.get(key) + .ok_or_else(|| bad(format!("Missing PDF {}", String::from_utf8_lossy(key)))) +} +fn trim_end(mut data: &[u8]) -> &[u8] { + while data.last().is_some_and(|b| white(*b)) { + data = &data[..data.len() - 1]; + } + data +} +fn footer(data: &[u8]) -> Result<(usize, usize)> { + let end = trim_end(data); + let Some(before_eof) = end.strip_suffix(b"%%EOF") else { + return Err(bad("PDF must end with %%EOF")); + }; + if !before_eof.last().is_some_and(|b| white(*b)) { + return Err(bad("Missing PDF EOF separator")); + } + let before_eof = trim_end(before_eof); + let number_start = before_eof + .iter() + .rposition(|b| !b.is_ascii_digit()) + .map_or(0, |n| n + 1); + if number_start == 0 || !white(before_eof[number_start - 1]) { + return Err(bad("Missing PDF xref number separator")); + } + let mut number = Lexer::new(&before_eof[number_start..]); + let xref = number.unsigned()?; + let prefix = trim_end(&before_eof[..number_start]); + let Some(before_start) = prefix.strip_suffix(b"startxref") else { + return Err(bad("Missing final PDF startxref")); + }; + if !before_start.last().is_some_and(|b| white(*b)) { + return Err(bad("Missing PDF startxref separator")); + } + let footer_start = before_start.len(); + if xref >= footer_start { + return Err(bad("PDF xref offset points outside object section")); + } + Ok((xref, footer_start)) +} +fn object<'a>( + data: &'a [u8], + offset: usize, + id: ObjectId, + nodes: usize, +) -> Result<(Object<'a>, usize, usize)> { + if !data.get(offset).is_some_and(u8::is_ascii_digit) { + return Err(bad("PDF xref must point exactly to an object header")); + } + let mut lexer = Lexer { + data, + at: offset, + nodes, + }; + if lexer.unsigned()? != id.0 as usize || lexer.unsigned()? != id.1 as usize { + return Err(bad("PDF xref object identity mismatch")); + } + lexer.keyword(b"obj")?; + let value = lexer.value(0)?; + let mut stream = None; + let token = lexer.word()?; + if token == b"stream" { + let length = get(value.dict()?, b"Length")?.integer()?; + if length > MAX_STREAM { + return Err(limit("PDF encoded stream exceeds 1 MiB")); + } + // Indirect Length is declined before reading stream bytes; no searching for endstream. + if lexer.data.get(lexer.at..lexer.at + 2) == Some(b"\r\n") { + lexer.at += 2; + } else if lexer.data.get(lexer.at) == Some(&b'\n') { + lexer.at += 1; + } else { + return Err(bad("PDF stream keyword must be followed by LF or CRLF")); + } + let end = lexer + .at + .checked_add(length) + .filter(|n| *n <= data.len()) + .ok_or_else(|| bad("PDF stream length exceeds its object"))?; + stream = Some(&data[lexer.at..end]); + lexer.at = end; + lexer.keyword(b"endstream")?; + lexer.keyword(b"endobj")?; + } else if token != b"endobj" { + return Err(bad("Unexpected token after PDF object")); + } + Ok((Object { value, stream }, lexer.at, lexer.nodes)) +} +pub fn parse(data: &[u8]) -> Result> { + if data.len() > MAX_FILE { + return Err(limit("PDF file exceeds 16 MiB")); + } + if data.len() < 9 + || &data[..7] != b"%PDF-1." + || !matches!(data[7], b'0'..=b'7') + || !matches!(data[8], 10 | 13) + { + return Err(unsupported( + "Only PDF 1.0-1.7 with an exact header and classic xref table is supported", + )); + } + let (xref, footer_start) = footer(data)?; + if !data[xref..].starts_with(b"xref") { + return Err(unsupported( + "PDF requires a classic xref table; xref streams are unsupported", + )); + } + let mut lexer = Lexer { + data, + at: xref, + nodes: 0, + }; + lexer.keyword(b"xref")?; + let mut entries = BTreeMap::new(); + loop { + lexer.skip(); + if data[lexer.at..].starts_with(b"trailer") { + lexer.keyword(b"trailer")?; + break; + } + let start = lexer.unsigned()?; + let count = lexer.unsigned()?; + if count == 0 { + return Err(bad("PDF xref subsection cannot be empty")); + } + if start >= MAX_OBJECTS + || count > MAX_OBJECTS - start + || entries.len() + count > MAX_OBJECTS + { + return Err(limit("PDF xref exceeds 10000 entries")); + } + for id in start..start + count { + let offset = lexer.unsigned()?; + let generation = lexer.unsigned()?; + let flag = lexer.word()?; + if generation > u16::MAX as usize + || (flag == b"n" && generation == u16::MAX as usize) + || !matches!(flag, b"n" | b"f") + { + return Err(bad("Invalid PDF xref entry")); + } + if entries + .insert(id, (offset, generation as u16, flag == b"n")) + .is_some() + { + return Err(bad("Duplicate PDF xref object number")); + } + } + } + let trailer = lexer.value(0)?; + let trailer = trailer.dict()?; + let size = get(trailer, b"Size")?.integer()?; + if size == 0 + || size > MAX_OBJECTS + || entries.len() != size + || entries.keys().copied().ne(0..size) + { + return Err(bad( + "PDF xref must fully declare its bounded Size without incremental sections", + )); + } + if entries + .get(&0) + .is_none_or(|(_, generation, active)| *active || *generation != u16::MAX) + { + return Err(bad("PDF object zero must be free with generation 65535")); + } + let root = match get(trailer, b"Root")? { + Value::Ref(a, b) => (*a, *b), + _ => return Err(bad("PDF Root must be an indirect reference")), + }; + lexer.skip(); + if lexer.at != footer_start { + return Err(bad("Unexpected PDF data between trailer and final footer")); + } + let mut ordered = Vec::new(); + for (number, (offset, generation, active)) in entries { + if !active { + continue; + } + if offset < 9 || offset >= xref { + return Err(bad( + "Active PDF object offset is outside the object section", + )); + } + ordered.push((offset, (number as u32, generation))); + } + ordered.sort_unstable(); + let mut previous = 0; + let mut nodes = lexer.nodes; + let mut objects = BTreeMap::new(); + for (offset, id) in ordered { + if offset < previous { + return Err(bad("PDF object ranges overlap")); + } + let mut gap = Lexer { + data, + at: previous, + nodes, + }; + gap.skip(); + if gap.at != offset { + return Err(bad( + "Unindexed data or overlapping comments in PDF object section", + )); + } + let (item, end, count) = object(&data[..xref], offset, id, nodes)?; + previous = end; + nodes = count; + objects.insert(id, item); + } + let mut gap = Lexer { + data, + at: previous, + nodes, + }; + gap.skip(); + if gap.at != xref { + return Err(bad("Unindexed trailing data before PDF xref")); + } + let document = Document { objects, root }; + let root_object = document.object(root)?; + if root_object.stream.is_some() { + return Err(bad("PDF Root cannot be a stream")); + } + root_object.value.dict()?; + Ok(document) +} +// Decode one already bounded stream; verify the actual zlib terminator and complete input consumption. +pub fn decode(object: &Object<'_>) -> Result> { + let raw = object.stream.ok_or_else(|| bad("Expected a PDF stream"))?; + if raw.len() > MAX_STREAM { + return Err(limit("PDF encoded stream exceeds 1 MiB")); + } + let dict = object.value.dict()?; + if dict.contains_key(b"DecodeParms".as_slice()) || dict.contains_key(b"F".as_slice()) { + return Err(unsupported( + "PDF stream predictors, decode parameters and external data are unsupported", + )); + } + let Some(filter) = dict.get(b"Filter".as_slice()) else { + return Ok(raw.to_vec()); + }; + let filter = match filter { + Value::Array(filters) if filters.len() == 1 => &filters[0], + value => value, + }; + if filter.name()? != b"FlateDecode" { + return Err(unsupported( + "Only unfiltered or single FlateDecode PDF streams are supported", + )); + } + let mut decoder = flate2::Decompress::new(true); + let mut output = Vec::new(); + let mut buffer = [0u8; 8192]; + loop { + let before_in = decoder.total_in() as usize; + let before_out = decoder.total_out() as usize; + let room = (MAX_STREAM + 1 - output.len()).min(buffer.len()); + let status = decoder + .decompress( + &raw[before_in..], + &mut buffer[..room], + flate2::FlushDecompress::None, + ) + .map_err(|_| bad("Invalid PDF zlib stream or checksum"))?; + let written = decoder.total_out() as usize - before_out; + output.extend_from_slice(&buffer[..written]); + if output.len() > MAX_STREAM { + return Err(limit("PDF decoded stream exceeds 1 MiB")); + } + if status == flate2::Status::StreamEnd { + if decoder.total_in() as usize != raw.len() { + return Err(bad("Trailing data after PDF zlib stream")); + } + return Ok(output); + } + if decoder.total_in() as usize == before_in && written == 0 { + return Err(bad("Truncated PDF zlib stream")); + } + } +} +#[cfg(test)] +#[path = "pdf_container_tests.rs"] +mod tests; diff --git a/native/crates/ops-documents/src/pdf_container_tests.rs b/native/crates/ops-documents/src/pdf_container_tests.rs new file mode 100644 index 00000000..706d3a13 --- /dev/null +++ b/native/crates/ops-documents/src/pdf_container_tests.rs @@ -0,0 +1,313 @@ +use super::*; +fn fixture(values: &[&[u8]]) -> Vec { + let mut pdf = b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n".to_vec(); + let mut offsets = vec![0]; + for (index, value) in values.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", index + 1).as_bytes()); + pdf.extend_from_slice(value); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len()).as_bytes()); + for offset in offsets.iter().skip(1) { + pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", + offsets.len() + ) + .as_bytes(), + ); + pdf +} +fn replace(pdf: &[u8], old: &[u8], new: &[u8]) -> Vec { + let at = pdf.windows(old.len()).position(|v| v == old).unwrap(); + [&pdf[..at], new, &pdf[at + old.len()..]].concat() +} +fn rejected(pdf: &[u8], code: &str) { + let error = parse(pdf).unwrap_err(); + assert_eq!(error.code, code, "{}", error.message); +} +#[test] +fn real_classic_pdf_preserves_objects_values_and_exact_stream_bytes() { + let pdf = fixture(&[ + b"<< /Type /Catalog /Pages 2 0 R /A true /B false /N null /Escaped#20Name <4f70 7> >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", + b"<< /Length 9 >>\nstream\n(Hi) Tj\n\nendstream", + ]); + let document = parse(&pdf).unwrap(); + assert_eq!(document.objects.len(), 4); + assert_eq!( + document.object((4, 0)).unwrap().stream, + Some(b"(Hi) Tj\n\n".as_slice()) + ); + let root = document + .object(document.root) + .unwrap() + .value + .dict() + .unwrap(); + assert!(matches!(get(root, b"A").unwrap(), Value::Bool(true))); + assert!(matches!(get(root, b"B").unwrap(), Value::Bool(false))); + assert!(matches!(get(root, b"Escaped Name").unwrap(), Value::String(v) if v == b"Opp")); +} +#[test] +fn literal_strings_escapes_line_endings_and_odd_hex_are_exact() { + let mut lexer = Lexer::new(b"(a\\n\\r\\t\\b\\f\\(b\\)\\134\\101\\\r\nc\r\nd)"); + assert!( + matches!(lexer.value(0).unwrap(), Value::String(v) if v == b"a\n\r\t\x08\x0c(b)\\Ac\nd") + ); + let mut lexer = Lexer::new(b"<123>"); + assert!(matches!(lexer.value(0).unwrap(), Value::String(v) if v == [0x12,0x30])); + assert!(Lexer::new(b"1.0").value(0).unwrap().integer().is_err()); + assert!(Lexer::new(b"+1").value(0).unwrap().integer().is_ok()); +} +#[test] +fn duplicate_keys_and_escaped_unsupported_names_are_rejected() { + for value in [ + b"<< /Name 1 /Na#6de 2 >>".as_slice(), + b"<< /Bad#0 1 >>", + b"<< /Bad#00 1 >>", + b"<< /Na#GG 1 >>", + ] { + rejected(&fixture(&[value]), "DOCUMENT_PARSE_ERROR"); + } + for name in ["Encrypt", "Pr#65v", "XRefStm", "ObjStm", "XRef"] { + rejected( + &fixture(&[format!("<< /{name} null >>").as_bytes()]), + "UNSUPPORTED_DOCUMENT", + ); + } +} +#[test] +fn offset_alias_overlap_and_unindexed_data_are_never_recovered() { + let pdf = fixture(&[b"<<>>", b"(value)"]); + let document = parse(&pdf).unwrap(); + assert_eq!(document.objects.len(), 2); + let first = pdf.windows(7).position(|w| w == b"1 0 obj").unwrap(); + let second = pdf.windows(7).position(|w| w == b"2 0 obj").unwrap(); + rejected( + &replace( + &pdf, + format!("{second:010}").as_bytes(), + format!("{first:010}").as_bytes(), + ), + "DOCUMENT_PARSE_ERROR", + ); + rejected( + &replace( + &pdf, + format!("{first:010}").as_bytes(), + format!("{:010}", first - 1).as_bytes(), + ), + "DOCUMENT_PARSE_ERROR", + ); + rejected( + &replace(&pdf, b"2 0 obj", b"2 1 obj"), + "DOCUMENT_PARSE_ERROR", + ); + rejected( + &replace(&pdf, b"%\xE2\xE3\xCF\xD3", b"xxxxx"), + "DOCUMENT_PARSE_ERROR", + ); + // A comment starting before an indexed object must not allow that object to become executable. + rejected( + &replace(&pdf, b"\n2 0 obj", b"%2 0 obj"), + "DOCUMENT_PARSE_ERROR", + ); +} +#[test] +fn xref_counts_generations_size_and_footer_are_bounded_and_exact() { + let pdf = fixture(&[b"<<>>"]); + for (old, new) in [ + (b"xref\n0 2".as_slice(), b"xref\n0 1000000000".as_slice()), + (b"xref\n0 2", b"xref\n10000 2"), + ] { + rejected(&replace(&pdf, old, new), "OUTPUT_LIMIT"); + } + for (old, new) in [ + (b"/Size 2".as_slice(), b"/Size 3".as_slice()), + (b"/Size 2", b"/Size 2.0"), + (b"65535 f", b"65534 f"), + (b"00000 n", b"99999 n"), + (b"00000 n", b"00000 x"), + (b"startxref\n", b"startxref"), + (b"%%EOF\n", b"%%EOF x"), + (b"xref\n0 2", b"xref\n0 1"), + ] { + rejected(&replace(&pdf, old, new), "DOCUMENT_PARSE_ERROR"); + } +} +#[test] +fn streams_require_bounded_direct_integer_lengths_and_matching_end() { + for value in [ + b"<< /Length 1.0 >>\nstream\nx\nendstream".as_slice(), + b"<< /Length 1 0 R >>\nstream\nx\nendstream", + b"<< /Length 3 >>\nstream\nx\nendstream", + b"<< /Length 100000 >>\nstream\nx\nendstream", + b"<< /Length 1 >>\nstream x\nendstream", + b"<< /Length 1 >>\nstream\nx\nendobj", + ] { + rejected(&fixture(&[value]), "DOCUMENT_PARSE_ERROR"); + } + rejected( + &fixture(&[b"<< /Length 1048577 >>\nstream\nx\nendstream"]), + "OUTPUT_LIMIT", + ); + let pdf = fixture(&[b"<<>>", b"<< /Length 0 >>\nstream\nendstream"]); + assert_eq!( + parse(&pdf).unwrap().object((2, 0)).unwrap().stream, + Some(b"".as_slice()) + ); +} +#[test] +fn nesting_strings_names_and_node_totals_are_bounded() { + let deep = format!("<< /X {}0{} >>", "[".repeat(65), "]".repeat(65)); + rejected(&fixture(&[deep.as_bytes()]), "OUTPUT_LIMIT"); + let name = format!("<< /{} 0 >>", "a".repeat(257)); + rejected(&fixture(&[name.as_bytes()]), "OUTPUT_LIMIT"); + let nodes = format!("<< /X [{}] >>", "0 ".repeat(100000)); + rejected(&fixture(&[nodes.as_bytes()]), "OUTPUT_LIMIT"); + let string = format!("<< /X ({}) >>", "x".repeat(1024 * 1024 + 1)); + rejected(&fixture(&[string.as_bytes()]), "OUTPUT_LIMIT"); + let hex = format!("<< /X <{}> >>", "a".repeat(2 * 1024 * 1024 + 1)); + rejected(&fixture(&[hex.as_bytes()]), "OUTPUT_LIMIT"); + rejected(&vec![b' '; MAX_FILE + 1], "OUTPUT_LIMIT"); +} +#[test] +fn reference_resolution_is_bounded_and_cycles_missing_objects_fail() { + let pdf = fixture(&[b"<< /X 2 0 R >>", b"3 0 R", b"2 0 R"]); + let doc = parse(&pdf).unwrap(); + let value = get(doc.object(doc.root).unwrap().value.dict().unwrap(), b"X").unwrap(); + assert_eq!(doc.resolve(value).unwrap_err().code, "DOCUMENT_PARSE_ERROR"); + assert!(doc.resolve(&Value::Ref(999, 0)).is_err()); + let mut values = vec![b"<<>>".to_vec()]; + for n in 2..68 { + values.push(format!("{} 0 R", n + 1).into_bytes()); + } + values.push(b"null".to_vec()); + let pdf = fixture(&values.iter().map(Vec::as_slice).collect::>()); + assert_eq!( + parse(&pdf) + .unwrap() + .resolve(&Value::Ref(2, 0)) + .unwrap_err() + .code, + "OUTPUT_LIMIT" + ); +} +#[test] +fn every_truncated_prefix_and_single_byte_mutation_is_panic_free() { + let pdf = fixture(&[ + b"<< /Type /Catalog /X [(a\\)b) 1 -2 3.5] >>", + b"<< /Length 2 >>\nstream\nxy\nendstream", + ]); + for end in 0..pdf.len() { + assert!(parse(&pdf[..end]).is_err() || trim_end(&pdf[..end]).ends_with(b"%%EOF")); + } + for at in 0..pdf.len() { + for byte in [0, b'%', b'(', b'<', b'9', 255] { + let mut changed = pdf.clone(); + changed[at] = byte; + let _ = parse(&changed); + } + } +} +#[test] +fn flate_streams_validate_actual_decoded_size_checksum_and_consumption() { + use std::io::Write; + fn zipped(bytes: &[u8]) -> Vec { + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() + } + fn decode_zip(bytes: &[u8]) -> ops_protocol::Result> { + let mut dict = Dict::new(); + dict.insert(b"Filter".to_vec(), Value::Name(b"FlateDecode".to_vec())); + decode(&Object { + value: Value::Dict(dict), + stream: Some(bytes), + }) + } + let text = b"BT /F1 12 Tf (A real PDF text stream) Tj ET"; + let compressed = zipped(text); + assert_eq!(decode_zip(&compressed).unwrap(), text); + for length in 0..compressed.len() { + assert_eq!( + decode_zip(&compressed[..length]).unwrap_err().code, + "DOCUMENT_PARSE_ERROR" + ); + } + let mut trailing = compressed.clone(); + trailing.push(0); + assert_eq!( + decode_zip(&trailing).unwrap_err().code, + "DOCUMENT_PARSE_ERROR" + ); + let mut corrupt = compressed.clone(); + let end = corrupt.len() - 1; + corrupt[end] ^= 1; + assert_eq!( + decode_zip(&corrupt).unwrap_err().code, + "DOCUMENT_PARSE_ERROR" + ); + assert_eq!( + decode_zip(&zipped(&vec![b'x'; MAX_STREAM])).unwrap().len(), + MAX_STREAM + ); + assert_eq!( + decode_zip(&zipped(&vec![b'x'; MAX_STREAM + 1])) + .unwrap_err() + .code, + "OUTPUT_LIMIT" + ); + let raw = Object { + value: Value::Dict(Dict::new()), + stream: Some(text), + }; + assert_eq!(decode(&raw).unwrap(), text); +} +#[test] +fn filter_chains_predictors_and_external_streams_are_declined() { + for (key, value) in [ + (b"Filter".as_slice(), Value::Name(b"LZWDecode".to_vec())), + ( + b"Filter", + Value::Array(vec![Value::Name(b"FlateDecode".to_vec()); 2]), + ), + (b"DecodeParms", Value::Null), + (b"F", Value::String(b"file.pdf".to_vec())), + ] { + let mut dict = Dict::new(); + dict.insert(key.to_vec(), value); + assert!( + decode(&Object { + value: Value::Dict(dict), + stream: Some(b"") + }) + .is_err() + ); + } +} +#[test] +fn stream_roots_reserved_generations_and_direct_oversize_decode_are_refused() { + rejected( + &fixture(&[b"<< /Length 0 >>\nstream\nendstream"]), + "DOCUMENT_PARSE_ERROR", + ); + let pdf = fixture(&[b"<<>>"]); + rejected( + &replace(&pdf, b"00000 n", b"65535 n"), + "DOCUMENT_PARSE_ERROR", + ); + let bytes = vec![0; MAX_STREAM + 1]; + let raw = Object { + value: Value::Dict(Dict::new()), + stream: Some(&bytes), + }; + assert_eq!(decode(&raw).unwrap_err().code, "OUTPUT_LIMIT"); +} diff --git a/native/crates/ops-documents/src/pdf_syntax.rs b/native/crates/ops-documents/src/pdf_syntax.rs new file mode 100644 index 00000000..d540cb18 --- /dev/null +++ b/native/crates/ops-documents/src/pdf_syntax.rs @@ -0,0 +1,357 @@ +//! Bounded PDF values, shared by classic-object and content-stream parsing. No recovery heuristics. +use ops_protocol::{Failure, Result}; +use std::collections::BTreeMap; +pub const MAX_NODES: usize = 100_000; +pub const MAX_STRING: usize = 1024 * 1024; +pub type Dict = BTreeMap, Value>; +#[derive(Clone, Debug)] +pub enum Value { + Null, + Bool(bool), + Integer(i64), + Number(f64), + Name(Vec), + String(Vec), + Array(Vec), + Dict(Dict), + Ref(u32, u16), +} +impl Value { + pub fn integer(&self) -> Result { + match self { + Self::Integer(n) if *n >= 0 && *n <= u32::MAX as i64 => Ok(*n as usize), + _ => Err(bad("Expected a bounded nonnegative PDF integer")), + } + } + pub fn dict(&self) -> Result<&Dict> { + match self { + Self::Dict(d) => Ok(d), + _ => Err(bad("Expected a PDF dictionary")), + } + } + pub fn name(&self) -> Result<&[u8]> { + match self { + Self::Name(n) => Ok(n), + _ => Err(bad("Expected a PDF name")), + } + } +} +pub fn bad(message: impl ToString) -> Failure { + Failure::new("DOCUMENT_PARSE_ERROR", message) +} +pub fn unsupported(message: impl ToString) -> Failure { + Failure::new("UNSUPPORTED_DOCUMENT", message) +} +pub fn limit(message: &str) -> Failure { + Failure::new("OUTPUT_LIMIT", message) +} +pub fn white(b: u8) -> bool { + matches!(b, 0 | 9 | 10 | 12 | 13 | 32) +} +fn delimiter(b: u8) -> bool { + white(b) || b"()<>[]{}/%".contains(&b) +} +fn hex(b: u8) -> Result { + match b { + b'0'..=b'9' => Ok(b - b'0'), + b'a'..=b'f' => Ok(b - b'a' + 10), + b'A'..=b'F' => Ok(b - b'A' + 10), + _ => Err(bad("Invalid PDF hexadecimal digit")), + } +} +pub struct Lexer<'a> { + pub data: &'a [u8], + pub at: usize, + pub nodes: usize, +} +impl<'a> Lexer<'a> { + pub fn new(data: &'a [u8]) -> Self { + Self { + data, + at: 0, + nodes: 0, + } + } + pub fn skip(&mut self) { + loop { + while self.at < self.data.len() && white(self.data[self.at]) { + self.at += 1 + } + if self.data.get(self.at) != Some(&b'%') { + break; + } + while self.at < self.data.len() && !matches!(self.data[self.at], 10 | 13) { + self.at += 1 + } + } + } + pub fn word(&mut self) -> Result<&'a [u8]> { + self.skip(); + let start = self.at; + while self.at < self.data.len() && !delimiter(self.data[self.at]) { + self.at += 1; + if self.at - start > 256 { + return Err(limit("PDF token exceeds 256 bytes")); + } + } + if self.at == start { + return Err(bad("Expected a PDF keyword")); + } + Ok(&self.data[start..self.at]) + } + pub fn keyword(&mut self, expected: &[u8]) -> Result<()> { + if self.word()? != expected { + return Err(bad(format!( + "Expected PDF keyword {}", + String::from_utf8_lossy(expected) + ))); + } + Ok(()) + } + pub fn unsigned(&mut self) -> Result { + let token = self.word()?; + if !token.iter().all(u8::is_ascii_digit) || token.len() > 10 { + return Err(bad("Invalid bounded PDF integer")); + } + std::str::from_utf8(token) + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|n| *n <= u32::MAX as usize) + .ok_or_else(|| bad("PDF integer overflow")) + } + fn string(&mut self) -> Result> { + self.at += 1; + let mut output = Vec::new(); + let mut depth = 1; + while let Some(&b) = self.data.get(self.at) { + self.at += 1; + match b { + b'(' => { + depth += 1; + if depth > 64 { + return Err(limit("PDF string nesting exceeds 64")); + } + output.push(b) + } + b')' => { + depth -= 1; + if depth == 0 { + return Ok(output); + } + output.push(b) + } + b'\\' => { + let b = *self + .data + .get(self.at) + .ok_or_else(|| bad("Unclosed PDF string escape"))?; + self.at += 1; + match b { + b'n' => output.push(10), + b'r' => output.push(13), + b't' => output.push(9), + b'b' => output.push(8), + b'f' => output.push(12), + 10 => {} + 13 => { + if self.data.get(self.at) == Some(&10) { + self.at += 1 + } + } + b'0'..=b'7' => { + let mut value = (b - b'0') as u16; + for _ in 0..2 { + if let Some(b'0'..=b'7') = self.data.get(self.at) { + value = value * 8 + (self.data[self.at] - b'0') as u16; + self.at += 1 + } else { + break; + } + } + output.push(value as u8) + } + _ => output.push(b), + } + } + 13 => { + if self.data.get(self.at) == Some(&10) { + self.at += 1 + } + output.push(10) + } + _ => output.push(b), + } + if output.len() > MAX_STRING { + return Err(limit("PDF string exceeds 1 MiB")); + } + } + Err(bad("Unclosed PDF literal string")) + } + fn name(&mut self) -> Result> { + self.at += 1; + let mut name = Vec::new(); + while let Some(&b) = self.data.get(self.at) { + if delimiter(b) { + break; + } + self.at += 1; + let decoded = if b == b'#' { + if self.data.len() - self.at < 2 { + return Err(bad("Incomplete PDF name escape")); + } + let b = hex(self.data[self.at])? * 16 + hex(self.data[self.at + 1])?; + self.at += 2; + b + } else { + b + }; + if decoded == 0 { + return Err(bad("PDF names cannot contain NUL")); + } + name.push(decoded); + if name.len() > 256 { + return Err(limit("PDF name exceeds 256 bytes")); + } + } + if matches!( + name.as_slice(), + b"Encrypt" | b"Prev" | b"XRefStm" | b"ObjStm" | b"XRef" + ) { + return Err(unsupported( + "Encrypted, incremental, object-stream and xref-stream PDFs are unsupported", + )); + } + Ok(name) + } + pub fn value(&mut self, depth: usize) -> Result { + if depth > 64 { + return Err(limit("PDF object nesting exceeds 64")); + } + self.nodes += 1; + if self.nodes > MAX_NODES { + return Err(limit("PDF metadata exceeds 100000 values")); + } + self.skip(); + match self + .data + .get(self.at) + .copied() + .ok_or_else(|| bad("Missing PDF value"))? + { + b'/' => Ok(Value::Name(self.name()?)), + b'(' => Ok(Value::String(self.string()?)), + b'<' => { + self.at += 1; + if self.data.get(self.at) == Some(&b'<') { + self.at += 1; + let mut dict = Dict::new(); + loop { + self.skip(); + if self.data.get(self.at..self.at + 2) == Some(b">>") { + self.at += 2; + return Ok(Value::Dict(dict)); + } + if self.data.get(self.at) != Some(&b'/') { + return Err(bad("PDF dictionary key is not a name")); + } + let key = self.name()?; + if dict.contains_key(&key) { + return Err(bad("Duplicate PDF dictionary keys are ambiguous")); + } + if dict.len() >= 4096 { + return Err(limit("PDF dictionary exceeds 4096 keys")); + } + let value = self.value(depth + 1)?; + dict.insert(key, value); + } + } else { + let mut output = Vec::new(); + let mut high = None; + loop { + let b = *self + .data + .get(self.at) + .ok_or_else(|| bad("Unclosed PDF hex string"))?; + self.at += 1; + if b == b'>' { + if let Some(high) = high { + output.push(high * 16) + } + if output.len() > MAX_STRING { + return Err(limit("PDF hex string exceeds 1 MiB")); + } + return Ok(Value::String(output)); + } + if white(b) { + continue; + } + let digit = hex(b)?; + if let Some(h) = high.take() { + output.push(h * 16 + digit) + } else { + high = Some(digit) + } + if output.len() > MAX_STRING { + return Err(limit("PDF hex string exceeds 1 MiB")); + } + } + } + } + b'[' => { + self.at += 1; + let mut values = Vec::new(); + loop { + self.skip(); + if self.data.get(self.at) == Some(&b']') { + self.at += 1; + return Ok(Value::Array(values)); + } + values.push(self.value(depth + 1)?) + } + } + _ => { + let token = self.word()?; + match token { + b"null" => Ok(Value::Null), + b"true" => Ok(Value::Bool(true)), + b"false" => Ok(Value::Bool(false)), + _ => { + if !token + .iter() + .all(|b| b.is_ascii_digit() || b"+-.".contains(b)) + { + return Err(bad("Invalid PDF value token")); + } + let number = std::str::from_utf8(token) + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|n| n.is_finite()) + .ok_or_else(|| bad("Invalid PDF number"))?; + let saved = self.at; + if token.iter().all(u8::is_ascii_digit) + && number <= 10000.0 + && number.fract() == 0.0 + { + if let Ok(generation) = self.unsigned() + && generation <= 65535 + && self.word().is_ok_and(|w| w == b"R") + { + return Ok(Value::Ref(number as u32, generation as u16)); + } + self.at = saved; + } + if !token.contains(&b'.') { + let integer = std::str::from_utf8(token) + .ok() + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| bad("PDF integer overflow"))?; + Ok(Value::Integer(integer)) + } else { + Ok(Value::Number(number)) + } + } + } + } + } + } +} diff --git a/test/package-source.test.mjs b/test/package-source.test.mjs index 84f5f71e..51b51554 100644 --- a/test/package-source.test.mjs +++ b/test/package-source.test.mjs @@ -19,6 +19,8 @@ await test('npm pack includes compiled host code and native sources, never host 'native/crates/ops-documents/src/decode.rs', 'native/crates/ops-documents/src/parse.rs', 'native/crates/ops-documents/src/tests.rs', 'native/crates/ops-documents/src/archive.rs', 'native/crates/ops-documents/src/xml.rs', 'native/crates/ops-documents/src/docx.rs', + 'native/crates/ops-documents/src/pdf_syntax.rs', + 'native/crates/ops-documents/src/pdf_container.rs', 'native/crates/ops-documents/src/pdf_container_tests.rs', 'licenses/documents/THIRD-PARTY-NOTICES.txt'] for (const name of required) assert.ok(files.includes(name), `missing source-build dependency: ${name}`) assert.equal(files.some(name => name.startsWith('src/')), false, 'host source must not ship beside compiled lib/') From 04da26ecf1af1fac0f9489389f07b18f671c0e62 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:27:33 +0800 Subject: [PATCH 088/118] feat(documents): read bounded PDF text pages --- docs/CHANGELOG.md | 9 + docs/native-kernel.md | 42 +++ ...37\347\224\237\345\206\205\346\240\270.md" | 33 ++ native/crates/ops-documents/src/lib.rs | 4 + native/crates/ops-documents/src/pdf.rs | 171 ++++++++++ native/crates/ops-documents/src/pdf_pages.rs | 180 ++++++++++ native/crates/ops-documents/src/pdf_text.rs | 321 ++++++++++++++++++ native/crates/ops-results/src/spill.rs | 2 +- native/src/lib.rs | 2 +- native/src/main.rs | 2 +- test/lib/pdf-fixture.mjs | 36 ++ test/package-source.test.mjs | 2 + test/pdf-worker.test.mjs | 162 +++++++++ 13 files changed, 963 insertions(+), 3 deletions(-) create mode 100644 native/crates/ops-documents/src/pdf.rs create mode 100644 native/crates/ops-documents/src/pdf_pages.rs create mode 100644 native/crates/ops-documents/src/pdf_text.rs create mode 100644 test/lib/pdf-fixture.mjs create mode 100644 test/pdf-worker.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5f41fd03..aed9ebe3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,15 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm safe prefixes on unsupported content, stable-scan-only continuation and scoped reference recovery. - DOCX 的 Native/PTC 按需启用,明确正文范围与排除内容;缺口保留安全前缀且不伪造续取。 +### Read bounded PDF text pages with honest coverage (#261, 15d/16) + +- Read a documented classic-xref/standard-font PDF subset into page text and individual runs, + with explicit exclusions, per-stream/page/aggregate limits and no OCR or layout claims. +- Preserve preceding complete pages on unsupported content, keep totals/revision conditional + on stable full scans, and capture full pages before bounded reference spill. +- 新增受限 PDF 文本页及 run 读取,明确排除内容和累计预算;保留此前完整页, + 仅完整稳定扫描提供总数/修订,复用只读权限与无损引用,不声称 OCR 或阅读布局。 + ### Add bounded PDF container foundation (#261, 15c/16) - Validate a restricted classic-xref PDF grammar, exact object boundaries and bounded diff --git a/docs/native-kernel.md b/docs/native-kernel.md index aff0723c..79125560 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -249,3 +249,45 @@ of the input. No size advertised by a PDF author bypasses these limits. This slice exposes no tool and makes no text-coverage claim. Page-tree interpretation, the supported font/encoding subset, partial results and worker authority checks are introduced together in the following PDF extraction slice. + +## PDF text worker (15d/16) + +The one-shot worker accepts strict pdf_read { path, offset?, limit? }. The host resolves +path from its current directory. Offset is a 1-based page number (1–32, default 1); +limit is 1–32 pages (default 10). This remains an internal worker capability until the +separate discovery/Native/PTC adapters are connected; no host tool is registered here. + +Each item is { page, text, runs: string[] }. Runs retain individual PDF text-show +operations; TJ spacing numbers are not characters. Text joins runs with newlines for +display. Scope is page-text-layer-content-order, **not rendered reading order**. +Excluded content is stated in every result: layout, images/OCR, annotations/form fields, +attachments/metadata, and structure-tree alternate text. A page invoking unsupported +forms/images, marked content or external graphics state is a gap, even when it also has +supported text. Blank absent/null/empty/comment-only content yields an empty page; +nonempty graphics without a supported text layer is explicitly unsupported. + +The supported text subset is PDF 1.3–1.7 with the foundation's classic xref container, +standard Helvetica/Courier/Times Type1 families, built-in StandardEncoding printable +characters (including its curly quote glyphs), or named WinAnsiEncoding. The WinAnsi +bullet/space/hyphen aliases are explicit; unknown/control codes are refused without +replacement. Embedded/custom fonts, ToUnicode/CID maps and encoding differences are +unsupported. Earlier PDF encodings are declined rather than reinterpreted. The mapping +and operator semantics follow the [Adobe-hosted ISO 32000-1 reference, sections 9.4/9.6 +and Annex D](https://developer.adobe.com/document-services/docs/assets/35e4369068f86065372c18787171a17e/PDF_ISO_32000-1.pdf). + +Physical scanning stops at 32 pages, 30 seconds, 1 MiB decoded content per page, 64 +content streams per page, 1 MiB text/10,000 runs per page, and 16 MiB aggregate decoded +streams **and** 16 MiB processed stream bytes. Shared streams decode once but every +reuse counts against processed work. The existing 16 MiB file and per-stream limits +also apply. Page offsets do not bypass any physical bound. + +scanComplete concerns the entire bounded text scope; pageComplete concerns the +requested page range and becomes false on file changes. pagesObserved and skipped +count actual observed pages. Only a stable complete scan includes total, omitted +and a raw-file SHA-256 revision. Unsupported later pages retain only preceding complete +pages, return an error/nextAction and omit full totals/revision. Rescan pagination must +compare revisions before combining pages. Inline output is capped; the shared reference +sink captures whole pages before clipping, and stored pages retain original scope and +scan/capture completeness. A reference page being complete does not fill a source gap. +Read-only, credential denial, no-follow opens and no-business-effect failures use the +same worker authority checks as the other document readers. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index 2ee05844..e62fd206 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -198,3 +198,36 @@ FILE_CHANGED 始终使所请求页不完整。引用页生成的 source 类型 本切片不暴露工具,也不宣称提取了完整文本。页面树、支持的字体/编码子集、 部分结果及 worker 权限检查将在下一 PDF 提取切片中一并接入。 + +## PDF 文本 worker(15d/16) + +单次 worker 接受严格的 pdf_read { path, offset?, limit? }。宿主从当前目录 +解析路径。offset 为 1 起始页码(1–32,默认 1),limit 为 1–32 页(默认 10)。 +发现、Native/PTC 适配器由后续切片接入;本切片不注册宿主工具。 + +每项为 { page, text, runs: string[] }。runs 保留各 PDF 显示文本操作; +TJ 的间距数字不当作字符。text 仅以换行连接各 run 供展示。范围明确为 +page-text-layer-content-order,并非渲染阅读顺序。每次结果列出排除内容: +布局、图片/OCR、注释/表单、附件/元数据及结构树替代文本。页面实际调用 +不支持的表单/图片、标记内容或外部图形状态时,整页视为缺口,即使该页也有 +可读文本。缺失/null/空白/仅注释内容可返回空页;有图形但无支持文本层时 +明确拒绝,不执行 OCR。 + +文本子集限于 PDF 1.3–1.7、基础切片支持的传统 xref,以及标准 +Helvetica/Courier/Times Type1 字体。支持内建 StandardEncoding 的可打印字符 +(含弯引号映射)或命名 WinAnsiEncoding,并明确处理后者项目符号/空格/连字符 +别名。未知/控制编码不替换解码;嵌入/自定义字体、ToUnicode/CID 及编码差异表 +均拒绝。更早 PDF 编码不擅自重新解释。映射与操作语义依据 +[Adobe 托管的 ISO 32000-1 第 9.4/9.6 节及附录 D](https://developer.adobe.com/document-services/docs/assets/35e4369068f86065372c18787171a17e/PDF_ISO_32000-1.pdf)。 + +物理扫描限制为 32 页、30 秒、每页解码内容 1 MiB/64 个流、每页文本 +1 MiB/10,000 个 run,以及累计解码 16 MiB 和累计处理流字节 16 MiB。 +共享流只解压一次,但每次复用均计入处理预算。既有 16 MiB 文件和单流边界 +仍然适用,页码偏移无法绕过这些限制。 + +scanComplete 表示整个有界文本范围的扫描完整性,pageComplete 表示请求页范围, +文件变化时必为 false。pagesObserved/skipped 只统计实际观察页。仅稳定完整扫描 +附带 total、omitted 和原文件 SHA-256 revision。后续页不支持时仅保留先前完整页, +返回错误/恢复指引,不生成总数或 revision;组合重扫分页前必须比较 revision。 +共享引用接收器在展示裁剪前捕获完整页并保留范围、扫描/捕获事实;引用分页完成 +不等于源文档缺口已恢复。只读、凭据拒绝、nofollow 及无业务副作用沿用统一权限边界。 diff --git a/native/crates/ops-documents/src/lib.rs b/native/crates/ops-documents/src/lib.rs index 90674bd2..4d406ab0 100644 --- a/native/crates/ops-documents/src/lib.rs +++ b/native/crates/ops-documents/src/lib.rs @@ -2,6 +2,9 @@ #![forbid(unsafe_code)] mod archive; mod pdf_syntax; +mod pdf_text; +mod pdf; +mod pdf_pages; pub mod pdf_container; mod xml; mod docx; @@ -41,6 +44,7 @@ fn recovery(error: &mut Failure) { } struct Source { path: PathBuf, file: File, before: Metadata, started: Instant } pub fn dispatch(request: &Request) -> Result { + if request.tool == "pdf_read" { return pdf::read(request); } if request.tool == "doc_read" { return docx::read(request); } if request.tool != "table_read" { return Err(Failure::new("INVALID_ARGUMENT", "Unknown document capability")); } let args: Args = serde_json::from_value(request.args.clone())?; diff --git a/native/crates/ops-documents/src/pdf.rs b/native/crates/ops-documents/src/pdf.rs new file mode 100644 index 00000000..6a168e4b --- /dev/null +++ b/native/crates/ops-documents/src/pdf.rs @@ -0,0 +1,171 @@ +//! Read-only, stable-scope PDF page observations. Unsupported content never becomes complete text. +use crate::{ + pdf_container::{self, MAX_FILE}, + pdf_pages::{self, MAX_PAGES, Streams}, + pdf_text, +}; +use ops_protocol::{Failure, Request, Result, capture::json_size}; +use serde::Deserialize; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{io::Read, path::Path, time::Instant}; +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Args { + path: String, + #[serde(default = "super::one")] + offset: usize, + #[serde(default = "ten")] + limit: usize, +} +fn page_complete(failure: Option<&Failure>, observed: usize, offset: usize, limit: usize) -> bool { + !failure.is_some_and(|error| error.code == "FILE_CHANGED") + && (failure.is_none() || observed >= offset + limit - 1) +} +fn ten() -> usize { + 10 +} +pub fn read(request: &Request) -> Result { + let args: Args = serde_json::from_value(request.args.clone())?; + if args.offset == 0 || args.offset > MAX_PAGES || args.limit == 0 || args.limit > MAX_PAGES { + return Err(Failure::new( + "INVALID_ARGUMENT", + "pdf_read offset and limit are 1-32 pages (defaults 1 and 10)", + )); + } + request.authorize_path(Path::new(&args.path))?; + let target = ops_files::file_path::checked(&args.path)?; + request.authorize_path(&target.display)?; + let mut file = ops_files::file_path::read_file(&target)?.into_std(); + let before = file.metadata()?; + let started = Instant::now(); + if before.len() > MAX_FILE as u64 { + return Err(Failure::new("OUTPUT_LIMIT", "PDF input exceeds 16 MiB")); + } + let mut bytes = Vec::new(); + file.by_ref() + .take(MAX_FILE as u64 + 1) + .read_to_end(&mut bytes)?; + let document = pdf_container::parse(&bytes)?; + if bytes[7] < b'3' { + return Err(Failure::new( + "UNSUPPORTED_DOCUMENT", + "PDF text extraction supports PDF 1.3-1.7 encoding semantics", + )); + } + let pages = pdf_pages::pages(&document, started)?; + let mut streams = Streams::new(); + let (mut observed, mut selected, mut inline_bytes) = (0usize, 0usize, 0usize); + let (mut items, mut failure, mut cut) = (Vec::new(), None, false); + for (index, page) in pages.iter().enumerate() { + let result = streams + .content(&document, page, started) + .and_then(|content| pdf_text::extract(&document, page.resources, &content, started)); + let runs = match result { + Ok(runs) => runs, + Err(error) => { + failure = Some(error); + break; + } + }; + observed += 1; + let number = index + 1; + if number < args.offset || number >= args.offset + args.limit { + continue; + } + selected += 1; + let item = json!({"page":number,"text":runs.join("\n"),"runs":runs}); + if let Some(sink) = request.output_sink.as_deref() { + sink.push(&item) + } + let cost = json_size(&item) + 1; + if cut || inline_bytes + cost > 80 * 1024 { + cut = true; + if let Some(sink) = request.output_sink.as_deref() { + sink.clipped() + } + } else { + inline_bytes += cost; + items.push(item) + } + } + let after = file.metadata()?; + if before.len() != after.len() || before.modified().ok() != after.modified().ok() { + failure = Some(Failure::new( + "FILE_CHANGED", + "PDF metadata changed while reading; page observations are not a stable snapshot", + )); + } + let scan = failure.is_none(); + let next = args.offset + selected; + let more = !scan || next <= observed; + let cause = if let Some(error) = &failure { + match error.code.as_str() { + "DEADLINE_EXCEEDED" => "time", + "FILE_CHANGED" => "changed", + "OUTPUT_LIMIT" => "scan-budget", + "UNSUPPORTED_DOCUMENT" => "unsupported-content", + _ => "parse-error", + } + } else if more { + "row-limit" + } else { + "none" + }; + let mut output = json!({"status":if scan{"completed"}else{"partial"},"phase":"verify","changed":0,"effectState":"none","path":target.display,"format":"pdf", + "scope":"page-text-layer-content-order","excludedContent":["rendered layout and reading order","images and OCR","annotations and form fields","attachments and document metadata","structure-tree alternate text"], + "bytes":before.len(),"decodedBytes":streams.decoded,"processedBytes":streams.processed,"pagesObserved":observed,"skipped":(args.offset-1).min(observed), + "scanComplete":scan,"pageComplete":page_complete(failure.as_ref(),observed,args.offset,args.limit),"complete":scan&&!more,"truncated":more,"truncatedBy":cause,"nextOffset":next,"items":[]}); + if scan { + output["total"] = json!(observed); + output["omitted"] = json!(observed.saturating_sub(next - 1)); + output["revision"] = json!(format!("{:x}", Sha256::digest(&bytes))) + } + if let Some(mut error) = failure { + error.next_action="Use a dedicated PDF reader for unsupported content or physical scan limits; page offsets do not bypass those gaps. OCR and rendered reading order are not provided.".into(); + output["nextAction"] = json!(error.next_action); + output["error"] = json!(error); + } + if let Some(sink) = request.output_sink.as_deref() { + sink.source(output.clone()) + } + if cut { + output["complete"] = json!(false); + output["pageComplete"] = json!(false); + output["truncated"] = json!(true); + output["nextOffset"] = json!(args.offset + items.len()); + if scan { + output["truncatedBy"] = json!("output-budget"); + output["omitted"] = json!(observed.saturating_sub(args.offset - 1 + items.len())) + } + if output.get("nextAction").is_none() { + output["nextAction"] = json!( + "Read the stored reference when available; otherwise reduce the page limit or use a dedicated reader for a large page." + ) + } + } + output["items"] = json!(items); + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn coverage_invalidates_changed_pages_but_retains_completed_prefix_before_later_gaps() { + let changed = Failure::new("FILE_CHANGED", "changed"); + let unsupported = Failure::new("UNSUPPORTED_DOCUMENT", "later page"); + assert!(!page_complete(Some(&changed), 12, 1, 10)); + assert!(page_complete(Some(&unsupported), 10, 1, 10)); + assert!(!page_complete(Some(&unsupported), 9, 1, 10)); + assert!(page_complete(None, 2, 30, 1)); + } + #[test] + fn expired_pdf_deadline_is_an_explicit_failure() { + let expired = Instant::now() - std::time::Duration::from_secs(30); + assert_eq!( + pdf_text::deadline(expired).unwrap_err().code, + "DEADLINE_EXCEEDED" + ); + } +} diff --git a/native/crates/ops-documents/src/pdf_pages.rs b/native/crates/ops-documents/src/pdf_pages.rs new file mode 100644 index 00000000..4951b275 --- /dev/null +++ b/native/crates/ops-documents/src/pdf_pages.rs @@ -0,0 +1,180 @@ +//! Bounded page-tree traversal and one-time stream decompression; repeated content is charged again as work. +use crate::{ + pdf_container::{Document, MAX_STREAM, ObjectId, decode, get}, + pdf_syntax::{Dict, Value, bad, limit}, + pdf_text::deadline, +}; +use ops_protocol::Result; +use std::{ + collections::{BTreeMap, BTreeSet}, + time::Instant, +}; +pub const MAX_PAGES: usize = 32; +pub const MAX_DECODED: usize = 16 * 1024 * 1024; +pub struct Page<'a> { + pub dict: &'a Dict, + pub resources: Option<&'a Value>, +} +fn reference(value: &Value) -> Result { + match value { + Value::Ref(n, g) => Ok((*n, *g)), + _ => Err(bad("PDF page/content identity must be indirect")), + } +} +pub fn pages<'a>(doc: &'a Document<'_>, started: Instant) -> Result>> { + let catalog = doc.object(doc.root)?.value.dict()?; + if get(catalog, b"Type")?.name()? != b"Catalog" { + return Err(bad("PDF Root must be a Catalog")); + } + let mut seen = BTreeSet::new(); + let mut output = Vec::new(); + walk( + doc, + reference(get(catalog, b"Pages")?)?, + None, + None, + &mut seen, + &mut output, + started, + 0, + )?; + Ok(output) +} +#[allow(clippy::too_many_arguments)] +fn walk<'a>( + doc: &'a Document<'_>, + id: ObjectId, + parent: Option, + inherited: Option<&'a Value>, + seen: &mut BTreeSet, + output: &mut Vec>, + started: Instant, + depth: usize, +) -> Result { + deadline(started)?; + if depth > 64 || seen.len() >= 10_000 { + return Err(limit("PDF page tree exceeds bounded depth or nodes")); + } + if !seen.insert(id) { + return Err(bad("Cyclic or aliased PDF page tree")); + } + let object = doc.object(id)?; + if object.stream.is_some() { + return Err(bad("PDF page tree node cannot be a stream")); + } + let dict = object.value.dict()?; + if let Some(parent) = parent { + if reference(get(dict, b"Parent")?)? != parent { + return Err(bad("PDF page Parent disagrees with page tree")); + } + } else if dict.contains_key(b"Parent".as_slice()) { + return Err(bad("PDF page-tree root cannot have a Parent")); + } + let resources = dict.get(b"Resources".as_slice()).or(inherited); + match get(dict, b"Type")?.name()? { + b"Page" => { + if output.len() >= MAX_PAGES { + return Err(limit( + "PDF physical scan exceeds 32 pages; paging does not bypass this bound", + )); + } + output.push(Page { dict, resources }); + Ok(1) + } + b"Pages" => { + let expected = get(dict, b"Count")?.integer()?; + if expected > MAX_PAGES { + return Err(limit( + "PDF physical scan exceeds 32 pages; paging does not bypass this bound", + )); + } + let Value::Array(kids) = get(dict, b"Kids")? else { + return Err(bad("PDF Pages Kids must be an array")); + }; + let mut count = 0; + for kid in kids { + count += walk( + doc, + reference(kid)?, + Some(id), + resources, + seen, + output, + started, + depth + 1, + )?; + } + if count != expected { + return Err(bad("PDF page Count disagrees with observed tree")); + } + Ok(count) + } + _ => Err(bad("PDF page tree node has an invalid Type")), + } +} +pub struct Streams { + cache: BTreeMap>, + pub decoded: usize, + pub processed: usize, +} +impl Streams { + pub fn new() -> Self { + Self { + cache: BTreeMap::new(), + decoded: 0, + processed: 0, + } + } + fn append( + &mut self, + doc: &Document<'_>, + value: &Value, + output: &mut Vec, + started: Instant, + ) -> Result<()> { + deadline(started)?; + let id = reference(value)?; + if !self.cache.contains_key(&id) { + let bytes = decode(doc.object(id)?)?; + deadline(started)?; + if self.decoded + bytes.len() > MAX_DECODED { + return Err(limit("PDF aggregate decoded streams exceed 16 MiB")); + } + self.decoded += bytes.len(); + self.cache.insert(id, bytes); + } + let bytes = &self.cache[&id]; + if self.processed + bytes.len() > MAX_DECODED || output.len() + bytes.len() > MAX_STREAM { + return Err(limit( + "PDF processed content exceeds 16 MiB total or 1 MiB per page", + )); + } + self.processed += bytes.len(); + output.extend_from_slice(bytes); + Ok(()) + } + pub fn content( + &mut self, + doc: &Document<'_>, + page: &Page<'_>, + started: Instant, + ) -> Result> { + let mut output = Vec::new(); + let Some(value) = page.dict.get(b"Contents".as_slice()) else { + return Ok(output); + }; + match value { + Value::Array(values) => { + if values.len() > 64 { + return Err(limit("PDF page exceeds 64 content streams")); + } + for value in values { + self.append(doc, value, &mut output, started)? + } + } + Value::Null => {} + value => self.append(doc, value, &mut output, started)?, + } + Ok(output) + } +} diff --git a/native/crates/ops-documents/src/pdf_text.rs b/native/crates/ops-documents/src/pdf_text.rs new file mode 100644 index 00000000..ed2db95a --- /dev/null +++ b/native/crates/ops-documents/src/pdf_text.rs @@ -0,0 +1,321 @@ +//! PDF text operators for standard Latin Type1 fonts. No glyph substitution or layout inference. +use crate::{ + pdf_container::{Document, get}, + pdf_syntax::{Dict, Lexer, Value, bad, limit, unsupported}, +}; +use ops_protocol::{Failure, Result}; +use std::time::{Duration, Instant}; +pub const MAX_TEXT: usize = 1024 * 1024; +pub fn deadline(started: Instant) -> Result<()> { + if started.elapsed() >= Duration::from_secs(30) { + return Err(Failure::new( + "DEADLINE_EXCEEDED", + "PDF reading reached its 30-second deadline", + )); + } + Ok(()) +} +#[derive(Clone, Copy)] +enum Encoding { + Standard, + WinAnsi, +} +fn font(doc: &Document<'_>, resources: &Dict, name: &[u8]) -> Result { + let fonts = doc.resolve(get(resources, b"Font")?)?.dict()?; + let font = doc.resolve(get(fonts, name)?)?.dict()?; + if get(font, b"Type")?.name()? != b"Font" || get(font, b"Subtype")?.name()? != b"Type1" { + return Err(unsupported("PDF text requires standard Latin Type1 fonts")); + } + if font.contains_key(b"ToUnicode".as_slice()) || font.contains_key(b"FontDescriptor".as_slice()) + { + return Err(unsupported( + "PDF embedded fonts and ToUnicode mappings are unsupported", + )); + } + if !matches!( + get(font, b"BaseFont")?.name()?, + b"Helvetica" + | b"Helvetica-Bold" + | b"Helvetica-Oblique" + | b"Helvetica-BoldOblique" + | b"Courier" + | b"Courier-Bold" + | b"Courier-Oblique" + | b"Courier-BoldOblique" + | b"Times-Roman" + | b"Times-Bold" + | b"Times-Italic" + | b"Times-BoldItalic" + ) { + return Err(unsupported( + "PDF font is outside the standard Helvetica/Courier/Times families", + )); + } + match font.get(b"Encoding".as_slice()) { + None => Ok(Encoding::Standard), + Some(Value::Name(name)) if name == b"WinAnsiEncoding" => Ok(Encoding::WinAnsi), + _ => Err(unsupported( + "PDF font requires built-in StandardEncoding or named WinAnsiEncoding; differences and other encodings are unsupported", + )), + } +} +fn text(bytes: &[u8], encoding: Encoding) -> Result { + // ISO 32000-1 Annex D: WinAnsi differs from a generic CP1252 decoder at bullet/space/hyphen slots. + const C1: [char; 32] = [ + '€', '•', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '•', 'Ž', '•', '•', '‘', + '’', '“', '”', '•', '–', '—', '˜', '™', 'š', '›', 'œ', '•', 'ž', 'Ÿ', + ]; + let mut output = String::new(); + for &byte in bytes { + let c = match encoding { + Encoding::Standard => match byte { + 39 => '’', + 96 => '‘', + 32..=126 => byte as char, + _ => { + return Err(unsupported( + "PDF StandardEncoding character is outside the supported printable Latin subset", + )); + } + }, + Encoding::WinAnsi => match byte { + 32..=126 => byte as char, + 127 => '•', + 128..=159 => C1[(byte - 128) as usize], + 160 => ' ', + 173 => '-', + 161..=255 => byte as char, + _ => { + return Err(unsupported( + "PDF WinAnsi character has no supported glyph mapping", + )); + } + }, + }; + if output.len() + c.len_utf8() > MAX_TEXT { + return Err(limit("PDF text run exceeds 1 MiB")); + } + output.push(c); + } + Ok(output) +} +fn numeric(value: &Value) -> bool { + matches!(value, Value::Integer(_) | Value::Number(_)) +} +fn numbers(args: &[Value], count: usize) -> Result<()> { + if args.len() != count || !args.iter().all(numeric) { + return Err(bad("Invalid PDF numeric operator arguments")); + } + Ok(()) +} +fn no_args(args: &[Value]) -> Result<()> { + numbers(args, 0) +} +fn string(value: &Value, encoding: Encoding) -> Result { + match value { + Value::String(bytes) => text(bytes, encoding), + _ => Err(bad("PDF text-showing operand is not a string")), + } +} +pub fn extract( + doc: &Document<'_>, + resources: Option<&Value>, + bytes: &[u8], + started: Instant, +) -> Result> { + if bytes.iter().all(|b| crate::pdf_syntax::white(*b)) { + return Ok(Vec::new()); + } + let mut lexer = Lexer::new(bytes); + lexer.skip(); + if lexer.at == bytes.len() { + return Ok(Vec::new()); + } + let mut args = Vec::new(); + let (mut inside, mut selected, mut stack) = (false, None, Vec::new()); + let (mut runs, mut text_bytes) = (Vec::new(), 0usize); + loop { + deadline(started)?; + lexer.skip(); + if lexer.at == bytes.len() { + break; + } + let b = bytes[lexer.at]; + if b.is_ascii_digit() || b"+-./(<[".contains(&b) { + if args.len() >= 64 { + return Err(limit("PDF operator exceeds 64 operands")); + } + args.push(lexer.value(0)?); + continue; + } + let op = lexer.word()?; + if inside + && !matches!( + op, + b"ET" + | b"Tf" + | b"Tj" + | b"TJ" + | b"'" + | b"\"" + | b"T*" + | b"Td" + | b"TD" + | b"Tm" + | b"Tc" + | b"Tw" + | b"Tz" + | b"TL" + | b"Ts" + | b"Tr" + | b"BT" + ) + { + return Err(bad("Non-text PDF operator inside a text object")); + } + let mut shown = None; + match op { + b"BT" => { + no_args(&args)?; + if inside { + return Err(bad("Nested PDF text objects")); + } + inside = true + } + b"ET" => { + no_args(&args)?; + if !inside { + return Err(bad("PDF ET outside text object")); + } + inside = false + } + b"Tf" => { + if !inside || args.len() != 2 || !numeric(&args[1]) { + return Err(bad("Invalid PDF Tf text state")); + } + let resources = doc + .resolve(resources.ok_or_else(|| bad("Missing PDF page Resources"))?)? + .dict()?; + selected = Some(font(doc, resources, args[0].name()?)?); + } + b"Tj" | b"TJ" | b"'" | b"\"" => { + if !inside { + return Err(bad("PDF text show outside text object")); + } + let encoding = selected + .ok_or_else(|| bad("PDF text show before a supported font is selected"))?; + shown = Some(match op { + b"TJ" => { + if args.len() != 1 { + return Err(bad("Invalid PDF TJ arguments")); + } + let Value::Array(parts) = &args[0] else { + return Err(bad("PDF TJ operand is not an array")); + }; + let mut run = String::new(); + for part in parts { + if numeric(part) { + continue; + } + let fragment = string(part, encoding)?; + if run.len() + fragment.len() > MAX_TEXT { + return Err(limit("PDF text run exceeds 1 MiB")); + } + run.push_str(&fragment); + } + run + } + b"\"" => { + if args.len() != 3 { + return Err(bad("Invalid PDF quote arguments")); + } + numbers(&args[..2], 2)?; + string(&args[2], encoding)? + } + _ => { + if args.len() != 1 { + return Err(bad("Invalid PDF text-show arguments")); + } + string(&args[0], encoding)? + } + }); + } + b"T*" | b"Td" | b"TD" | b"Tm" | b"Tc" | b"Tw" | b"Tz" | b"TL" | b"Ts" | b"Tr" => { + if !inside { + return Err(bad("PDF text positioning outside text object")); + } + let count = match op { + b"T*" => 0, + b"Td" | b"TD" => 2, + b"Tm" => 6, + _ => 1, + }; + numbers(&args, count)?; + if op == b"Tr" && !matches!(args[0], Value::Integer(0..=7)) { + return Err(bad("Invalid PDF text rendering mode")); + } + } + b"q" => { + no_args(&args)?; + if inside { + return Err(unsupported("PDF q inside a text object is unsupported")); + } + if stack.len() >= 64 { + return Err(limit("PDF graphics state exceeds depth 64")); + } + stack.push(selected) + } + b"Q" => { + no_args(&args)?; + if inside { + return Err(unsupported("PDF Q inside a text object is unsupported")); + } + selected = stack.pop().ok_or_else(|| bad("Unbalanced PDF Q"))? + } + b"cm" | b"c" => numbers(&args, 6)?, + b"v" | b"y" | b"re" | b"k" | b"K" => numbers(&args, 4)?, + b"rg" | b"RG" => numbers(&args, 3)?, + b"m" | b"l" => numbers(&args, 2)?, + b"w" | b"J" | b"j" | b"M" | b"i" | b"g" | b"G" => numbers(&args, 1)?, + b"h" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n" + | b"W" | b"W*" => no_args(&args)?, + b"d" => { + if args.len() != 2 + || !numeric(&args[1]) + || !matches!(&args[0],Value::Array(v) if v.iter().all(numeric)) + { + return Err(bad("Invalid PDF dash arguments")); + } + } + b"ri" => { + if args.len() != 1 { + return Err(bad("Invalid PDF rendering intent arguments")); + } + args[0].name()?; + } + _ => { + return Err(unsupported(format!( + "Unsupported PDF content operator {}; forms, images, marked content and external graphics state are not traversed", + String::from_utf8_lossy(op) + ))); + } + } + if let Some(run) = shown { + if runs.len() >= 10_000 || text_bytes + run.len() + 1 > MAX_TEXT { + return Err(limit("PDF page text exceeds 1 MiB or 10000 runs")); + } + text_bytes += run.len() + 1; + runs.push(run); + } + args.clear(); + } + if inside || !stack.is_empty() || !args.is_empty() { + return Err(bad("Unclosed PDF text/graphics state or dangling operands")); + } + if runs.iter().all(String::is_empty) { + return Err(unsupported( + "PDF content has no supported text layer; OCR is not provided", + )); + } + Ok(runs) +} diff --git a/native/crates/ops-results/src/spill.rs b/native/crates/ops-results/src/spill.rs index a3b25fe0..6f40a039 100644 --- a/native/crates/ops-results/src/spill.rs +++ b/native/crates/ops-results/src/spill.rs @@ -19,7 +19,7 @@ fn mark_head(request: &Request, value: &mut Value) { let skipped = if request.tool == "replace" { 0 } else { value["skipped"].as_u64().unwrap_or(0) }; if let Some(total) = value["total"].as_u64() { value["omitted"] = json!(total.saturating_sub(skipped)); } if value.get("nextOffset").is_some() { - value["nextOffset"] = json!(if matches!(request.tool.as_str(), "table_read" | "doc_read") { skipped + 1 } else if request.tool == "inspect_local_file" { + value["nextOffset"] = json!(if matches!(request.tool.as_str(), "table_read" | "doc_read" | "pdf_read") { skipped + 1 } else if request.tool == "inspect_local_file" { if request.args["view"] == "hex" { skipped / 16 + 1 } else { skipped + 1 } } else { skipped }); } diff --git a/native/src/lib.rs b/native/src/lib.rs index e4a49714..7cf1e3a2 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -33,7 +33,7 @@ pub fn dispatch(request: Request) -> Result { Ok(ops_results::spill::finish(&request, value)) } fn execute(dir: &Dir, request: &Request) -> Result { - if matches!(request.tool.as_str(), "table_read" | "doc_read") { return ops_documents::dispatch(request); } + if matches!(request.tool.as_str(), "table_read" | "doc_read" | "pdf_read") { return ops_documents::dispatch(request); } // File reads/searches do not create or consult the durable audit store. // This is important for read-only sessions and keeps observation separate // from mutation audit records. diff --git a/native/src/main.rs b/native/src/main.rs index 6465cefd..20c0ffbd 100644 --- a/native/src/main.rs +++ b/native/src/main.rs @@ -4,7 +4,7 @@ use serde_json::json; use std::io::{Read, Write}; fn may_mutate(request: &Request) -> bool { - !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "table_read" | "doc_read" | "glob" | "grep" | "diff" | "stat") + !request.read_only && !matches!(request.tool.as_str(), "inspect_local_file" | "read_many" | "table_read" | "doc_read" | "pdf_read" | "glob" | "grep" | "diff" | "stat") && !(request.tool == "result" && request.args.get("action").and_then(serde_json::Value::as_str) == Some("page")) } fn main() { diff --git a/test/lib/pdf-fixture.mjs b/test/lib/pdf-fixture.mjs new file mode 100644 index 00000000..8835bbe5 --- /dev/null +++ b/test/lib/pdf-fixture.mjs @@ -0,0 +1,36 @@ +/** Small real classic-xref PDFs. Offsets/stream lengths count bytes; no external PDF package is needed. */ +import { deflateSync } from 'node:zlib' +const bytes = value => Buffer.isBuffer(value) ? value : Buffer.from(value, 'latin1') +export function pdf(objects, { version = '1.4', trailer = '' } = {}) { + const chunks = [Buffer.from('%PDF-' + version + '\n%\xe2\xe3\xcf\xd3\n', 'latin1')], offsets = [0] + let offset = chunks[0].length + for (const [index, value] of objects.entries()) { + offsets.push(offset) + const item = Buffer.concat([Buffer.from(String(index + 1) + ' 0 obj\n'), bytes(value), Buffer.from('\nendobj\n')]) + chunks.push(item); offset += item.length + } + const xref = 'xref\n0 ' + offsets.length + '\n0000000000 65535 f \n' + offsets.slice(1).map(at => String(at).padStart(10, '0') + ' 00000 n \n').join('') + chunks.push(Buffer.from(xref + 'trailer\n<< /Size ' + offsets.length + ' /Root 1 0 R ' + trailer + ' >>\nstartxref\n' + offset + '\n%%EOF\n')) + return Buffer.concat(chunks) +} +export function stream(content, { deflate = false, extra = '', length, packed } = {}) { + const encoded = packed ?? (deflate ? deflateSync(bytes(content)) : bytes(content)) + return Buffer.concat([Buffer.from('<< /Length ' + (length ?? encoded.length) + (deflate ? ' /Filter /FlateDecode' : '') + ' ' + extra + ' >>\nstream\n'), encoded, Buffer.from('\nendstream')]) +} +export function show(value) { + let text = '' + for (const byte of bytes(value)) text += byte < 32 || byte > 126 ? '\\' + byte.toString(8).padStart(3, '0') : [40, 41, 92].includes(byte) ? '\\' + String.fromCharCode(byte) : String.fromCharCode(byte) + return 'BT /F1 12 Tf 72 720 Td (' + text + ') Tj ET\n' +} +export function textPdf(pages, options = {}) { + const objects = [ + '<< /Type /Catalog /Pages 2 0 R ' + (options.catalogExtra ?? '') + ' >>', + '<< /Type /Pages /Kids [' + pages.map((_, i) => String(4 + i * 2) + ' 0 R').join(' ') + '] /Count ' + pages.length + ' >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /' + (options.font ?? 'Helvetica') + (options.encoding === null ? '' : ' /Encoding /' + (options.encoding ?? 'WinAnsiEncoding')) + ' ' + (options.fontExtra ?? '') + ' >>', + ] + for (const [i, content] of pages.entries()) { + objects.push('<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents ' + (5 + i * 2) + ' 0 R ' + (options.pageExtra ?? '') + ' >>') + objects.push(stream(content, options)) + } + return pdf(objects, options) +} diff --git a/test/package-source.test.mjs b/test/package-source.test.mjs index 51b51554..034fa4d0 100644 --- a/test/package-source.test.mjs +++ b/test/package-source.test.mjs @@ -20,6 +20,8 @@ await test('npm pack includes compiled host code and native sources, never host 'native/crates/ops-documents/src/tests.rs', 'native/crates/ops-documents/src/archive.rs', 'native/crates/ops-documents/src/xml.rs', 'native/crates/ops-documents/src/docx.rs', 'native/crates/ops-documents/src/pdf_syntax.rs', + 'native/crates/ops-documents/src/pdf.rs', 'native/crates/ops-documents/src/pdf_text.rs', + 'native/crates/ops-documents/src/pdf_pages.rs', 'native/crates/ops-documents/src/pdf_container.rs', 'native/crates/ops-documents/src/pdf_container_tests.rs', 'licenses/documents/THIRD-PARTY-NOTICES.txt'] for (const name of required) assert.ok(files.includes(name), `missing source-build dependency: ${name}`) diff --git a/test/pdf-worker.test.mjs b/test/pdf-worker.test.mjs new file mode 100644 index 00000000..0ebe0db5 --- /dev/null +++ b/test/pdf-worker.test.mjs @@ -0,0 +1,162 @@ +/** Real PDF worker observations: supported text scope, honest gaps, bounded work and authority. */ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { callWorker, request, envelopeOf, requireWorker } from './lib/boundary.mjs' +import { pdf, stream, show, textPdf } from './lib/pdf-fixture.mjs' +requireWorker('pdf-worker') +const fileAt = (root, value, name = 'document.pdf') => { const file = path.join(root, name); fs.writeFileSync(file, value); return file } +async function run(root, args, options = {}) { + const payload = { version: 1, requestId: 'pdf-test', component: 'file', budget: { timeoutMs: 30000, maxBytes: options.maxBytes ?? 131072 }, + ...(options.cache ? { storage: { root: options.cache, authorityId: 'a'.repeat(64), allowedTools: ['pdf_read', 'result'] } } : {}), + request: { ...request(options.tool ?? 'pdf_read', args, { root, readOnly: options.readOnly, writeRoots: [] }), ...options.authority } } + const raw = await callWorker(JSON.stringify(payload)); assert.ok(Buffer.byteLength(raw.stdout) <= payload.budget.maxBytes) + return envelopeOf(raw, assert) +} +function failure(body, code) { assert.equal(body.error?.code, code, JSON.stringify(body)); assert.equal(body.error.effectState, 'none'); assert.notEqual(body.complete, true); assert.equal(body.total, undefined); assert.equal(body.revision, undefined) } +const catalog = '<< /Type /Catalog /Pages 2 0 R >>' +const font = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>' +const page = content => '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents ' + content + ' >>' +function padded(size, label = 'ok') { const prefix = show(label); return prefix + '%' + 'x'.repeat(size - Buffer.byteLength(prefix) - 2) + '\n' } + +await test('raw and Flate PDF pages preserve literal/hex/TJ runs and exact WinAnsi Unicode', async () => { + await withTempDir('pdf-text', async root => { + const content = 'BT /F1 12 Tf (A\\(B\\)\\\\C) Tj [<436166e9> -120 ( ) 5 <80>] TJ T* (next) \' 0 0 (last) " ET\n' + for (const deflate of [false, true]) { + const bytes = textPdf([content], { deflate }), body = await run(root, { path: fileAt(root, bytes) }) + assert.equal(body.error, undefined); assert.equal(body.status, 'completed'); assert.equal(body.complete, true) + assert.equal(body.scope, 'page-text-layer-content-order'); assert.ok(body.excludedContent.includes('images and OCR')) + assert.deepEqual(body.items, [{ page: 1, text: 'A(B)\\C\nCafé €\nnext\nlast', runs: ['A(B)\\C', 'Café €', 'next', 'last'] }]) + assert.equal(body.total, 1); assert.equal(body.revision, createHash('sha256').update(bytes).digest('hex')); assert.equal(body.nextOffset, 2) + } + const aliases = await run(root, { path: fileAt(root, textPdf([show(Buffer.from([127,129,141,143,144,157,160,173,159]))])) }) + assert.equal(aliases.items[0].text, '•••••• -Ÿ') + const standard = await run(root, { path: fileAt(root, textPdf([show("'quote" + String.fromCharCode(96))], { encoding: null })) }) + assert.equal(standard.items[0].text, '’quote‘') + }) +}) +await test('page offsets have complete-scan totals, ten-page default and honest blank/empty documents', async () => { + await withTempDir('pdf-pages', async root => { + const file = fileAt(root, textPdf(Array.from({ length: 12 }, (_, i) => show('Page ' + (i + 1))))) + const first = await run(root, { path: file }); assert.equal(first.items.length, 10); assert.equal(first.nextOffset, 11); assert.equal(first.total, 12); assert.equal(first.scanComplete, true); assert.equal(first.pageComplete, true); assert.equal(first.complete, false) + const last = await run(root, { path: file, offset: 11, limit: 2 }); assert.deepEqual(last.items.map(i => i.page), [11, 12]); assert.equal(last.complete, true); assert.equal(last.revision, first.revision) + const blank = await run(root, { path: fileAt(root, textPdf([' \n\t'])) }); assert.deepEqual(blank.items, [{ page: 1, text: '', runs: [] }]); assert.equal(blank.complete, true) + const empty = await run(root, { path: fileAt(root, textPdf([])) }); assert.equal(empty.total, 0); assert.deepEqual(empty.items, []); assert.equal(empty.complete, true) + for (const args of [{ offset: 0 }, { offset: 33 }, { limit: 0 }, { limit: 33 }, { unknown: true }]) failure(await run(root, { path: file, ...args }), 'INVALID_ARGUMENT') + }) +}) +await test('unsupported later operators keep preceding complete pages without inventing a total or cursor', async () => { + await withTempDir('pdf-gaps', async root => { + for (const content of ['/X1 Do', 'BI /W 1 /H 1 ID x EI', '/Tag BMC EMC', '/GS1 gs', '0 0 m 1 1 l S', 'BX weird EX']) { + const body = await run(root, { path: fileAt(root, textPdf([show('safe'), content, show('unobserved')])), limit: 1 }) + failure(body, 'UNSUPPORTED_DOCUMENT'); assert.equal(body.status, 'partial'); assert.equal(body.scanComplete, false); assert.equal(body.pageComplete, true) + assert.equal(body.pagesObserved, 1); assert.deepEqual(body.items.map(i => i.text), ['safe']); assert.match(body.nextAction, /paging|offsets|gaps/i); assert.equal(body.continuation, undefined) + } + }) +}) +await test('unsupported fonts mappings controls and older encoding semantics fail explicitly', async () => { + await withTempDir('pdf-fonts', async root => { + for (const options of [{ font: 'Symbol' }, { font: 'Arial' }, { encoding: 'MacRomanEncoding' }, { fontExtra: '/ToUnicode 3 0 R' }, { fontExtra: '/FontDescriptor 3 0 R' }, { version: '1.2' }]) { + failure(await run(root, { path: fileAt(root, textPdf([show('text')], options)) }), 'UNSUPPORTED_DOCUMENT') + } + failure(await run(root, { path: fileAt(root, textPdf([show(Buffer.from([0xe9]))], { encoding: null })) }), 'UNSUPPORTED_DOCUMENT') + failure(await run(root, { path: fileAt(root, textPdf([show(Buffer.from([0,10]))])) }), 'UNSUPPORTED_DOCUMENT') + }) +}) +await test('malformed content cannot be accepted as complete text', async () => { + await withTempDir('pdf-grammar', async root => { + for (const content of ['BT BT ET ET', '(x) Tj', 'ET', 'BT /F1 Tf ET', 'BT /F1 12 Tf [(x) /bad] TJ ET', 'BT /F1 12 Tf (x) Tj', 'q', 'Q', '1 2', 'BT /F1 12 Tf 8 Tr (x) Tj ET', 'BT /F1 12 Tf Tj ET']) { + failure(await run(root, { path: fileAt(root, textPdf([content])) }), 'DOCUMENT_PARSE_ERROR') + } + }) +}) +await test('page counts aliases cycles parents and physical page limits are enforced before text traversal', async () => { + await withTempDir('pdf-tree', async root => { + for (const tree of ['<< /Type /Pages /Kids [4 0 R] /Count 2 >>', '<< /Type /Pages /Kids [4 0 R 4 0 R] /Count 2 >>', '<< /Type /Pages /Kids [2 0 R] /Count 1 >>']) { + failure(await run(root, { path: fileAt(root, pdf([catalog, tree, font, page('5 0 R'), stream(show('safe'))])) }), 'DOCUMENT_PARSE_ERROR') + } + failure(await run(root, { path: fileAt(root, pdf([catalog, '<< /Type /Pages /Kids [4 0 R] /Count 1 >>', font, page('5 0 R').replace('/Parent 2', '/Parent 1'), stream(show('safe'))])) }), 'DOCUMENT_PARSE_ERROR') + failure(await run(root, { path: fileAt(root, textPdf(Array.from({ length: 33 }, () => show('x')))), limit: 1 }), 'OUTPUT_LIMIT') + }) +}) +await test('raw file and actual decoded stream limits are independent of advertised compressed sizes', async () => { + await withTempDir('pdf-bounds', async root => { + failure(await run(root, { path: fileAt(root, Buffer.alloc(16 * 1024 * 1024 + 1)) }), 'OUTPUT_LIMIT') + for (const deflate of [false, true]) failure(await run(root, { path: fileAt(root, textPdf([padded(1024 * 1024 + 1)], { deflate })) }), 'OUTPUT_LIMIT') + failure(await run(root, { path: fileAt(root, textPdf([show('x')], { deflate: true, extra: '/DecodeParms << /Predictor 12 >>' })) }), 'UNSUPPORTED_DOCUMENT') + }) +}) +await test('unique decoded streams and repeated shared work have separate aggregate caps', async () => { + await withTempDir('pdf-aggregate', async root => { + const content = padded(1024 * 1024) + const unique = await run(root, { path: fileAt(root, textPdf(Array(17).fill(content), { deflate: true })), limit: 1 }) + failure(unique, 'OUTPUT_LIMIT'); assert.equal(unique.pagesObserved, 16); assert.equal(unique.decodedBytes, 16 * 1024 * 1024); assert.equal(unique.items[0].text, 'ok') + const objects = [catalog, '<< /Type /Pages /Kids [' + Array.from({ length: 17 }, (_, i) => (5 + i) + ' 0 R').join(' ') + '] /Count 17 >>', font, stream(content, { deflate: true }), ...Array(17).fill(page('4 0 R'))] + const shared = await run(root, { path: fileAt(root, pdf(objects)), limit: 1 }) + failure(shared, 'OUTPUT_LIMIT'); assert.equal(shared.pagesObserved, 16); assert.equal(shared.decodedBytes, 1024 * 1024); assert.equal(shared.processedBytes, 16 * 1024 * 1024) + const array = [catalog, '<< /Type /Pages /Kids [4 0 R] /Count 1 >>', font, page('[5 0 R 6 0 R]'), stream(padded(600000), { deflate: true }), stream(padded(600000), { deflate: true })] + failure(await run(root, { path: fileAt(root, pdf(array)) }), 'OUTPUT_LIMIT') + }) +}) +await test('content arrays concatenate in order and inherited resources resolve without a second decoding', async () => { + await withTempDir('pdf-inherit', async root => { + const objects = [catalog, '<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /Resources << /Font << /F1 3 0 R >> >> >>', font, + '<< /Type /Page /Parent 2 0 R /Contents [6 0 R 7 0 R] >>', '<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>', stream(show('first'), { deflate: true }), stream(show('second'), { deflate: true })] + const body = await run(root, { path: fileAt(root, pdf(objects)) }) + assert.equal(body.complete, true); assert.deepEqual(body.items.map(i => i.runs), [['first', 'second'], ['first']]) + assert.equal(body.decodedBytes, Buffer.byteLength(show('first') + show('second'))); assert.equal(body.processedBytes, body.decodedBytes + Buffer.byteLength(show('first'))) + }) +}) +await test('read-only PDF observations create nothing and repeat credential/no-follow checks', async () => { + await withTempDir('pdf-authority', async root => { + const file = fileAt(root, textPdf([show('safe')])), before = fs.readdirSync(root) + assert.equal((await run(root, { path: file }, { readOnly: true })).complete, true); assert.deepEqual(fs.readdirSync(root), before) + failure(await run(root, { path: file }, { authority: { deny_roots: [root] } }), 'CREDENTIAL_DENIED') + const target = path.join(root, 'target'); fs.mkdirSync(target); const hidden = fileAt(target, textPdf([show('secret')])) + const alias = path.join(root, 'alias'); fs.symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + const denied = await run(root, { path: path.join(alias, path.basename(hidden)) }); assert.ok(denied.error); assert.equal(denied.error.effectState, 'none'); assert.equal(denied.items, undefined) + }) +}) +await test('reference spill reconstructs complete PDF pages and retains source coverage and root', async () => { + await withTempDir('pdf-spill', async root => { + const cache = path.join(root, 'cache'); fs.mkdirSync(cache, { mode: 0o700 }) + const value = Buffer.alloc(30000, 0xe9), expected = 'é'.repeat(30000) + const file = fileAt(root, textPdf([show(value), show('last')], { deflate: true })) + const head = await run(root, { path: file }, { cache, maxBytes: 4096 }) + assert.ok(head.reference, JSON.stringify(head)); assert.equal(head.complete, false); assert.equal(head.nextOffset, 1) + assert.equal(head.referenceRoot, process.platform === 'win32' ? path.toNamespacedPath(fs.realpathSync.native(root)) : fs.realpathSync(root)) + const records = []; let offset = 0, source + for (;;) { const next = await run(head.referenceRoot, { action: 'page', id: head.reference.refId, offset, limit: 512, maxBytes: 65536 }, { cache, tool: 'result', readOnly: true }); assert.equal(next.error, undefined); records.push(...next.items); source = next.source; assert.ok(next.nextOffset > offset); offset = next.nextOffset; if (next.complete) break } + assert.equal(source.scope, 'page-text-layer-content-order'); assert.equal(source.scanComplete, true); assert.equal(source.captureComplete, true); assert.equal(source.total, 2) + const rows = []; let chunks = [], byteOffset = 0 + for (const item of records) { assert.equal(item.index, rows.length); if (item.kind === 'item') { rows.push(item.value); continue } assert.equal(item.chunk, chunks.length); assert.equal(item.byteOffset, byteOffset); chunks.push(item.text); byteOffset += Buffer.byteLength(item.text); if (item.complete) { rows.push(JSON.parse(chunks.join(''))); chunks = []; byteOffset = 0 } } + assert.deepEqual(rows.map(row => row.text), [expected, 'last']); assert.equal(chunks.length, 0); assert.deepEqual(rows[0].runs, [expected]) + const readonly = await run(root, { path: file }, { cache, readOnly: true, maxBytes: 4096 }); assert.equal(readonly.reference, undefined); assert.equal(readonly.storageError.code, 'POLICY_DENIED'); assert.equal(readonly.complete, false) + }) +}) +await test('transport output limit still reports no business effects in writable sessions', async () => { + await withTempDir('pdf-transport', async root => { failure(await run(root, { path: fileAt(root, textPdf([show('x'.repeat(30000))])) }, { maxBytes: 1024 }), 'OUTPUT_LIMIT') }) +}) +await test('out-of-range offsets count actual skips and sparse blank pages remain empty', async () => { + await withTempDir('pdf-sparse', async root => { + const file=fileAt(root,textPdf([show('one'),show('two')])) + const beyond=await run(root,{path:file,offset:32,limit:1}) + assert.equal(beyond.skipped,2);assert.equal(beyond.total,2);assert.deepEqual(beyond.items,[]);assert.equal(beyond.complete,true) + const blanks=[catalog,'<< /Type /Pages /Kids [4 0 R 5 0 R 6 0 R] /Count 3 >>',font, + '<< /Type /Page /Parent 2 0 R >>',page('null'),page('7 0 R'),stream('% comment only\n')] + const body=await run(root,{path:fileAt(root,pdf(blanks))}) + assert.equal(body.complete,true);assert.deepEqual(body.items.map(item=>item.runs),[[],[],[]]) + failure(await run(root,{path:fileAt(root,textPdf(['BT /F1 12 Tf (x) Tj 0 0 m ET']))}),'DOCUMENT_PARSE_ERROR') + }) +}) +await test('stream-valued font and resource dictionaries cannot be interpreted as plain values', async () => { + await withTempDir('pdf-stream-dicts',async root=>{ + const tree='<< /Type /Pages /Kids [4 0 R] /Count 1 >>' + const fontStream=stream('',{extra:'/Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding'}) + failure(await run(root,{path:fileAt(root,pdf([catalog,tree,fontStream,page('5 0 R'),stream(show('text'))]))}),'DOCUMENT_PARSE_ERROR') + const resourcePage='<< /Type /Page /Parent 2 0 R /Resources 6 0 R /Contents 5 0 R >>' + failure(await run(root,{path:fileAt(root,pdf([catalog,tree,font,resourcePage,stream(show('text')),stream('',{extra:'/Font << /F1 3 0 R >>'})]))}),'DOCUMENT_PARSE_ERROR') + }) +}) +report('pdf-worker') From 4958350625cd20de1397195d848c18008495189d Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:53:51 +0800 Subject: [PATCH 089/118] feat(documents): expose scoped PDF text readers --- docs/CHANGELOG.md | 4 +- docs/native-kernel.md | 11 ++- ...37\347\224\237\345\206\205\346\240\270.md" | 8 +- dsh-plugin.json | 3 +- src/capabilities/discovery.ts | 1 + src/capabilities/documents.ts | 13 ++- src/capabilities/registry.ts | 2 +- src/modes/native/documents.ts | 5 +- src/modes/ptc/continuation.ts | 4 +- test/capabilities.test.mjs | 6 +- test/discovery.test.mjs | 4 +- test/pdf-host.test.mjs | 95 +++++++++++++++++++ test/types/ptc-sdk.ts | 16 ++++ 13 files changed, 155 insertions(+), 17 deletions(-) create mode 100644 test/pdf-host.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index aed9ebe3..aa91ab42 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,8 +19,10 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm with explicit exclusions, per-stream/page/aggregate limits and no OCR or layout claims. - Preserve preceding complete pages on unsupported content, keep totals/revision conditional on stable full scans, and capture full pages before bounded reference spill. +- Publish discoverable-only Native PDF text and typed PTC page/run outcomes, retaining source scope, + safe failed prefixes, forward stable-scan continuations and reference recovery metadata. - 新增受限 PDF 文本页及 run 读取,明确排除内容和累计预算;保留此前完整页, - 仅完整稳定扫描提供总数/修订,复用只读权限与无损引用,不声称 OCR 或阅读布局。 + 仅完整稳定扫描提供总数/修订,复用只读权限与无损引用,不声称 OCR 或阅读布局。Native/PTC 按代理按需启用,保留正文缺口与类型化页数据。 ### Add bounded PDF container foundation (#261, 15c/16) diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 79125560..fbcf31f8 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -254,8 +254,8 @@ introduced together in the following PDF extraction slice. The one-shot worker accepts strict pdf_read { path, offset?, limit? }. The host resolves path from its current directory. Offset is a 1-based page number (1–32, default 1); -limit is 1–32 pages (default 10). This remains an internal worker capability until the -separate discovery/Native/PTC adapters are connected; no host tool is registered here. +limit is 1–32 pages (default 10). The capability stays outside the default schema. Native selects it +through `ops_discover`, PTC through `ops_sdk`; it activates at the owning agent's next step. Each item is { page, text, runs: string[] }. Runs retain individual PDF text-show operations; TJ spacing numbers are not characters. Text joins runs with newlines for @@ -291,3 +291,10 @@ sink captures whole pages before clipping, and stored pages retain original scop scan/capture completeness. A reference page being complete does not fill a source gap. Read-only, credential denial, no-follow opens and no-business-effect failures use the same worker authority checks as the other document readers. + +Native shows page numbers and escaped text with the explicit text-layer scope and exclusions. +PTC generated declarations retain page, text and string[] runs; unsupported or malformed observations +keep canonical prefix data inside failure Outcomes without a looping page continuation. Stable full +scans alone permit forward rescan pages, with revisions compared before combining results. References +retain typed original page coverage, decoded/processed work, scope and capture facts. All reads use +the same live authority, credentials, no-follow, shared budgets and cancellation path as file tools. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index e62fd206..de36bc3e 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -203,7 +203,7 @@ FILE_CHANGED 始终使所请求页不完整。引用页生成的 source 类型 单次 worker 接受严格的 pdf_read { path, offset?, limit? }。宿主从当前目录 解析路径。offset 为 1 起始页码(1–32,默认 1),limit 为 1–32 页(默认 10)。 -发现、Native/PTC 适配器由后续切片接入;本切片不注册宿主工具。 +该能力默认不出现在 schema;经发现或 SDK 选择后,在所属代理下一步骤启用。 每项为 { page, text, runs: string[] }。runs 保留各 PDF 显示文本操作; TJ 的间距数字不当作字符。text 仅以换行连接各 run 供展示。范围明确为 @@ -231,3 +231,9 @@ scanComplete 表示整个有界文本范围的扫描完整性,pageComplete 表 返回错误/恢复指引,不生成总数或 revision;组合重扫分页前必须比较 revision。 共享引用接收器在展示裁剪前捕获完整页并保留范围、扫描/捕获事实;引用分页完成 不等于源文档缺口已恢复。只读、凭据拒绝、nofollow 及无业务副作用沿用统一权限边界。 + +PDF 能力不进入默认 schema:Native 经 `ops_discover`、PTC 经 `ops_sdk` 选择,在所属代理下一 +步骤启用。Native 显示页码、转义文本与明确的文本层范围/排除内容;PTC 生成类型保留 page、text +和 string[] runs。不支持或非法页面在失败 Outcome 内保留此前规范数据,不生成循环页续取; +仅稳定完整扫描可前进分页,组合前须比较 revision。引用保留原页面覆盖率、解码/处理工作量、 +范围与捕获事实;每次读取复用文件工具的实时权限、凭据、nofollow、共享预算和取消路径。 diff --git a/dsh-plugin.json b/dsh-plugin.json index 6f385f64..0b25f6f3 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -87,6 +87,7 @@ "edit_many", "table_read", "doc_read", + "pdf_read", "result" ], "shellPermission": "Per-call DSH sandbox policy and native subprocess" @@ -112,7 +113,7 @@ "targets": ["win32-x64", "linux-x64", "darwin-arm64"], "presentation": "PTC-only; reader/result/SDK loader resident, other APIs become visible on the next agent step", "runtime": "independent native Rust file worker", - "tools": ["ops_inspect_local_file","ops_grep","ops_glob","ops_replace","ops_write_file","ops_edit_file","ops_diff","ops_stat","ops_copy","ops_move","ops_remove","ops_read_many","ops_edit_many","ops_table_read","ops_doc_read","ops_batch","ops_result","ops_sdk"], + "tools": ["ops_inspect_local_file","ops_grep","ops_glob","ops_replace","ops_write_file","ops_edit_file","ops_diff","ops_stat","ops_copy","ops_move","ops_remove","ops_read_many","ops_edit_many","ops_table_read","ops_doc_read","ops_pdf_read","ops_batch","ops_result","ops_sdk"], "permissions": "Per-call original DSH sandboxPolicy and sandbox.confine (host full/partial enforcement reported); full access bypasses confinement and permits explicit operation roots; subprocess owns cancellation and credential scrub" }, { diff --git a/src/capabilities/discovery.ts b/src/capabilities/discovery.ts index 91b1cd28..ed4b8b24 100644 --- a/src/capabilities/discovery.ts +++ b/src/capabilities/discovery.ts @@ -3,6 +3,7 @@ import type { ParameterSchema } from './types.js' /** Reviewed, currently implemented advanced operations. Future formats are absent. */ export const DISCOVERY_TERMS = Object.freeze({ + pdf_read: 'pdf portable document pages text extraction font', doc_read: 'docx word document paragraphs body text tables zip xml unicode', table_read: 'table csv tsv delimiter quoted records columns rows encoding utf16', inspect_local_file: 'read inspect single file text hex binary bounded line windows', diff --git a/src/capabilities/documents.ts b/src/capabilities/documents.ts index 659e13c7..0fe3706f 100644 --- a/src/capabilities/documents.ts +++ b/src/capabilities/documents.ts @@ -8,6 +8,11 @@ const boolean: JsonSchemaNode = { type: 'boolean' } const strings: JsonSchemaNode = { type: 'array', items: string } const object = (properties: Record, required: string[] = []): ParameterSchema => ({ type: 'object', properties, required, additionalProperties: false }) export const DOCUMENT_PARAMETERS = Object.freeze({ + pdf_read: object({ + path: { ...string, description: 'Regular PDF file path relative to the current session cwd.' }, + offset: { type: 'integer', default: 1, description: '1-32 pages, 1-based.' }, + limit: { type: 'integer', default: 10, description: '1-32 pages.' }, + }, ['path']), doc_read: object({ path: { ...string, description: 'Regular DOCX file path relative to the current session cwd. Binary DOC is unsupported.' }, offset: { type: 'integer', default: 1, description: '1-100000 main-body paragraphs, 1-based.' }, @@ -23,11 +28,12 @@ export const DOCUMENT_PARAMETERS = Object.freeze({ }, ['path']), }) export const DOCUMENT_DESCRIPTIONS = Object.freeze({ + pdf_read: 'Read bounded PDF 1.3-1.7 page text and individual runs relative to session cwd. Restricted classic-xref PDFs with standard Helvetica/Courier/Times Type1 fonts and Standard/WinAnsi encoding only. Scope is text-layer content order; excludes rendered reading order, images/OCR, annotations/forms and metadata. Unsupported or malformed later pages retain preceding complete pages with error/nextAction. Check scanComplete versus pageComplete; total/revision require a stable full scan. 16 MiB input and aggregate decoded/processed streams, 1 MiB/page, 32 pages, 30s. Page offsets cannot bypass physical gaps; compare revisions across calls. Failures return status:error|rejected and error.code; never throw.', table_read: 'Read bounded CSV/TSV lexical string cells relative to session cwd. Preserves quoted multiline records and physical LF lineStart/lineEnd. Check scanComplete versus pageComplete; total/revision exist only after a stable full scan. Up to 32 MiB, 100000 data records, 1 MiB/record, 1024 columns and 30s; row paging cannot bypass scan gaps. Errors retain safe preceding rows with nextAction. Compare revisions before combining rescanned pages. No spreadsheet formulas or type coercion. Failures return status:error|rejected and error.code; never throw.', doc_read: 'Read bounded DOCX main-body paragraphs relative to session cwd, preserving Unicode text and table/row/cell coordinates. Scope excludes layout, headers/footers, notes, comments and metadata; complete applies only to that scope. Standard stored/deflated ZIP, strict UTF-8/BOM UTF-16; unsupported content and parse errors retain safe preceding paragraphs with nextAction. Check scanComplete versus pageComplete; total/revision require a stable full scan. 32 MiB archive, 8 MiB main XML, 1 MiB/paragraph, 100000 paragraphs, 30s. Paging cannot bypass physical gaps; compare revisions across calls. No binary DOC, macros, external fetch or extraction to disk. Failures return status:error|rejected and error.code; never throw.', }) export const DOCUMENT_FACTS: Record = { - scope: string, excludedContent: strings, blocksObserved: integer, + scope: string, excludedContent: strings, blocksObserved: integer, pagesObserved: integer, decodedBytes: integer, processedBytes: integer, format: string, encoding: string, header: boolean, columns: strings, columnCount: integer, bytes: integer, bytesRead: integer, rowsObserved: integer, skipped: integer, scanComplete: boolean, pageComplete: boolean, complete: boolean, truncated: boolean, @@ -47,5 +53,8 @@ const DOCX_OUTPUT: JsonSchemaNode = { ...TABLE_OUTPUT, properties: { ...TABLE_OU items: { type: 'array', items: object({ block: integer, type: { type: 'string', enum: ['paragraph'] }, text: string, table: integer, row: integer, cell: integer }, ['block', 'type', 'text']) }, } } +const PDF_OUTPUT: JsonSchemaNode = { ...TABLE_OUTPUT, properties: { ...TABLE_OUTPUT.properties, + items: { type: 'array', items: object({ page: integer, text: string, runs: strings }, ['page', 'text', 'runs']) }, +} } export function isDocumentTool(tool: string): tool is keyof typeof DOCUMENT_PARAMETERS { return Object.hasOwn(DOCUMENT_PARAMETERS, tool) } -export function documentOutput(tool: string): JsonSchemaNode | undefined { return tool === 'doc_read' ? DOCX_OUTPUT : tool === 'table_read' ? TABLE_OUTPUT : undefined } +export function documentOutput(tool: string): JsonSchemaNode | undefined { return tool === 'pdf_read' ? PDF_OUTPUT : tool === 'doc_read' ? DOCX_OUTPUT : tool === 'table_read' ? TABLE_OUTPUT : undefined } diff --git a/src/capabilities/registry.ts b/src/capabilities/registry.ts index c03e6a53..38bb06d8 100644 --- a/src/capabilities/registry.ts +++ b/src/capabilities/registry.ts @@ -10,7 +10,7 @@ import type { CapabilityMetadata, Component, SchemaCapability } from './types.js export type FileToolName = keyof typeof fileParameters export type PtcToolName = keyof typeof ptcParameters // Preserve the published registration order independently of schema object key order. -const fileOrder = ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read', 'doc_read'] as const satisfies readonly FileToolName[] +const fileOrder = ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read', 'doc_read', 'pdf_read'] as const satisfies readonly FileToolName[] const fileMutations = new Set(['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) const allPresentations = Object.freeze(['native', 'ptc', 'both'] as const) diff --git a/src/modes/native/documents.ts b/src/modes/native/documents.ts index 1a14b18a..aadf49be 100644 --- a/src/modes/native/documents.ts +++ b/src/modes/native/documents.ts @@ -6,14 +6,15 @@ const facts = (value: Row, keys: string[]): string => keys.filter(key => value[k export function renderDocument(tool: string, args: Row, value: Row): string[] { const items = Array.isArray(value.items) ? value.items.filter(object) : [] const lines = ['=== DOCUMENT: ' + show(value.path ?? args.path ?? '(path unavailable)') + ' ===', - '[Observation: ' + facts(value, ['status', 'format', 'encoding', 'header', 'bytes', 'bytesRead', 'revision', 'columnCount']) + '.]', - '[Coverage: ' + facts(value, ['scanComplete', 'pageComplete', 'complete', 'rowsObserved', 'blocksObserved', 'skipped', 'total', 'omitted', 'nextOffset', 'truncatedBy']) + '.]'] + '[Observation: ' + facts(value, ['status', 'format', 'encoding', 'header', 'bytes', 'bytesRead', 'decodedBytes', 'processedBytes', 'revision', 'columnCount']) + '.]', + '[Coverage: ' + facts(value, ['scanComplete', 'pageComplete', 'complete', 'rowsObserved', 'blocksObserved', 'pagesObserved', 'skipped', 'total', 'omitted', 'nextOffset', 'truncatedBy']) + '.]'] if (typeof value.scope === 'string') lines.push('[Scope: ' + show(value.scope) + '; excludedContent=' + show(value.excludedContent) + '. Completeness applies only to this scope.]') if (Array.isArray(value.columns)) lines.push('Columns: ' + show(value.columns)) if (object(value.error)) lines.push('Error: ' + facts(value.error, ['code', 'message', 'effectState', 'nextAction'])) if (value.scanComplete === false || object(value.error)) lines.push('[Notice: This observation is not exhaustive. Only preceding complete records are shown; page offsets do not recover a physical scan gap.]') for (const item of items) lines.push(tool === 'table_read' ? 'Row ' + show(item.row) + ' (LF lines ' + show(item.lineStart) + '-' + show(item.lineEnd) + ') | ' + show(item.cells) + : tool === 'pdf_read' ? 'Page ' + show(item.page) + ' [runs=' + (Array.isArray(item.runs) ? item.runs.length : 'unknown') + '] | ' + show(item.text) : 'Paragraph ' + show(item.block) + ' [' + facts(item, ['table', 'row', 'cell']) + '] | ' + show(item.text)) if (!items.length) lines.push('(no records in this displayed range)') lines.push('[Notice: displayedRecords=' + items.length + '. Scan, requested range, and displayed content have separate completeness.]') diff --git a/src/modes/ptc/continuation.ts b/src/modes/ptc/continuation.ts index a199c03b..de337159 100644 --- a/src/modes/ptc/continuation.ts +++ b/src/modes/ptc/continuation.ts @@ -2,7 +2,7 @@ import type { CanonicalData, JsonValue } from './outcome.js' const object = (value: unknown): CanonicalData | undefined => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as CanonicalData : undefined const integer = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0 -const readers = ['inspect_local_file', 'read_many', 'glob', 'grep', 'diff', 'result', 'table_read', 'doc_read'] as const +const readers = ['inspect_local_file', 'read_many', 'glob', 'grep', 'diff', 'result', 'table_read', 'doc_read', 'pdf_read'] as const export type ContinuationTool = typeof readers[number] export interface RetrievalContinuation { version: 1, tool: ContinuationTool, args: CanonicalData } const PREFIX = 'ops-next-v1:' @@ -53,7 +53,7 @@ export function continuationFor(tool: string, rawArgs: unknown, data: CanonicalD if ((tool === 'grep' || tool === 'glob') && data.scanComplete === true && typeof data.continuation === 'string') { return encode(tool, { ...args, continuation: data.continuation, ...(integer(data.nextOffset) ? { offset: data.nextOffset } : {}) }) } - if (tool === 'table_read' || tool === 'doc_read') { + if (tool === 'table_read' || tool === 'doc_read' || tool === 'pdf_read') { const offset = args.offset ?? 1 return data.scanComplete === true && typeof data.revision === 'string' && /^[a-f0-9]{64}$/u.test(data.revision) && integer(offset) && offset > 0 && integer(data.nextOffset) && data.nextOffset > offset diff --git a/test/capabilities.test.mjs b/test/capabilities.test.mjs index 267d4c3c..0495aa3f 100644 --- a/test/capabilities.test.mjs +++ b/test/capabilities.test.mjs @@ -10,9 +10,9 @@ import { BASH_TOOL_NAME } from '../lib/shells/constants.js' import { assert, test, report } from './lib/harness.mjs' await test('the catalogue covers every owned file, PTC and shell tool exactly once', () => { - assert.equal(CAPABILITIES.length, 20) + assert.equal(CAPABILITIES.length, 21) assert.equal(new Set(CAPABILITIES.map(item => item.rawName)).size, CAPABILITIES.length) - assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read', 'doc_read']) + assert.deepEqual(FILE_TOOLS, ['inspect_local_file', 'grep', 'glob', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many', 'table_read', 'doc_read', 'pdf_read']) assert.deepEqual(FILE_MUTATIONS, ['replace', 'write_file', 'edit_file', 'copy', 'move', 'remove', 'edit_many']) assert.deepEqual(PTC_TOOLS, ['batch', 'result']) assert.deepEqual(IN_PROCESS_TOOLS, ['bash']) @@ -55,7 +55,7 @@ const snapshot = (names, definition) => names.map(name => { await test('business file and batch definitions retain their original serialized contracts', () => { // Captured before this migration, covering every argument/output contract and published description. const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') - assert.equal(hash(snapshot(FILE_TOOLS.filter(name => !['glob', 'grep', 'read_many', 'edit_many', 'table_read', 'doc_read'].includes(name)), fileDefinition)), 'f32e33306b4052fdb3d19a11e0fe9d65c255a87d2d485de08a9d7b1275e958fc') + assert.equal(hash(snapshot(FILE_TOOLS.filter(name => !['glob', 'grep', 'read_many', 'edit_many', 'table_read', 'doc_read', 'pdf_read'].includes(name)), fileDefinition)), 'f32e33306b4052fdb3d19a11e0fe9d65c255a87d2d485de08a9d7b1275e958fc') const batch = ptcDefinition('batch', async () => null) assert.equal(batch.parameters, ptcParameters.batch); assert.ok(batch.description.startsWith(ptcDescriptions.batch)); assert.match(batch.description, /PTC returns Outcome/) assert.ok(batch.output.schema.oneOf.every(branch => branch.properties.status)) diff --git a/test/discovery.test.mjs b/test/discovery.test.mjs index aa5bf8ba..19c860b0 100644 --- a/test/discovery.test.mjs +++ b/test/discovery.test.mjs @@ -18,7 +18,7 @@ await test('discovery config validates its own keys and copies bounded operator const selected = resolveDiscoveryConfig(input) input.allowedTools.push('diff') assert.deepEqual(selected.allowedTools, ['stat']) - for (const raw of [null, [], false, { enabled: 1 }, { maxResults: 0 }, { maxResults: 6 }, { maxResults: 1.5 }, { maxResults: null }, { allowedTools: ['pdf_read'] }, { alwaysLoaded: ['stat'], allowedTools: [] }]) assert.throws(() => resolveDiscoveryConfig(raw)) + for (const raw of [null, [], false, { enabled: 1 }, { maxResults: 0 }, { maxResults: 6 }, { maxResults: 1.5 }, { maxResults: null }, { allowedTools: ['unknown_document_reader'] }, { alwaysLoaded: ['stat'], allowedTools: [] }]) assert.throws(() => resolveDiscoveryConfig(raw)) assert.throws(() => resolveDiscoveryConfig({ allowedTool: [] }), /unknown discovery key: allowedTool/) }) await test('discovery arguments enforce max_results 1-5 and reject hidden action payloads', () => { @@ -26,7 +26,7 @@ await test('discovery arguments enforce max_results 1-5 and reject hidden action for (const raw of [null, {}, { query: '' }, { query: 'a'.repeat(513) }, { query: 'stat', path: 'file' }, ...[0, 6, 1.5, null, '3'].map(max_results => ({ query: 'stat', max_results }))]) assert.equal(typeof parseRequest(raw), 'string') assert.deepEqual(search('select:ops_stat', DISCOVERABLE_TOOLS, 5), ['stat']) assert.deepEqual(search('metadata revision hash', DISCOVERABLE_TOOLS, 1), ['stat']) - assert.deepEqual(search('select:ops_pdf_read', DISCOVERABLE_TOOLS, 5), []) + assert.deepEqual(search('select:ops_pdf_read', DISCOVERABLE_TOOLS, 5), ['pdf_read']) }) await test('discovery-entry schema has a reproducible pinned token measurement, separate from the full BM-03 gate', () => { const wire = JSON.stringify([{ name: publicToolName('discover'), description: DISCOVERY_DESCRIPTION, parameters: DISCOVERY_PARAMETERS }]) diff --git a/test/pdf-host.test.mjs b/test/pdf-host.test.mjs new file mode 100644 index 00000000..9834a3c0 --- /dev/null +++ b/test/pdf-host.test.mjs @@ -0,0 +1,95 @@ +/** Real Native/PTC PDF observations preserve restricted text scope and bounded page recovery. */ +import fs from 'node:fs' +import path from 'node:path' +import { assertSupportedJsonSchema, jsonSchemaToTs } from '@deepseek-ai/dsh-tools' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { shutdown } from './lib/host.mjs' +import { requireWorker } from './lib/boundary.mjs' +import { textPdf, show } from './lib/pdf-fixture.mjs' +import { documentHost, documentNames, documentCall, documentCode, activateDocument } from './lib/document-host.mjs' +import { capability } from '../lib/capabilities/registry.js' +import { publicToolName } from '../lib/policy.js' +import { canonicalOutput } from '../lib/modes/ptc/canonical-schema.js' +import { decodeContinuation } from '../lib/modes/ptc/continuation.js' +import { definition } from '../lib/file/contract.js' +requireWorker('pdf-host') +const TOOL = publicToolName('pdf_read') +const render = value => definition('pdf_read', async () => value).output.render({ path: 'text.pdf' }, value)[0].text +await test('PDF capability stays discoverable and generates strict page/text/run contracts', () => { + const entry = capability('pdf_read'); assert.equal(entry.availability, 'discoverable'); assert.equal(entry.mutates, false) + assert.equal(entry.parameters.additionalProperties, false); assert.deepEqual(entry.parameters.required, ['path']) + const schema = canonicalOutput('pdf_read'); assertSupportedJsonSchema(schema) + assert.equal(schema.properties.items.items.properties.runs.items.type, 'string') + assert.equal(schema.properties.items.items.properties.block, undefined); assert.equal(schema.properties.source.properties.pagesObserved.type, 'integer') + assert.match(jsonSchemaToTs(schema), /runs: string\[\]/); assert.match(entry.description, /images\/OCR/) +}) +await test('real PDF discovery activates next step for its owner and PTC pages preserve exact text runs', () => withTempDir('pdf-host-pages', async root => { + const cwd = path.join(root, 'nested'); fs.mkdirSync(cwd) + fs.writeFileSync(path.join(cwd, 'text.pdf'), textPdf([show('BM04 PDF text'), show(Buffer.from([0x80, 0x95, 0xa0]))], { deflate: true })) + const { ctx, agent } = await documentHost(root, ['pdf_read'], { real: true }) + try { + const native = await agent('native', { cwd }), sibling = await agent(), child = await agent('native', { parent: native }), code = await agent('ptc', { cwd }) + const initial = await ctx.get('systemPrompt').assemble({ scope: native }) + assert.equal(initial.tools.some(tool => tool.name === TOOL), false) + await activateDocument(ctx, native, 'pdf_read'); await activateDocument(ctx, code, 'pdf_read', 'ptc') + for (const other of [sibling, child]) assert.equal(documentNames(ctx, other).includes(TOOL), false) + const raw = await documentCall(ctx, native, 'pdf_read', { path: 'text.pdf', limit: 1 }) + assert.equal(raw.scope, 'page-text-layer-content-order'); assert.equal(raw.total, 2); assert.equal(raw.scanComplete, true) + assert.deepEqual(raw.items, [{ page: 1, text: 'BM04 PDF text', runs: ['BM04 PDF text'] }]) + const text = render(raw); assert.match(text, /Page 1/); assert.match(text, /BM04 PDF text/); assert.match(text, /images and OCR/) + assert.match(text, /Completeness applies only to this scope/) + const result = await documentCode(ctx, code, `return await tools.${TOOL}({path:'text.pdf',limit:1});`) + assert.equal(result.status, 'partial'); assert.equal(result.data.items[0].runs[0], 'BM04 PDF text') + const next = decodeContinuation(result.continuation); assert.equal(next.tool, 'pdf_read'); assert.equal(next.args.offset, 2) + const tail = await documentCall(ctx, code, next.tool, next.args) + assert.equal(tail.status, 'completed'); assert.equal(tail.data.revision, result.data.revision) + assert.deepEqual(tail.data.items[0], { page: 2, text: '€• ', runs: ['€• '] }) + } finally { await shutdown(ctx) } +})) +await test('unsupported later PDF pages retain only preceding complete pages and never resume the same gap', () => withTempDir('pdf-host-gap', async root => { + fs.writeFileSync(path.join(root, 'text.pdf'), textPdf([show('safe prefix'), show('not complete') + '/Im1 Do\n'])) + const { ctx, agent } = await documentHost(root, ['pdf_read']) + try { + const native = await agent(), code = await agent('ptc') + await activateDocument(ctx, native, 'pdf_read'); await activateDocument(ctx, code, 'pdf_read', 'ptc') + const raw = await documentCall(ctx, native, 'pdf_read', { path: 'text.pdf', limit: 1 }) + assert.equal(raw.scanComplete, false); assert.equal(raw.pageComplete, true); assert.equal(raw.pagesObserved, 1) + assert.equal(raw.total, undefined); assert.match(render(raw), /UNSUPPORTED_DOCUMENT/); assert.match(render(raw), /Page 1.*safe prefix/) + assert.match(render(raw), /not exhaustive/); assert.doesNotMatch(render(raw), /not complete/) + const result = await documentCall(ctx, code, 'pdf_read', { path: 'text.pdf', limit: 1 }) + assert.equal(result.status, 'error'); assert.equal(result.error.code, 'UNSUPPORTED_DOCUMENT'); assert.equal(result.error.retry, 'never') + assert.equal(result.continuation, undefined); assert.equal(result.data.items[0].text, 'safe prefix'); assert.equal(result.data.revision, undefined) + assert.match(result.error.nextAction, /page offsets do not bypass/) + } finally { await shutdown(ctx) } +})) +await test('PDF current credential policy and read-only authority survive decoding; malformed files stay errors', () => withTempDir('pdf-host-authority', async root => { + fs.writeFileSync(path.join(root, 'text.pdf'), textPdf([show('readonly')])) + fs.writeFileSync(path.join(root, 'id_ed25519.pdf'), textPdf([show('SECRET')])) + fs.writeFileSync(path.join(root, 'broken.pdf'), '%PDF-1.4\n') + const before = fs.readdirSync(root).sort(), { ctx, agent } = await documentHost(root, ['pdf_read']) + try { + const owner = await agent('ptc', { policy: 'read-only' }); await activateDocument(ctx, owner, 'pdf_read', 'ptc') + assert.equal((await documentCall(ctx, owner, 'pdf_read', { path: 'text.pdf' })).status, 'completed') + const denied = await documentCall(ctx, owner, 'pdf_read', { path: 'id_ed25519.pdf' }) + assert.equal(denied.error.code, 'CREDENTIAL_DENIED'); assert.equal(denied.error.effectState, 'none'); assert.doesNotMatch(JSON.stringify(denied), /SECRET/) + const broken = await documentCall(ctx, owner, 'pdf_read', { path: 'broken.pdf' }) + assert.equal(broken.status, 'error'); assert.equal(broken.error.code, 'DOCUMENT_PARSE_ERROR'); assert.equal(broken.continuation, undefined) + const invalid = await documentCall(ctx, owner, 'pdf_read', { path: 'text.pdf', limit: 33 }) + assert.equal(invalid.error.code, 'INVALID_ARGUMENT'); assert.deepEqual(fs.readdirSync(root).sort(), before) + } finally { await shutdown(ctx) } +})) +await test('large PDF text and run data spill with original page scope and bounded Native output', () => withTempDir('pdf-host-spill', async root => { + fs.writeFileSync(path.join(root, 'text.pdf'), textPdf([show('ab'.repeat(40000))], { deflate: true })) + const { ctx, agent } = await documentHost(root, ['pdf_read', 'result']) + try { + const owner = await agent(); await activateDocument(ctx, owner, 'pdf_read'); await activateDocument(ctx, owner, 'result') + const raw = await documentCall(ctx, owner, 'pdf_read', { path: 'text.pdf' }) + assert.equal(raw.reference.producerTool, 'pdf_read'); assert.equal(raw.scanComplete, true); assert.equal(raw.complete, false) + const content = render(raw); assert.ok(Buffer.byteLength(JSON.stringify([{ type: 'text', text: content }])) <= 65536); assert.match(content, /ref:\/\//) + const page = await documentCall(ctx, owner, 'result', { action: 'page', id: raw.reference.refId, root: raw.referenceRoot, limit: 1 }) + assert.equal(page.source.scope, 'page-text-layer-content-order'); assert.equal(page.source.pagesObserved, 1) + assert.equal(page.source.captureComplete, true); assert.ok(page.source.decodedBytes > 80000); assert.ok(page.source.processedBytes > 80000) + assert.equal(page.items[0].kind, 'item-json-chunk'); assert.equal(page.items[0].byteOffset, 0) + } finally { await shutdown(ctx) } +})) +report('pdf-host') diff --git a/test/types/ptc-sdk.ts b/test/types/ptc-sdk.ts index 3d8bdb05..2251249b 100644 --- a/test/types/ptc-sdk.ts +++ b/test/types/ptc-sdk.ts @@ -70,3 +70,19 @@ async function paragraphs() { // @ts-expect-error DOCX decoding comes from the package, not a caller-supplied encoding. void tools.ops_doc_read({ path: 'body.docx', encoding: 'utf-8' }) void paragraphs + +async function pdfPages() { + const value = await tools.ops_pdf_read({ path: 'text.pdf', offset: 1, limit: 10 }) + if (value.status === 'completed' || value.status === 'partial') { + const runs: string[] | undefined = value.data.items?.[0]?.runs + const page: number | undefined = value.data.items?.[0]?.page + const processed: number | undefined = value.data.source?.processedBytes + // @ts-expect-error PDF pages have no DOCX paragraph block index. + void value.data.items?.[0]?.block + return { runs, page, processed } + } + return value.data?.items?.[0]?.text +} +// @ts-expect-error PDF encoding derives from supported fonts, not caller selection. +void tools.ops_pdf_read({ path: 'text.pdf', encoding: 'utf-8' }) +void pdfPages From 95b1af59370e0a4602032608bb8e3963e43249a7 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:46:16 +0800 Subject: [PATCH 090/118] refactor(config): type validation and component lifecycle boundaries --- AGENTS.md | 2 +- docs/CHANGELOG.md | 3 ++ docs/CONTRIBUTING.md | 7 ++- ...41\347\214\256\346\214\207\345\215\227.md" | 4 +- .../background/{index.js => index.ts} | 3 +- src/components/tui/{index.js => index.ts} | 13 +++++- src/{config-updates.js => config-updates.ts} | 24 ++++++---- src/{config.js => config.ts} | 46 +++++++++---------- test/types/config.ts | 30 ++++++++++++ test/types/tsconfig.json | 3 +- 10 files changed, 92 insertions(+), 43 deletions(-) rename src/components/background/{index.js => index.ts} (84%) rename src/components/tui/{index.js => index.ts} (72%) rename src/{config-updates.js => config-updates.ts} (86%) rename src/{config.js => config.ts} (81%) create mode 100644 test/types/config.ts diff --git a/AGENTS.md b/AGENTS.md index 83042cdd..b2e1475f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ node scripts/verify-gate.mjs --skip-install # the full release gate - English for code, comments, CLI output and commit messages; user-facing docs in both languages, updated in the same PR. - Tool names always come from `publicToolName()` in `src/policy.ts`; never hand-write `ops_`. -- Config is validated in `src/config.js`; unknown keys are errors that name the key. Defaults in +- Config is validated in `src/config.ts`; unknown keys are errors that name the key. Defaults in `cordis.patch.yml` must equal the defaults in code. - The host half must not import `@deepseek-ai/*` values at load time; use `ctx.inject` / `ctx.get`. Publish tools only through this plugin's own scoped `ctx.tools.register`. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8873b8cb..dd056499 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,9 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Type central configuration validation, deferred profile migrations, and the remaining TUI/background entries strictly; retain their emitted paths, defaults, validation errors and lifecycle behavior. +- 将中央配置校验、延迟 profile 迁移及剩余 TUI/background 入口迁为严格 TypeScript;保留产物路径、默认值、校验错误及生命周期行为。 + ### Type the component host entry (#261, 16/16) - Migrate the root component entry to strict TypeScript using public Cordis and Agent types; preserve configuration validation, scoped registration, shell denial and teardown behavior. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 8e42a022..74c11dce 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -65,9 +65,8 @@ be edited by hand. Package exports, component entries and tests continue to load Run `npm run build` after changing source or assets and before invoking a suite directly with `node test/run.mjs `. `npm test`, `npm run test:ptc` and `npm run verify` build first; `npm run typecheck` checks the compiler configuration and migrated TypeScript without emitting -files. JavaScript type checking remains disabled during the migration. The shell execution handle, -host contract, approvals, jobs, session executor and runtime manager use strict TypeScript; older -resolver, IPC and mount modules remain JavaScript with typed boundaries while their migration is staged. +files. JavaScript type checking remains disabled during the migration. Host execution boundaries, central configuration validation and migration, and component entry modules +use strict TypeScript. The compiler accepts remaining JavaScript until the final migration audit. For a local or Git-source installation, build and pack a checkout before installing it into DSH: @@ -158,7 +157,7 @@ catalogue, retry rules and positive/negative TypeScript contracts. Node declarat `test/lib/` layout. - **Tool names** always come from `publicToolName()` in `src/policy.ts`; never hand-write the `ops_` prefix. -- **Config validation** lives in `src/config.js`; an unknown key is an error that names the key. +- **Config validation** lives in `src/config.ts`; an unknown key is an error that names the key. Defaults in `cordis.patch.yml` must match the defaults in code. - **Parsing and probing** happen synchronously in `apply`; `required: true` must refuse activation at load time. diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 9b568bb7..8aec2679 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -49,7 +49,7 @@ npm run verify 插件源码在 `src/` 中修改。迁移期间 TypeScript 编译器兼容现有 JavaScript,并将 JavaScript 产物输出到 `lib/`;`npm run build` 会重建该目录,并复制组件 manifest、语言文件、图标和面板 HTML。`lib/` 已被 Git 忽略,不得手动修改。包导出、组件入口和测试仍然加载 `lib/`。 -修改源码或资源后,先运行 `npm run build`,再通过 `node test/run.mjs <过滤词>` 直接运行套件。`npm test`、`npm run test:ptc` 与 `npm run verify` 会先构建;`npm run typecheck` 检查编译器配置和已迁移的 TypeScript,不生成产物。迁移期间暂不启用 JavaScript 类型检查。Shell 执行句柄、宿主契约、审批、jobs、会话执行器和运行时管理器已使用严格 TypeScript;旧解析器、IPC 和挂载模块暂保留 JavaScript,并通过类型化边界分阶段迁移。 +修改源码或资源后,先运行 `npm run build`,再通过 `node test/run.mjs <过滤词>` 直接运行套件。`npm test`、`npm run test:ptc` 与 `npm run verify` 会先构建;`npm run typecheck` 检查编译器配置和已迁移的 TypeScript,不生成产物。迁移期间暂不启用 JavaScript 类型检查。宿主执行边界、中央配置校验与迁移及组件入口模块已使用严格 TypeScript。最终迁移审计完成前,编译器仍兼容剩余 JavaScript。 从本地目录或 Git 源码安装时,先在检出的仓库中构建并打包,再安装到 DSH: @@ -103,7 +103,7 @@ npm run build:bash-ipc # 仅 Windows:Bash IPC - **文档成对**:`docs/` 下同一文档中英各一份,例如 `docs/usage.md` 与 `docs/使用指南.md`,同一个 PR 里一起更新。 - **文件规模**:新文件保持在约 500 行以内。文件超过这个规模时,拆成模块,并把相关模块归到一个文件夹中,参照现有的 `src/components/*` 与 `test/lib/` 布局。 - **工具名**:一律经 `src/policy.ts` 的 `publicToolName()` 生成,不要手写 `ops_` 前缀。 -- **配置校验**:放在 `src/config.js`,未知键必须报错并点名;`cordis.patch.yml` 里的默认值必须与代码默认值一致。 +- **配置校验**:放在 `src/config.ts`,未知键必须报错并点名;`cordis.patch.yml` 里的默认值必须与代码默认值一致。 - **解析与探测**:同步放在 `apply` 里;`required: true` 必须在加载期拒绝激活。 ## 提交与 PR diff --git a/src/components/background/index.js b/src/components/background/index.ts similarity index 84% rename from src/components/background/index.js rename to src/components/background/index.ts index bf984f99..c8707d73 100644 --- a/src/components/background/index.js +++ b/src/components/background/index.ts @@ -5,9 +5,10 @@ * Existing profiles still carry this row, so it keeps loading: it accepts any config without * validating it, publishes nothing, and says once that the row can be deleted. */ +import type { Context } from '@deepseek-ai/cordis' export const name = "dsh-ops-background" export const REMOVED_MESSAGE = "dsh-ops: the dsh-ops/background component (FastCtx background jobs) was removed in this version; it publishes no tools. Delete this row from the profile configuration." -export function apply(ctx, _config) { +export function apply(ctx: Context | null | undefined, _config?: unknown): void { const logger = ctx?.logger if (typeof logger?.warn === "function") logger.warn(REMOVED_MESSAGE) else console.warn(REMOVED_MESSAGE) diff --git a/src/components/tui/index.js b/src/components/tui/index.ts similarity index 72% rename from src/components/tui/index.js rename to src/components/tui/index.ts index ae65ad33..b33c9c70 100644 --- a/src/components/tui/index.js +++ b/src/components/tui/index.ts @@ -1,4 +1,7 @@ /** Optional read-only panel. Missing tuiPanels leaves only this component pending. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-session' +import type { AttentionLevel } from '../../tui/attention.js' import { createActivityStore, resolveActivityConfig } from '../../tui/activity.js' import { createOpsPanel } from '../../tui/panel.js' import { createAttentionStore } from '../../tui/attention.js' @@ -8,9 +11,15 @@ export const name = 'dsh-ops-tui' export const inject = ['tuiPanels'] export { resolveActivityConfig as resolveTuiConfig } -export function apply(ctx, rawConfig) { +/** Narrow injected API-v1 surface, independent of a host TUI/React installation. */ +export interface TuiPanels { + register(descriptor: { apiVersion: 1; id: string; title: string; minColumns: number; component: ReturnType }): unknown + list(): readonly { id: string }[] + badge(id: string, value: { level: AttentionLevel | undefined; unread: number } | null): void +} +export function apply(ctx: Context, rawConfig?: unknown): void { const config = resolveActivityConfig(rawConfig) - const panels = ctx.get('tuiPanels') + const panels = ctx.get('tuiPanels') as TuiPanels const store = createActivityStore(config) const attention = createAttentionStore() const dispose = panels.register({ apiVersion: 1, id: 'overview', title: 'OPS', minColumns: 28, diff --git a/src/config-updates.js b/src/config-updates.ts similarity index 86% rename from src/config-updates.js rename to src/config-updates.ts index c10e3ce5..fceddbaf 100644 --- a/src/config-updates.js +++ b/src/config-updates.ts @@ -7,14 +7,20 @@ * profile or rename dsh-ops/file: name-qualified overrides would be skipped. * Explicit legacy panel capture:false is retained; no migration may start capture. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-config-editor' import { LEGACY_FASTCTX_KEYS } from './config.js' export const CONFIG_UPDATE_VERSION = 2 /** Apply every pending step in order; each step runs exactly once per row. */ -export function updatedConfig(raw = {}, disableEvaluatedFallback = false) { - const version = Number.isSafeInteger(raw?.configUpdateVersion) ? raw.configUpdateVersion : 0 - if (version >= CONFIG_UPDATE_VERSION) return raw +export function updatedConfig(value: unknown = {}, disableEvaluatedFallback = false): Record { + // The component validates effective config first; persisted raw rows can still + // contain loader expressions, so preserve their unknown values until the editor validates. + const raw = value as Record | null | undefined + const marker = raw?.configUpdateVersion + const version = typeof marker === 'number' && Number.isSafeInteger(marker) ? marker : 0 + if (version >= CONFIG_UPDATE_VERSION) return raw! const next = { ...raw } // 1 (0.2.5): retire the old implicit system-bash opt-in. Later manual opt-ins are kept. if (version < 1 && (raw?.allowSystemShellFallback === true || disableEvaluatedFallback)) next.allowSystemShellFallback = false @@ -35,7 +41,7 @@ const pending = new WeakSet() const notedInMemory = new WeakSet() const warnedSaveFailure = new WeakSet() /** True only the first time this logger is seen, so a notice is per process, not per row. */ -function firstSight(set, logger) { +function firstSight(set: WeakSet, logger: unknown): boolean { // `WeakSet` keys must be objects; a logger that is missing or not one cannot be tracked, and // there is nothing to log to anyway. if (!logger || (typeof logger !== 'object' && typeof logger !== 'function')) return false @@ -43,11 +49,11 @@ function firstSight(set, logger) { set.add(logger) return true } -function noteInMemoryOnly(ctx, reason) { +function noteInMemoryOnly(ctx: Context, reason: string): void { if (!firstSight(notedInMemory, ctx.logger)) return ctx.logger.info?.(`dsh-ops: config update ${CONFIG_UPDATE_VERSION} applied in memory only (${reason}); the migration is in effect for this activation.`) } -function warnSaveFailure(ctx, error) { +function warnSaveFailure(ctx: Context, error: unknown): void { if (!firstSight(warnedSaveFailure, ctx.logger)) return // Anything can be thrown: `error.message` on `undefined`/`null` would raise from inside the very // handler that promises to report a failed save, turning it into an unhandled rejection. @@ -80,8 +86,10 @@ function warnSaveFailure(ctx, error) { * it never writes anything, never surfaces an error into the boot path, and never * needs an owning row to be correct — while the save itself is #138's channel. */ -export function mountConfigUpdates(ctx, raw) { - if ((raw?.configUpdateVersion ?? 0) >= CONFIG_UPDATE_VERSION) return +export function mountConfigUpdates(ctx: Context, value: unknown): void { + // The entry point already validated this effective configuration. + const raw = value as Record | null | undefined + if (((raw?.configUpdateVersion ?? 0) as number) >= CONFIG_UPDATE_VERSION) return const entry = ctx.fiber?.entry // A row this plugin does not own is none of our business: say nothing (#138's scope guard keeps // the no-op `dsh-ops/background` stub out of the save path). A context with no owning row at all diff --git a/src/config.js b/src/config.ts similarity index 81% rename from src/config.js rename to src/config.ts index db78dc20..bbfab175 100644 --- a/src/config.js +++ b/src/config.ts @@ -38,7 +38,7 @@ export { ConfigError } from './config-error.js' * @param {string[]} known - accepted keys. * @returns {void} */ -function rejectUnknownKeys(raw, known) { +function rejectUnknownKeys(raw: Record, known: readonly string[]): void { const unknown = Object.keys(raw).filter((key) => !known.includes(key)) if (unknown.length > 0) { throw new ConfigError( @@ -54,7 +54,7 @@ function rejectUnknownKeys(raw, known) { * @param {string} key - the key to read. * @returns {string|undefined} the value, or undefined when absent. */ -function optionalString(raw, key) { +function optionalString(raw: Record, key: string): string | undefined { const value = raw[key] if (value === undefined || value === null) return undefined if (typeof value !== 'string' || value.trim() === '') { @@ -70,7 +70,7 @@ function optionalString(raw, key) { * @param {boolean} fallback - the value when the key is absent. * @returns {boolean} the value. */ -function optionalBoolean(raw, key, fallback) { +function optionalBoolean(raw: Record, key: string, fallback: boolean): boolean { const value = raw[key] if (value === undefined || value === null) return fallback if (typeof value !== 'boolean') { @@ -86,7 +86,7 @@ function optionalBoolean(raw, key, fallback) { * @param {string[]} fallback - the value when the key is absent. * @returns {string[]} the value. */ -function optionalStringList(raw, key, fallback) { +function optionalStringList(raw: Record, key: string, fallback: string[]): string[] { const value = raw[key] if (value === undefined || value === null) return fallback if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string' || entry.trim() === '')) { @@ -95,34 +95,32 @@ function optionalStringList(raw, key, fallback) { return [...value] } -/** - * The resolved configuration, with every default applied. - * @typedef {object} ResolvedConfig - * @property {number} configUpdateVersion - per-row completed configuration migration version. - * @property {string|undefined} binaryPath - explicit native worker executable. - * @property {boolean} required - fail activation when the native worker is unavailable. - * @property {boolean} denyCredentialReads - refuse reads of credential locations. - * @property {'advise'|'deny-host-shell'} shellPolicy - treatment of host shell tools. - * @property {string[]} deniedHostTools - tool names refused by `deny-host-shell`. - * @property {boolean} promptPolicy - inject the repository-tooling prompt section. - * @property {string} extraGuidance - extra text appended to that section. - * @property {string|undefined} bashPath - explicit executable for the plugin's bash layer. - * @property {boolean} publishBashTool - publish ops_bash in full-access sessions with a usable bash and subprocess service. - * @property {boolean} allowSystemShellFallback - let the bash rung use a - * system-installed bash when the plugin carries no copy of its own. - */ +/** The resolved configuration, with every default applied. */ +export interface ResolvedConfig { + configUpdateVersion: number + binaryPath: string | undefined + required: boolean + denyCredentialReads: boolean + shellPolicy: 'advise' | 'deny-host-shell' + deniedHostTools: string[] + promptPolicy: boolean + extraGuidance: string + bashPath: string | undefined + publishBashTool: boolean + allowSystemShellFallback: boolean +} /** * Validate one plugin row's config. * @param {unknown} rawConfig - the `config` value from the loader row. * @returns {ResolvedConfig} the resolved configuration. */ -export function resolveConfig(rawConfig) { +export function resolveConfig(rawConfig?: unknown): ResolvedConfig { if (rawConfig === undefined || rawConfig === null) rawConfig = {} if (typeof rawConfig !== 'object' || Array.isArray(rawConfig)) { throw new ConfigError(`config must be an object, got ${JSON.stringify(rawConfig)}`) } - const raw = /** @type {Record} */ (rawConfig) + const raw = rawConfig as Record rejectUnknownKeys(raw, [ 'binaryPath', 'serverName', @@ -141,7 +139,7 @@ export function resolveConfig(rawConfig) { ]) const configUpdateVersion = raw.configUpdateVersion ?? 0 - if (!Number.isSafeInteger(configUpdateVersion) || configUpdateVersion < 0) { + if (typeof configUpdateVersion !== 'number' || !Number.isSafeInteger(configUpdateVersion) || configUpdateVersion < 0) { throw new ConfigError('configUpdateVersion must be a non-negative safe integer') } @@ -171,7 +169,7 @@ export function resolveConfig(rawConfig) { configUpdateVersion, binaryPath: optionalString(raw, 'binaryPath'), required: optionalBoolean(raw, 'required', false), - shellPolicy: /** @type {'advise'|'deny-host-shell'} */ (shellPolicy), + shellPolicy: shellPolicy as ResolvedConfig['shellPolicy'], deniedHostTools: optionalStringList(raw, 'deniedHostTools', DEFAULT_DENIED_HOST_TOOLS), promptPolicy: optionalBoolean(raw, 'promptPolicy', true), extraGuidance: optionalString(raw, 'extraGuidance') ?? '', diff --git a/test/types/config.ts b/test/types/config.ts new file mode 100644 index 00000000..a58558a9 --- /dev/null +++ b/test/types/config.ts @@ -0,0 +1,30 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { ResolvedConfig } from '../../src/config.js' +import type { TuiPanels } from '../../src/components/tui/index.js' +import { resolveConfig } from '../../src/config.js' +import { updatedConfig, mountConfigUpdates } from '../../src/config-updates.js' +import { apply as applyTui } from '../../src/components/tui/index.js' +import { apply as applyBackground } from '../../src/components/background/index.js' + +declare const raw: unknown, ctx: Context, panels: TuiPanels +const config: ResolvedConfig = resolveConfig(raw) +const required: boolean = config.required +const executable: string | undefined = config.bashPath +const policy: 'advise' | 'deny-host-shell' = config.shellPolicy +const migrated: Record = updatedConfig(raw) +mountConfigUpdates(ctx, raw) +applyTui(ctx, raw) +applyBackground(ctx, raw) +// @ts-expect-error Validated booleans cannot be confused with string values. +const invalid: string = config.denyCredentialReads +// @ts-expect-error A shell policy must remain one of the validated alternatives. +config.shellPolicy = 'allow' +// @ts-expect-error A raw migration does not validate unknown saved expressions. +const unvalidated: boolean = migrated.allowSystemShellFallback +// @ts-expect-error Evaluated fallback control must be a boolean. +updatedConfig({}, 'false') +// @ts-expect-error Component mounting requires the host context contract. +applyTui({}, raw) +// @ts-expect-error Panel badges cannot carry an undeclared alert level. +panels.badge('overview', { level: 'critical', unread: 1 }) +void [required, executable, policy, invalid, unvalidated] diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json index c00b15f3..9301ec8b 100644 --- a/test/types/tsconfig.json +++ b/test/types/tsconfig.json @@ -12,6 +12,7 @@ "authority.ts", "profile-temp.ts", "host-entry.ts", - "panel-collector.ts" + "panel-collector.ts", + "config.ts" ] } From fb5ff30aca5b1760e17c8cb29abc725a3561e7c2 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:50:15 +0800 Subject: [PATCH 091/118] build(types): require strict TypeScript plugin sources --- AGENTS.md | 2 +- docs/CHANGELOG.md | 3 ++ docs/CONTRIBUTING.md | 14 ++++----- ...41\347\214\256\346\214\207\345\215\227.md" | 4 +-- scripts/build.mjs | 13 +++++++++ test/build.test.mjs | 29 +++++++++++++++---- tsconfig.json | 2 -- 7 files changed, 49 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b2e1475f..a979b20b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ capabilities. | Path | Contents | | --- | --- | -| `src/` | Plugin source (TypeScript and not-yet-migrated JavaScript). Compiled by `tsc` to `lib/` | +| `src/` | Plugin source (strict TypeScript and TSX). Compiled by `tsc` to `lib/` | | `lib/` | Build output that is shipped and imported by tests — gitignored, never edited by hand | | `src/components//` | One directory per DSH component row (`shell`, `file`, `ptc`, `panel`, `tui`, `hide-official`, …): entry, `package.json`, `locale/`, `icon.png` | | `native/` | Rust worker (`dsh-ops-worker`); one JSON request on stdin, one JSON result on stdout, spawned per call through the host subprocess/sandbox | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index dd056499..28dc0679 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,9 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Complete the strict TypeScript source migration and disable JavaScript compilation. Reject unimported JavaScript source during packaging while preserving published `.js` paths. +- 完成严格 TypeScript 源码迁移并关闭 JavaScript 编译;打包时拒绝未导入的 JavaScript 源码,保持发布的 `.js` 路径不变。 + - Type central configuration validation, deferred profile migrations, and the remaining TUI/background entries strictly; retain their emitted paths, defaults, validation errors and lifecycle behavior. - 将中央配置校验、延迟 profile 迁移及剩余 TUI/background 入口迁为严格 TypeScript;保留产物路径、默认值、校验错误及生命周期行为。 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 74c11dce..7b2ee752 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -57,16 +57,16 @@ npm run verify ## Building the plugin and installing from source -Edit the plugin in `src/`. The TypeScript compiler accepts the existing JavaScript during the -migration and emits JavaScript into `lib/`; `npm run build` recreates that directory and copies -component manifests, locale files, icons and the panel HTML. `lib/` is ignored by Git and must not -be edited by hand. Package exports, component entries and tests continue to load `lib/`. +Edit the plugin in `src/` using strict TypeScript or TSX. `npm run build` emits JavaScript into +`lib/` and copies component manifests, locale files, icons and the panel HTML. `lib/` is ignored by +Git and must not be edited by hand. Package exports, component entries and tests load `lib/`; +runtime imports retain their `.js` paths. Run `npm run build` after changing source or assets and before invoking a suite directly with `node test/run.mjs `. `npm test`, `npm run test:ptc` and `npm run verify` build first; -`npm run typecheck` checks the compiler configuration and migrated TypeScript without emitting -files. JavaScript type checking remains disabled during the migration. Host execution boundaries, central configuration validation and migration, and component entry modules -use strict TypeScript. The compiler accepts remaining JavaScript until the final migration audit. +`npm run typecheck` checks all plugin source without emitting files. JavaScript source under `src/` +is rejected by the build, including unimported files that the TypeScript compiler would otherwise +silently ignore. Repository scripts and tests may continue to use JavaScript. For a local or Git-source installation, build and pack a checkout before installing it into DSH: diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 8aec2679..5153afa4 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -47,9 +47,9 @@ npm run verify ## 构建插件与源码安装 -插件源码在 `src/` 中修改。迁移期间 TypeScript 编译器兼容现有 JavaScript,并将 JavaScript 产物输出到 `lib/`;`npm run build` 会重建该目录,并复制组件 manifest、语言文件、图标和面板 HTML。`lib/` 已被 Git 忽略,不得手动修改。包导出、组件入口和测试仍然加载 `lib/`。 +插件源码在 `src/` 中使用严格 TypeScript 或 TSX 编写。`npm run build` 将 JavaScript 产物输出到 `lib/`,并复制组件 manifest、语言文件、图标和面板 HTML。`lib/` 已被 Git 忽略,不得手动修改。包导出、组件入口和测试加载 `lib/`;运行时导入保留 `.js` 路径。 -修改源码或资源后,先运行 `npm run build`,再通过 `node test/run.mjs <过滤词>` 直接运行套件。`npm test`、`npm run test:ptc` 与 `npm run verify` 会先构建;`npm run typecheck` 检查编译器配置和已迁移的 TypeScript,不生成产物。迁移期间暂不启用 JavaScript 类型检查。宿主执行边界、中央配置校验与迁移及组件入口模块已使用严格 TypeScript。最终迁移审计完成前,编译器仍兼容剩余 JavaScript。 +修改源码或资源后,先运行 `npm run build`,再通过 `node test/run.mjs <过滤词>` 直接运行套件。`npm test`、`npm run test:ptc` 与 `npm run verify` 会先构建;`npm run typecheck` 检查全部插件源码,不生成产物。构建拒绝 `src/` 中的 JavaScript 文件,包括 TypeScript 编译器原本会静默忽略的未导入文件。仓库脚本与测试仍可使用 JavaScript。 从本地目录或 Git 源码安装时,先在检出的仓库中构建并打包,再安装到 DSH: diff --git a/scripts/build.mjs b/scripts/build.mjs index fdc282ff..7bad995f 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -13,6 +13,16 @@ const output = path.join(root, 'lib') const compiler = path.join(root, 'node_modules/typescript/lib/tsc.js') const codeFile = /\.(?:[cm]?[jt]s|[jt]sx)$/i +function requireTypedSource(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const from = path.join(directory, entry.name) + if (entry.isDirectory()) requireTypedSource(from) + else if (entry.isFile() && /\.(?:[cm]?js|jsx)$/i.test(entry.name)) { + throw new Error(`JavaScript source is not supported: ${path.relative(root, from)}; migrate it to TypeScript.`) + } + } +} + function copyAssets(directory) { let count = 0 for (const entry of readdirSync(directory, { withFileTypes: true })) { @@ -35,6 +45,9 @@ try { // Both paths are fixed children of this checkout. Remove only generated output so deleted // source modules cannot remain importable after a successful rebuild. rmSync(output, { recursive: true, force: true }) + // tsc silently ignores unimported JavaScript when allowJs is disabled. Refuse it explicitly + // so a missed source migration cannot produce an apparently successful incomplete package. + requireTypedSource(source) const result = spawnSync(process.execPath, [compiler, '-p', path.join(root, 'tsconfig.json')], { cwd: root, stdio: 'inherit', windowsHide: true, }) diff --git a/test/build.test.mjs b/test/build.test.mjs index 8e357892..cd18984e 100644 --- a/test/build.test.mjs +++ b/test/build.test.mjs @@ -37,11 +37,11 @@ function build(root) { return { status: result.status, output: `${result.stdout}${result.stderr}` } } -await test('build emits runnable JavaScript and TypeScript at their existing package paths', () => +await test('build emits TypeScript as runnable JavaScript at the existing package paths', () => withTempDir('source-build-modules', async root => { fixture(root) write(root, 'src/index.ts', "import { prefix } from './policy.js'\nexport const answer: string = `${prefix}:42`\n") - write(root, 'src/policy.js', "export const prefix = 'ops'\n") + write(root, 'src/policy.ts', "export const prefix = 'ops'\n") write(root, 'src/nested/module.mts', 'export const count: number = 3\n') write(root, 'src/nested/module.cts', 'export const count: number = 4\n') write(root, 'src/types.d.ts', 'export interface Config { enabled: boolean }\n') @@ -58,8 +58,8 @@ await test('build emits runnable JavaScript and TypeScript at their existing pac await test('rebuild removes retired modules and assets while preserving every asset byte', () => withTempDir('source-build-assets', root => { fixture(root) - write(root, 'src/index.js', 'export const enabled = true\n') - write(root, 'src/retired.js', 'export default false\n') + write(root, 'src/index.ts', 'export const enabled = true\n') + write(root, 'src/retired.ts', 'export default false\n') write(root, 'src/panel/retired.html', '

retired

\n') const assets = { 'components/panel/package.json': Buffer.from('{"type":"module"}\n'), @@ -73,7 +73,7 @@ await test('rebuild removes retired modules and assets while preserving every as assert.equal(initial.status, 0, initial.output) assert.ok(fs.existsSync(path.join(root, 'lib/retired.js'))) assert.ok(fs.existsSync(path.join(root, 'lib/panel/retired.html'))) - fs.unlinkSync(path.join(root, 'src/retired.js')) + fs.unlinkSync(path.join(root, 'src/retired.ts')) fs.unlinkSync(path.join(root, 'src/panel/retired.html')) write(root, 'lib/obsolete/nested.txt', 'old build output') const rebuilt = build(root) @@ -84,7 +84,7 @@ await test('rebuild removes retired modules and assets while preserving every as for (const [name, bytes] of Object.entries(assets)) { assert.deepEqual(fs.readFileSync(path.join(root, 'lib', name)), bytes, `asset changed: ${name}`) } - assert.equal(fs.readFileSync(path.join(root, 'src/index.js'), 'utf8'), 'export const enabled = true\n') + assert.equal(fs.readFileSync(path.join(root, 'src/index.ts'), 'utf8'), 'export const enabled = true\n') })) await test('compiler errors fail the build with diagnostics and no stale or partially copied package', () => @@ -100,4 +100,21 @@ await test('compiler errors fail the build with diagnostics and no stale or part assert.equal(fs.existsSync(path.join(root, 'lib/panel/panel.html')), false) })) +await test('unimported JavaScript source fails packaging instead of silently disappearing', () => + withTempDir('source-build-unchecked', root => { + fixture(root) + write(root, 'src/index.ts', 'export const enabled = true\n') + for (const extension of ['js', 'mjs', 'cjs', 'jsx']) { + const unsupported = `src/nested/unimported.${extension}` + write(root, unsupported, 'export const unchecked = true\n') + write(root, 'lib/index.js', 'export const stale = true\n') + const result = build(root) + assert.notEqual(result.status, 0, result.output) + assert.match(result.output, /JavaScript source is not supported/) + assert.ok(result.output.includes(path.normalize(unsupported)), result.output) + assert.equal(fs.existsSync(path.join(root, 'lib/index.js')), false) + fs.unlinkSync(path.join(root, unsupported)) + } + })) + report('build') diff --git a/tsconfig.json b/tsconfig.json index 60501e39..95244604 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,8 +12,6 @@ "types": [ "node" ], - "allowJs": true, - "checkJs": false, "strict": true, "skipLibCheck": true, "resolveJsonModule": true, From 7d2d04f86f977d6458d704a7c893084d5bb4baf0 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:45:21 +0800 Subject: [PATCH 092/118] perf(native): compact fully returned file ranges --- docs/CHANGELOG.md | 4 ++ docs/native-ranges.md | 19 ++++++ ...07\344\273\266\350\214\203\345\233\264.md" | 19 ++++++ src/modes/native/ranges.ts | 52 +++++++++++++++ src/modes/native/read-observation.ts | 5 +- src/modes/native/render.ts | 3 +- test/context-read-many.test.mjs | 8 +-- test/lib/context-read.mjs | 4 +- test/native-ranges.test.mjs | 65 +++++++++++++++++++ 9 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 docs/native-ranges.md create mode 100644 "docs/\347\264\247\345\207\221\346\226\207\344\273\266\350\214\203\345\233\264.md" create mode 100644 src/modes/native/ranges.ts create mode 100644 test/native-ranges.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 28dc0679..f418c989 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -196,6 +196,10 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - 新增有界 CSV/TSV worker 读取,严格解码、保留原字符串与物理行范围;错误保留此前有效行, 并分别报告扫描、分页和引用捕获完整性,不新增外部依赖。通过发现/SDK 按代理在下一步骤启用;解析缺口保留前缀且不伪造续取。 +### Compact fully returned Native file ranges (#261, BM-01) + +- Use exact numbered multi-file bodies, quoted shared parent paths and one full-revision/continuation receipt per file. Preserve canonical metadata and diagnostic output for clipped, failed or uncertain ranges; update current-context repeat suppression for compact line labels. +- 多文件范围以紧凑行号、带引号的共享父目录及完整版本/续读提示展示;规范数据不变,截断、失败及不确定覆盖保留诊断信息,当前上下文去重同步支持新行号格式。 ### Deduplicate Native reads with current-context evidence (#261, 10/16) diff --git a/docs/native-ranges.md b/docs/native-ranges.md new file mode 100644 index 00000000..de0c483e --- /dev/null +++ b/docs/native-ranges.md @@ -0,0 +1,19 @@ +# Compact Native file ranges + +中文:[紧凑文件范围](紧凑文件范围.md). + +A fully returned `ops_read_many` range uses one path header, exact numbered text and one revision/continuation notice per file. When all files have exactly the same absolute parent, a JSON-quoted `Base` appears once and each JSON-quoted filename is relative to it. Different parents retain absolute paths. + +```text +[Base: "/workspace/src"] +=== FILE: "server.ts" (Lines 1-2 of 80) === +1|import { createServer } from 'http'; +2|const port = 8080; +[Notice: revision=cfd1bc81432fd6b88748b827f46bb4242db17cf8655cf4ea90f3ba0e62065355; more lines; nextOffset=3.] +``` + +The characters after `N|` are the source line, including leading spaces. Control characters and Unicode line separators are escaped so source text cannot forge extra numbered rows or file headers. LF/CRLF accounting remains the worker's physical-line accounting. + +“More lines” means the file has not been exhausted; use the stated `nextOffset`. “End of file; complete=true” means this range reached the end. The aggregate “requested ranges returned” receipt means every requested range body was returned, not that every whole file was read. Full revisions, total line counts and per-file continuation remain visible. Byte sizes and duplicate counters remain available in canonical/PTC data. + +Physical clipping, missing files, unexpected coverage, stored references and other uncertain results retain the diagnostic renderer and explicit recovery notices. Final display clipping removes the successful aggregate claim and asks for the original range with a smaller limit. Deduplication recognizes both `N|` and the existing single-file `N | ` format; only an exact prior body in current provider context permits an unchanged-reference receipt. diff --git "a/docs/\347\264\247\345\207\221\346\226\207\344\273\266\350\214\203\345\233\264.md" "b/docs/\347\264\247\345\207\221\346\226\207\344\273\266\350\214\203\345\233\264.md" new file mode 100644 index 00000000..a85c2086 --- /dev/null +++ "b/docs/\347\264\247\345\207\221\346\226\207\344\273\266\350\214\203\345\233\264.md" @@ -0,0 +1,19 @@ +# 紧凑文件范围 + +English: [Compact Native file ranges](native-ranges.md). + +`ops_read_many` 完整返回所请求的范围时,每个文件仅显示一个路径标题、精确的行号正文,以及一条版本/续读提示。只有所有文件具有完全相同的绝对父目录时,才共享一条 JSON 引号包围的 `Base`;各文件名同样使用 JSON 引号,并相对于该目录解析。父目录不同则保留各自的绝对路径。 + +```text +[Base: "/workspace/src"] +=== FILE: "server.ts" (Lines 1-2 of 80) === +1|import { createServer } from 'http'; +2|const port = 8080; +[Notice: revision=cfd1bc81432fd6b88748b827f46bb4242db17cf8655cf4ea90f3ba0e62065355; more lines; nextOffset=3.] +``` + +`N|` 后紧跟原始行内容,包含原有缩进。控制字符和 Unicode 换行分隔符会被转义,防止源文本伪造额外行号或文件标题;LF/CRLF 物理行规则与 worker 保持一致。 + +`more lines` 表示文件尚未读到末尾,按 `nextOffset` 继续;`end of file; complete=true` 表示本范围已经到达文件末尾。汇总的 `requested ranges returned` 仅证明所请求的范围正文全部返回,不代表读取了所有完整文件。完整版本散列、总行数和每个文件的续读位置继续保留;字节大小及重复计数仍在规范数据和 PTC 返回值中。 + +发生物理截断、缺失文件、覆盖事实异常、引用存储或其他不确定情况时,继续使用诊断输出并保留明确恢复提示。最终显示预算截断不会保留成功汇总,而会要求用更小上限重新读取原范围。上下文去重同时识别 `N|` 与现有单文件 `N | ` 格式;只有当前供应商上下文中存在完全相同的旧正文证据,才能返回未变化引用。 diff --git a/src/modes/native/ranges.ts b/src/modes/native/ranges.ts new file mode 100644 index 00000000..b842e70b --- /dev/null +++ b/src/modes/native/ranges.ts @@ -0,0 +1,52 @@ +import path from 'node:path' +type Row = Record +const record = (value: unknown): value is Row => value !== null && typeof value === 'object' && !Array.isArray(value) +const integer = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +const quoted = (value: string): string => JSON.stringify(value).replace(/[\u0085\u2028\u2029]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`) +const content = (value: string): string => value.replace(/[\r\n\t\x00-\x1f\x7f\u0085\u2028\u2029]/g, + char => /[\u0085\u2028\u2029]/.test(char) ? `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}` : JSON.stringify(char).slice(1, -1)) +interface Range { path: string; revision: string; total: number; next: number; complete: boolean; lines: { line: number; text: string }[] } +/** Compact only a fully evidenced requested range. Physical gaps keep the general diagnostic renderer. */ +function range(input: unknown, value: unknown): Range | undefined { + if (!record(input) || !record(value) || value.status !== 'completed' || value.error || value.reference || value.storageError + || value.truncated !== false || value.truncatedBy !== undefined || value.nextAction !== undefined + || value.metadataWarnings !== undefined || value.failed !== 0 || !integer(value.lineCount) + || !integer(value.nextOffset) || typeof value.path !== 'string' || !path.isAbsolute(value.path) + || typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision) || !Array.isArray(value.items)) return + const offset = input.offset ?? 1, limit = input.limit ?? 200 + if (!integer(offset) || offset < 1 || !integer(limit) || limit < 1) return + const expected = Math.max(0, Math.min(limit, value.lineCount - offset + 1)) + if (value.items.length !== expected || value.nextOffset !== offset + expected + || value.complete !== (offset - 1 + expected >= value.lineCount)) return + const lines: Range['lines'] = [] + for (const [index, item] of value.items.entries()) { + if (!record(item) || item.line !== offset + index || item.path !== value.path || typeof item.text !== 'string' + || item.view === 'hex' || item.truncatedInLine === true || item.metadataWarnings !== undefined) return + lines.push({ line: offset + index, text: item.text }) + } + return { path: value.path, revision: value.revision, total: value.lineCount, next: value.nextOffset, complete: value.complete, lines } +} +/** Canonical metadata remains unchanged; Native needs one revision and one clear continuation per file. */ +export function compactRanges(input: Row, value: Row): string[] | undefined { + if (!Array.isArray(input.files) || !Array.isArray(value.items) || !input.files.length || input.files.length !== value.items.length + || value.reference || value.storageError || value.truncated !== false || value.failed !== 0 || value.pending !== 0 + || value.total !== input.files.length || value.nextIndex !== input.files.length) return + const observed: Range[] = [] + for (const [index, item] of value.items.entries()) { + const current = range(input.files[index], item) + if (!current) return + observed.push(current) + } + const parent = path.dirname(observed[0]!.path) + const base = observed.length > 1 && observed.every(item => path.dirname(item.path) === parent) ? parent : undefined + const output = base ? [`[Base: ${quoted(base)}]`] : [] + for (const item of observed) { + const span = item.lines.length ? `${item.lines[0]!.line}-${item.lines.at(-1)!.line}` : 'none' + output.push(`=== FILE: ${quoted(base ? path.basename(item.path) : item.path)} (Lines ${span} of ${item.total}) ===`) + for (const line of item.lines) output.push(`${line.line}|${content(line.text)}`) + if (!item.lines.length) output.push(item.total === 0 ? '(empty file)' : '(no lines in this range)') + output.push(`[Notice: revision=${item.revision}; ${item.complete ? 'end of file; complete=true' : `more lines; nextOffset=${item.next}`}.]`) + } + output.push(`[Notice: ${observed.length}/${input.files.length} requested ranges returned.]`) + return output +} diff --git a/src/modes/native/read-observation.ts b/src/modes/native/read-observation.ts index 995cbbe8..93664f78 100644 --- a/src/modes/native/read-observation.ts +++ b/src/modes/native/read-observation.ts @@ -58,8 +58,9 @@ export function observeRead(tool: ToolDefinition, exec: Readonly, } } export function unchangedContent(body: string, callId: string, messageId: string): string | undefined { - const kept = body.split('\n').filter(line => !/^\d+ \| /.test(line)) - .map(line => line.replace(/^(\[Notice: \d+ items?) displayed;/, '$1 revalidated;')) + const kept = body.split('\n').filter(line => !/^\d+(?: \| |\|)/.test(line)) + .map(line => line.replace(/^(\[Notice: \d+ items?) displayed;/, '$1 revalidated;') + .replace(/requested ranges returned\./, 'requested ranges revalidated.')) const notice = `[Notice: Unchanged file range; body already present in the current model context in tool call ${JSON.stringify(callId)} / message ${JSON.stringify(messageId)}. Fresh read revalidated the revision above.]` const compact = [...kept, notice].join('\n') return Buffer.byteLength(compact, 'utf8') < Buffer.byteLength(body, 'utf8') ? compact : undefined diff --git a/src/modes/native/render.ts b/src/modes/native/render.ts index 20ec692e..f2d6ce3a 100644 --- a/src/modes/native/render.ts +++ b/src/modes/native/render.ts @@ -1,6 +1,7 @@ import { Buffer } from 'node:buffer' import { renderDocument } from './documents.js' import { isDocumentTool } from '../../capabilities/documents.js' +import { compactRanges } from './ranges.js' import { renderResult } from './result.js' import { errorPolicy } from '../../kernel/errors.js' import type { EffectState } from '../../kernel/outcome.js' @@ -166,7 +167,7 @@ export function renderNative(tool: string, args: unknown, result: unknown): stri if (isDocumentTool(tool)) lines = renderDocument(tool, input, result) else if (record(result.error) || ['error', 'rejected', 'conflict', 'unknown', 'failed'].includes(String(result.status))) lines = failure(result) else if (tool === 'result') return capNativeText(renderResult(input, result), NATIVE_OUTPUT_BYTES, 'Repeat the same offset with smaller limit/maxBytes; do not advance past undisplayed rows.') - else if (tool === 'read_many') lines = readMany(result) + else if (tool === 'read_many') lines = compactRanges(input, result) ?? readMany(result) else if (tool === 'inspect_local_file') lines = inspect(input, result) else if (mutations.has(tool)) lines = receipts(tool, result) else if (tool === 'stat') lines = stat(input, result) diff --git a/test/context-read-many.test.mjs b/test/context-read-many.test.mjs index 2f1b7b67..4abfe69d 100644 --- a/test/context-read-many.test.mjs +++ b/test/context-read-many.test.mjs @@ -29,8 +29,8 @@ await test('multi-file repeats require every fresh file and the exact prior batc assert.equal((text(repeat).match(/=== FILE:/g) ?? []).length, 2) assert.match(text(repeat), /nextOffset=11/) assert.match(text(repeat), /nextOffset=15/) - assert.match(text(repeat), /nextIndex=2; pending=0/) - assert.match(text(repeat), /complete=false/) + assert.match(text(repeat), /2\/2 requested ranges revalidated/) + assert.match(text(repeat), /more lines/); assert.equal(repeat.value.complete, false) })) await test('a changed sibling, its metadata or its identity restores the entire batch body', () => fixture(async context => { @@ -48,7 +48,7 @@ await test('a changed sibling, its metadata or its identity restores the entire fs.writeFileSync(second, original.toString().replace('payload line 3', 'changed line 3')) const changed = await batch() full(changed) - assert.match(text(changed), /^3 \| changed/m) + assert.match(text(changed), /^3\|changed/m) })) await test('single-file proof, reordered ranges and compaction cannot stand in for a batch', () => fixture(async context => { @@ -59,7 +59,7 @@ await test('single-file proof, reordered ranges and compaction cannot stand in f const first = await batch() await request([first.assistant, first.message]) const reordered = await batch({ args: { files: [...args.files].reverse() } }) - assert.match(text(reordered), /^3 \| payload/m) + assert.match(text(reordered), /^3\|payload/m) assert.doesNotMatch(text(reordered), /Unchanged file range/) compact() await request([first.assistant, first.message]) diff --git a/test/lib/context-read.mjs b/test/lib/context-read.mjs index 5724e0fb..3c83aa9f 100644 --- a/test/lib/context-read.mjs +++ b/test/lib/context-read.mjs @@ -13,10 +13,10 @@ import { bootHost, mountPlugin, shutdown } from './host.mjs' export const READ = publicToolName('inspect_local_file') export const text = result => result.content.map(block => block.type === 'text' ? block.text : '').join('\n') -export const full = result => assert.match(text(result), /^1 \| payload/m) +export const full = result => assert.match(text(result), /^1(?: \| |\|)payload/m) export const elided = result => { assert.match(text(result), /Unchanged file range; body already present in the current model context/) - assert.doesNotMatch(text(result), /^\d+ \| /m) + assert.doesNotMatch(text(result), /^\d+(?: \| |\|)/m) assert.match(text(result), /=== FILE: .*Lines \d+-\d+ of \d+/) assert.match(text(result), /revision=[a-f0-9]{64}/) } diff --git a/test/native-ranges.test.mjs b/test/native-ranges.test.mjs new file mode 100644 index 00000000..0b955381 --- /dev/null +++ b/test/native-ranges.test.mjs @@ -0,0 +1,65 @@ +import fs from 'node:fs' +import path from 'node:path' +import { assert, test, report, withTempDir } from './lib/harness.mjs' +import { callTool, envelopeOf, requireWorker } from './lib/boundary.mjs' +import { renderNative, NATIVE_OUTPUT_BYTES } from '../lib/modes/native/render.js' +import { compactRanges } from '../lib/modes/native/ranges.js' +requireWorker('native-ranges') +const render = (args, value) => renderNative('read_many', args, value) +const observed = (root, args) => callTool('read_many', args, { root, readOnly: true }).then(reply => envelopeOf(reply, assert)) +await test('real bounded ranges keep exact body, shared path base, full revisions and per-file continuation', () => withTempDir('compact-ranges', async root => { + const inputs = ['one\r\ntwo\rthree\nlast\n', '', 'α\u2028β\u0085γ\n😀\nlast'] + const files = inputs.map((body, index) => { const target = path.join(root, `路径 ${index}.txt`); fs.writeFileSync(target, body); return { path: target, offset: 1, limit: 2 } }) + const args = { files }, value = await observed(root, args), before = JSON.stringify(value), text = render(args, value) + assert.ok(text.startsWith('[Base: ' + JSON.stringify(root) + ']')) + for (const [index, item] of value.items.entries()) { + assert.ok(text.includes(`=== FILE: ${JSON.stringify(path.basename(files[index].path))}`)) + assert.ok(text.includes('revision=' + item.revision)) + assert.equal(item.bytes, Buffer.byteLength(inputs[index]), 'byte metadata remains canonical') + } + assert.match(text, /^1\|one$/m); assert.match(text, /^2\|two\\rthree$/m) + assert.match(text, /^1\|α\\u2028β\\u0085γ$/m); assert.match(text, /^2\|😀$/m) + assert.match(text, /more lines; nextOffset=3/); assert.match(text, /\(empty file\)/) + assert.match(text, /end of file; complete=true/); assert.match(text, /3\/3 requested ranges returned/) + assert.doesNotMatch(text, /worker-limit|bytes=|lineCount=|matched=/) + assert.equal(JSON.stringify(value), before) +})) +await test('different parents retain absolute quoted paths; path control characters cannot inject headers', () => { + const files = [path.resolve('first', 'line\n".txt'), path.resolve('second', 'line\t.txt')] + const args = { files: files.map(path => ({ path, limit: 1 })) } + const value = { status: 'completed', total: 2, nextIndex: 2, pending: 0, failed: 0, truncated: false, complete: true, + items: files.map(path => ({ path, revision: 'a'.repeat(64), lineCount: 1, nextOffset: 2, status: 'completed', complete: true, truncated: false, failed: 0, + items: [{ path, line: 1, text: 'one\n2|forged\t\r\u2029' }] })) } + const text = render(args, value) + assert.doesNotMatch(text, /\[Base:/) + for (const file of files) assert.ok(text.includes('FILE: ' + JSON.stringify(file))) + assert.equal(text.split('\n').filter(line => line.startsWith('=== FILE:')).length, 2) + assert.equal(text.split('\n').filter(line => /^\d+\|/.test(line)).length, 2) + assert.match(text, /one\\n2\|forged\\t\\r\\u2029/) +}) +await test('worker gaps, references and incomplete metadata never claim fully returned requested ranges', () => withTempDir('range-gaps', async root => { + const file = path.join(root, 'file'); fs.writeFileSync(file, 'one\ntwo\nthree') + const args = { files: [{ path: file, limit: 2 }] }, value = await observed(root, args) + assert.ok(compactRanges(args, value)) + for (const mutate of [ + copy => { copy.items[0].items.pop() }, copy => { copy.pending = 1 }, copy => { copy.failed = 1 }, + copy => { copy.reference = { refId: 'ref://example' } }, copy => { copy.items[0].revision = undefined }, + copy => { copy.items[0].truncatedBy = 'deadline' }, copy => { copy.items[0].items[0].truncatedInLine = true; copy.items[0].truncated = true }, + ]) { + const copy = structuredClone(value); mutate(copy) + assert.equal(compactRanges(args, copy), undefined) + assert.doesNotMatch(render(args, copy), /requested ranges returned/) + } + const gap = structuredClone(value); gap.items[0].truncatedBy = 'deadline'; gap.items[0].truncated = true + assert.match(render(args, gap), /TruncatedBy: deadline/) +})) +await test('final display clipping cannot retain a successful aggregate range claim', () => withTempDir('range-display-cap', async root => { + const file = path.join(root, 'file'); fs.writeFileSync(file, 'x'.repeat(400) + '\n') + const args = { files: [{ path: file }] }, value = await observed(root, args) + value.items[0].items[0].text = 'x'.repeat(100000) + const text = render(args, value) + assert.match(text, /Display incomplete\. TruncatedBy: output-budget/) + assert.doesNotMatch(text, /requested ranges returned/) + assert.ok(Buffer.byteLength(JSON.stringify([{ type: 'text', text }])) <= NATIVE_OUTPUT_BYTES) +})) +report('native-ranges') From 7dad8fb933aa0a8373bf8e66e22bc26524acbcd8 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:51:27 +0800 Subject: [PATCH 093/118] test(native): assert compact resident reader output --- test/resident-tools.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/resident-tools.test.mjs b/test/resident-tools.test.mjs index dde2428e..7a06fd77 100644 --- a/test/resident-tools.test.mjs +++ b/test/resident-tools.test.mjs @@ -33,7 +33,7 @@ await test('the resident file row publishes one bounded reader even with the dis fs.writeFileSync(path.join(dir, 'second.txt'), 'second file') const read = await callTool(ctx, READ, { files: [{ path: 'read.txt' }, { path: 'second.txt' }] }, undefined, agent) assert.equal(read.value.status, 'completed') - assert.match(read.text, /1 \| bounded reader/); assert.match(read.text, /1 \| second file/) + assert.match(read.text, /1\|bounded reader/); assert.match(read.text, /1\|second file/) const absent = await ctx.get('tools').execute({ agent, name: publicToolName('stat'), arguments: { path: 'read.txt' }, callId: 'absent', signal: new AbortController().signal }) assert.equal(absent.isError, true) } finally { await shutdown(ctx) } From 4765c90bb406f7e185b2ffe57b2192d29d11e085 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:01:00 +0800 Subject: [PATCH 094/118] test(bench): add opt-in real-host acceptance gates --- .github/workflows/benchmark-gates.yml | 52 +++++++++ bench/cli.mjs | 1 + bench/gates/fixtures.mjs | 32 ++++++ bench/gates/host.mjs | 87 +++++++++++++++ bench/gates/index.mjs | 64 +++++++++++ bench/gates/metrics.mjs | 63 +++++++++++ bench/gates/pdf.mjs | 28 +++++ bench/gates/prepare-baseline.mjs | 34 ++++++ bench/gates/provenance.mjs | 25 +++++ bench/gates/report.mjs | 23 ++++ bench/gates/tasks.mjs | 105 ++++++++++++++++++ docs/CHANGELOG.md | 4 + docs/benchmark-gates.md | 40 +++++++ ...72\345\207\206\351\227\250\347\246\201.md" | 40 +++++++ package-lock.json | 39 +++++++ package.json | 1 + test/bench-gates.test.mjs | 102 +++++++++++++++++ 17 files changed, 740 insertions(+) create mode 100644 .github/workflows/benchmark-gates.yml create mode 100644 bench/gates/fixtures.mjs create mode 100644 bench/gates/host.mjs create mode 100644 bench/gates/index.mjs create mode 100644 bench/gates/metrics.mjs create mode 100644 bench/gates/pdf.mjs create mode 100644 bench/gates/prepare-baseline.mjs create mode 100644 bench/gates/provenance.mjs create mode 100644 bench/gates/report.mjs create mode 100644 bench/gates/tasks.mjs create mode 100644 docs/benchmark-gates.md create mode 100644 "docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" create mode 100644 test/bench-gates.test.mjs diff --git a/.github/workflows/benchmark-gates.yml b/.github/workflows/benchmark-gates.yml new file mode 100644 index 00000000..cf7fa379 --- /dev/null +++ b/.github/workflows/benchmark-gates.yml @@ -0,0 +1,52 @@ +name: Opt-in OPS acceptance benchmarks +on: + workflow_dispatch: +permissions: + contents: read +jobs: + benchmark-gates: + strategy: + fail-fast: false + matrix: + include: + - { runner: windows-latest, target: win32-x64, suffix: '.exe' } + - { runner: ubuntu-24.04, target: linux-x64, suffix: '' } + - { runner: macos-latest, target: darwin-arm64, suffix: '' } + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + env: + CARGO_BUILD_JOBS: '3' + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-node@v7 + with: + node-version: '24' + - name: Set up pinned Rust + shell: bash + run: rustup toolchain install 1.97.1 --profile minimal && rustup default 1.97.1 + - name: Install locked dependencies + run: npm ci --ignore-scripts + - name: Build candidate host + run: npm run build + - name: Build candidate worker + run: npm run build:ptc + - name: Materialize exact 0.3.1 source blobs + run: node bench/gates/prepare-baseline.mjs baseline-031 + - name: Build independent 0.3.1 worker + run: cargo build --release --locked --manifest-path baseline-031/native/Cargo.toml + - name: Verify benchmark harness integrity + run: node test/run.mjs bench-gates + - name: Run all five real-host acceptance gates + run: node bench/cli.mjs gates --baseline-source baseline-031 --baseline-worker baseline-031/native/target/release/dsh-ops-worker${{ matrix.suffix }} --output benchmark-gates.json + - name: Upload exact evidence even when a gate fails + if: always() + uses: actions/upload-artifact@v7 + with: + name: benchmark-gates-${{ matrix.target }} + path: | + benchmark-gates.json + benchmark-gates.json.md + if-no-files-found: error + retention-days: 30 diff --git a/bench/cli.mjs b/bench/cli.mjs index 227c2fb8..28d6cc9b 100644 --- a/bench/cli.mjs +++ b/bench/cli.mjs @@ -26,6 +26,7 @@ export const EXIT = { ok: 0, failure: 1, usage: 2, pending: 3 } /** The subcommands, in help order, each with the slice that implements it. */ export const COMMANDS = [ + { name: 'gates', slice: '[16/16]', target: 'bench/gates/index.mjs', summary: 'scripted real-host BM-01 through BM-05 acceptance evidence' }, { name: 'setup', slice: 'P9/P10', target: 'bench/setup.mjs', summary: 'prepare both profiles once, idempotently' }, { name: 'ab', slice: 'P1 (plan) + P7/P8 + P11 (run)', target: 'bench/run.mjs', summary: 'baseline then candidate then compare, one group folder' }, { name: 'report', slice: 'P11', target: 'bench/compare.mjs', summary: 'render report.md from a sealed run.json' }, diff --git a/bench/gates/fixtures.mjs b/bench/gates/fixtures.mjs new file mode 100644 index 00000000..b6a4bbc8 --- /dev/null +++ b/bench/gates/fixtures.mjs @@ -0,0 +1,32 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +export const hash = value => createHash('sha256').update(value).digest('hex') +/** Prespecified sensitivity grid, including the original below-target 8-line fixture. */ +export const READ_CASES = Object.freeze([ + { id: 'typescript-first-50', limit: 50, style: 'typescript', first: true, primary: true }, + { id: 'typescript-8', limit: 8, style: 'typescript' }, + { id: 'typescript-1', limit: 1, style: 'typescript' }, + { id: 'typescript-32', limit: 32, style: 'typescript' }, + { id: 'long-lines-8', limit: 8, style: 'long-lines' }, + { id: 'unicode-json-8', limit: 8, style: 'unicode-json' }, +].map(Object.freeze)) +export function readFixture(root, scenario) { + const files = [13, 31, 51].map((offset, i) => { + const lines = Array.from({ length: 70 + i * 10 }, (_, n) => scenario.style === 'typescript' + ? `export const value${n + 1} = ${1000 + i * 100 + n};` + : scenario.style === 'long-lines' ? `// row ${n + 1}: ${'abcdefghij'.repeat(60)}` + : JSON.stringify({ id: n + 1, name: '路径与引号"', value: 'C:\\work\\file', active: true })) + const text = lines.join('\n'), file = path.join(root, `module-${i}.ts`) + fs.writeFileSync(file, text) + return { path: file, offset: scenario.first ? 1 : offset, limit: scenario.limit, hash: hash(text), expected: lines.slice((scenario.first ? 1 : offset) - 1, (scenario.first ? 1 : offset) + scenario.limit - 1).map((text, j) => ({ line: (scenario.first ? 1 : offset) + j, text })) } + }) + return files +} +export function fiveEdits(root) { + const original = Array.from({ length: 80 }, (_, n) => `const value${n + 1} = ${n + 1};`).join('\n') + const file = path.join(root, 'edit-target.ts'); fs.writeFileSync(file, original) + const edits = [4, 16, 28, 40, 52].map(n => ({ oldText: `const value${n} = ${n};`, newText: `const value${n} = ${n * 10};` })) + const expected = edits.reduce((text, edit) => text.replace(edit.oldText, edit.newText), original) + return { path: file, original, expected, edits, revision: hash(original) } +} diff --git a/bench/gates/host.mjs b/bench/gates/host.mjs new file mode 100644 index 00000000..ea859aae --- /dev/null +++ b/bench/gates/host.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict' +import { pathToFileURL } from 'node:url' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import Agents from '@deepseek-ai/dsh-agent' +import Sessions from '@deepseek-ai/dsh-session' +import Projections from '@deepseek-ai/dsh-session-projection' +import Prompt from '@deepseek-ai/dsh-system-prompt' +import Tools from '@deepseek-ai/dsh-tools' +import Loop from '@deepseek-ai/dsh-agent-loop' +import Jobs from '@deepseek-ai/dsh-jobs-local' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import Policy, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import Sandbox from '@deepseek-ai/dsh-sandbox-local' +import FileSystem from '@deepseek-ai/dsh-fs-local' +import * as OfficialTools from '@deepseek-ai/dsh-tool-fs' +import * as shell from '../../lib/components/shell/index.js' +import * as file from '../../lib/components/file/index.js' +import * as discovery from '../../lib/components/discovery/index.js' +let identity = 0 +const textOf = content => content.filter(block => block.type === 'text').map(block => block.text).join('\n') + +/** Real AgentLoop, Session, tool dispatch and policy; only the model response is scripted. */ +export async function boot({ root, worker, baselineSource, projection = 'candidate', alwaysLoaded = [], withShell = false }) { + const ctx = new Context(); ctx.logger.level = 0 + const queues = new Map(), requests = [], results = [], events = [], errors = [] + for (const plugin of [Agents, Sessions, Projections, Prompt]) await ctx.plugin(plugin, {}) + await ctx.plugin(Tools, { mode: 'native' }) + await ctx.plugin(Policy, { mode: 'workspace-write', workspaceRoot: root }) + if (withShell) await ctx.plugin(Jobs, {}) + await ctx.plugin(Subprocess); await ctx.plugin(Sandbox); await ctx.plugin(FileSystem, { cwd: root }) + await ctx.plugin(inner => { + inner.provide('llm', { async prepareCall(config) { return { config, adapterDefaults: {}, stream: async function* (request) { + assert.equal(request.purpose, undefined, 'auxiliary requests cannot spend a scripted ordinary round') + const queue = queues.get(request.sessionId) + assert.ok(queue?.length, 'unscripted provider request is forbidden') + const script = queue.shift() + const calls = typeof script === 'function' ? await script(request) : script + requests.push({ sessionId: request.sessionId, tools: structuredClone(request.tools ?? []), messages: structuredClone(request.messages), toolCalls: calls.length }) + for (const [index, call] of calls.entries()) yield { type: 'tool-call-delta', index, id: call.id ?? 'bm-call-' + ++identity, name: call.name, argumentsDelta: JSON.stringify(call.args) } + if (!calls.length) yield { type: 'text-delta', index: 0, text: 'Benchmark complete.' } + yield { type: 'finish', reason: { kind: 'stop' } } + } } } }) + inner.on('tools/execute', async (exec, next) => { + const result = await next() + results.push({ sessionId: exec.agent?.session.id, callId: exec.callId, name: exec.name, arguments: exec.arguments, result: structuredClone(result) }) + return result + }) + inner.on('agent/error', ({ error }) => errors.push(String(error?.stack ?? error))) + inner.on('session/event', (session, event) => { + if (event.type === 'tool/result') events.push({ sessionId: session.id, event: structuredClone(event), text: textOf(event.data.message.content) }) + }) + }) + if (projection === 'official') await ctx.plugin(OfficialTools, {}) + else if (projection === 'baseline') { + const { NativeFileTools } = await import(pathToFileURL(join(baselineSource, 'lib/file/runtime.js')).href) + await ctx.plugin({ inject: ['tools'], async apply(inner) { + const runtime = new NativeFileTools({ ctx: inner, config: { workerPath: worker, required: true, denyCredentialReads: true } }) + await runtime.start(); inner.effect(() => () => runtime.stop()) + } }) + } else { + await ctx.plugin(file, { binaryPath: worker, required: true }) + if (withShell) await ctx.plugin(shell, {}) + await ctx.plugin(discovery, { binaryPath: worker, required: true, alwaysLoaded }) + } + await ctx.plugin(Loop, { agents: [], maxParallelToolCalls: 3 }) + async function agent(mode = 'workspace-write', parentAgent) { + const { agent } = await ctx.get('agents').create({ sessionId: 'benchmark-' + ++identity, meta: { cwd: root }, parentAgent, + agentOptions: { provider: 'fixture', model: 'scripted' } }) + setSandboxMode(agent.session, mode) + return agent + } + async function run(agent, scripts) { + assert.ok(scripts.length && scripts.at(-1).length === 0, 'scripts must end with a final response') + const start = { requests: requests.length, results: results.length, events: events.length, errors: errors.length } + queues.set(agent.session.id, [...scripts]) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Execute the deterministic benchmark task.' }] })) + await agent.whenIdle() + assert.deepEqual(errors.slice(start.errors), []) + assert.equal(queues.get(agent.session.id).length, 0, 'every scripted provider response must be consumed') + const calls = requests.slice(start.requests), committed = events.slice(start.events) + return { providerRequests: calls.length, toolBearingRounds: calls.filter(value => value.toolCalls > 0).length, + requests: calls, results: results.slice(start.results), committed, outputBytes: committed.reduce((sum, value) => sum + Buffer.byteLength(value.text), 0) } + } + return { ctx, agent, run, close: () => ctx.fiber.dispose() } +} diff --git a/bench/gates/index.mjs b/bench/gates/index.mjs new file mode 100644 index 00000000..341f96ee --- /dev/null +++ b/bench/gates/index.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** Opt-in deterministic release gates. A failed or unavailable gate exits nonzero. */ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { formatReport } from './report.mjs' +import { verdict, fullAcceptance } from './metrics.mjs' +const root = fileURLToPath(new URL('../../', import.meta.url)) +export function parse(argv) { + const options = { tasks: [], worker: path.join(root, 'native', 'target', 'release', process.platform === 'win32' ? 'dsh-ops-worker.exe' : 'dsh-ops-worker') } + for (let i = 0; i < argv.length; ++i) { + const key = argv[i] + if (key === '--help' || key === '-h') { options.help = true; continue } + const field = { '--worker': 'worker', '--baseline-source': 'baselineSource', '--baseline-worker': 'baselineWorker', '--output': 'output', '--task': 'task' }[key] + if (!field || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error('Unknown or incomplete argument: ' + key) + const value = argv[++i] + if (field === 'task') { + if (!/^BM-0[1-5]$/.test(value)) throw new Error('Unknown benchmark task: ' + value) + if (!options.tasks.includes(value)) options.tasks.push(value) + } else options[field] = path.resolve(value) + } + if (!options.tasks.length) options.tasks = ['BM-01', 'BM-02', 'BM-03', 'BM-04', 'BM-05'] + if (!options.help && !options.output) throw new Error('--output is required; evidence files are never overwritten') + return options +} +export async function main(argv, io = { out: console.log, err: console.error }) { + let options + try { options = parse(argv) } catch (error) { io.err(String(error)); return 2 } + if (options.help) { io.out('node bench/cli.mjs gates --output report.json [--worker PATH] [--baseline-source PATH --baseline-worker PATH] [--task BM-01]'); return 0 } + const markdown = options.output + '.md' + if (fs.existsSync(options.output) || fs.existsSync(markdown)) { io.err('Refusing to overwrite evidence: ' + options.output); return 2 } + if (!fs.existsSync(options.worker)) { io.err('Build the candidate worker first: ' + options.worker); return 2 } + const temporary = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'ops-bm040-'))) + // Set only the dedicated DSH home; never mutate the operator HOME or profile. + process.env.DSH_HOME = path.join(temporary, 'dsh-home'); fs.mkdirSync(process.env.DSH_HOME) + const { bm01, bm02, bm03, bm05 } = await import('./tasks.mjs'), { bm04 } = await import('./pdf.mjs') + const { baselineProvenance } = await import('./provenance.mjs') + const report = { format: 'ops-040-gates-v1', createdAt: new Date().toISOString(), platform: process.platform, arch: process.arch, + node: process.version, hostVersion: '0.2.0-rc.2', model: 'scripted, no network or paid model calls', temporary, + candidateRevision: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(), + fixtureSourceSha256: createHash('sha256').update(fs.readFileSync(new URL('./fixtures.mjs', import.meta.url))).digest('hex'), + candidateWorker: { path: options.worker, sha256: createHash('sha256').update(fs.readFileSync(options.worker)).digest('hex') }, gates: [] } + const tasks = { 'BM-01': bm01, 'BM-02': bm02, 'BM-03': bm03, 'BM-04': bm04, 'BM-05': bm05 } + for (const id of options.tasks) { + io.out('Running ' + id) + try { + if (id === 'BM-01') report.baseline = baselineProvenance(options.baselineSource, options.baselineWorker) + report.gates.push(await tasks[id]({ ...options, temporary })) + } catch (error) { report.gates.push({ id, status: 'failed', error: String(error?.stack ?? error) }) } + io.out(id + ': ' + report.gates.at(-1).status) + } + report.passed = verdict(report.gates) + report.selectedTasks = options.tasks + report.fullAcceptance = fullAcceptance(report.gates) + // Retain the exclusively owned temporary fixtures for inspection; no broad cleanup. + fs.writeFileSync(options.output, JSON.stringify(report, null, 2) + '\n', { flag: 'wx' }) + fs.writeFileSync(markdown, formatReport(report), { flag: 'wx' }) + io.out('Evidence: ' + options.output + ' and ' + markdown) + return report.passed ? 0 : 1 +} +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) process.exitCode = await main(process.argv.slice(2)) diff --git a/bench/gates/metrics.mjs b/bench/gates/metrics.mjs new file mode 100644 index 00000000..c4f0fb75 --- /dev/null +++ b/bench/gates/metrics.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import { getEncoding } from 'js-tiktoken' +const calls = run => run.results.map(item => item.result) +export const schemaMeasurement = tools => { + const json = JSON.stringify(tools.map(({ name, description, parameters }) => ({ name, description, parameters }))) + return { tokens: getEncoding('cl100k_base').encode(json).length, bytes: Buffer.byteLength(json), json } +} +export function verifyReads(run, files, projection) { + assert.equal(run.committed.length, projection === 'candidate' ? 1 : 3) + for (const result of calls(run)) assert.equal(result.isError, false, JSON.stringify(result)) + const values = projection === 'candidate' ? calls(run)[0].value.items.map(value => ({ path: value.path, value })) + : run.results.map(item => ({ path: item.arguments[projection === 'official' ? 'file_path' : 'path'], value: item.result.value })) + assert.equal(values.length, files.length) + assert.equal(new Set(values.map(item => item.path)).size, files.length, 'each requested file appears once') + for (const item of values) { + const expected = files.find(file => file.path === item.path) + assert.ok(expected, 'the returned file must be requested') + const value = item.value + const lines = projection === 'official' ? value.lines.map(({ number, text }) => ({ line: number, text })) : value.items.map(({ line, text }) => ({ line, text })) + assert.deepEqual(lines, expected.expected, 'all comparators must return exactly the requested logical lines') + } +} + +export const verdict = gates => gates.length > 0 && gates.every(gate => gate.status === 'passed') +export const fullAcceptance = gates => verdict(gates) && new Set(gates.map(gate => gate.id)).size === 5 && ['BM-01', 'BM-02', 'BM-03', 'BM-04', 'BM-05'].every(id => gates.some(gate => gate.id === id)) + +export function lowerBound(files, rows) { + const sourceUtf8Bytes = files.reduce((sum, file) => sum + Buffer.byteLength(file.expected.map(line => line.text).join('\n')), 0) + const absolutePathRangeLabelsBytes = files.reduce((sum, file) => sum + Buffer.byteLength(file.path + ':' + file.offset + '-' + file.expected.at(-1)?.line + '\n'), 0) + return { sourceUtf8Bytes, absolutePathRangeLabelsBytes, + labelMethod: 'Illustrative uncompressed absolute path:start-end labels; shared-base layouts can be smaller. The source-only bound needs no labels.', + comparisons: Object.fromEntries(['baseline', 'official'].map(name => [name, { targetBytes: rows[name].outputBytes * .6, + sourceAloneExceedsTarget: sourceUtf8Bytes > rows[name].outputBytes * .6, maximumSourceOnlySavings: 1 - sourceUtf8Bytes / rows[name].outputBytes }])) } +} +const escapedLine = text => text.replace(/[\r\n\t\x00-\x1f\x7f\u0085\u2028\u2029]/g, char => /[\u0085\u2028\u2029]/.test(char) ? '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0') : JSON.stringify(char).slice(1,-1)) +/** Measure the actual model-visible text only after every source line is verified there too. */ +export function verifyEmittedReads(run, files, projection) { + if (projection === 'baseline') { + const values = run.committed.map(item => JSON.parse(item.text)) + for (const file of files) { const value = values.find(value => value.path === file.path); assert.ok(value); assert.deepEqual(value.items.map(({line,text})=>({line,text})), file.expected) } + return + } + if (projection === 'official') { + for (const file of files) { + const result = run.results.find(item => item.arguments.file_path === file.path) + const event = run.committed.find(item => item.event.data.message.toolCallId === result.callId) + assert.ok(event, 'official committed result identity') + const lines = event.text.split('\n').map(line => /^(\d+): (.*)$/.exec(line)).filter(Boolean).map(match => ({line:Number(match[1]),text:match[2]})) + assert.deepEqual(lines, file.expected) + } + return + } + const text = run.committed[0].text, base = /^\[Base: (.+)\]$/m.exec(text), groups = new Map(); let current + for (const line of text.split('\n')) { + const header = /^=== FILE: (.+) \(Lines .*\) ===$/.exec(line) + if (header) { const name = header[1].startsWith('"') ? JSON.parse(header[1]) : header[1]; current = path.isAbsolute(name) ? name : path.join(JSON.parse(base[1]), name); assert.equal(groups.has(current), false); groups.set(current, []); continue } + const row = /^(\d+)(?: \| |\|)(.*)$/.exec(line) + if (row) { assert.ok(current); groups.get(current).push({line:Number(row[1]),text:row[2]}) } + } + assert.equal(groups.size, files.length) + for (const file of files) assert.deepEqual(groups.get(file.path), file.expected.map(({line,text})=>({line,text:escapedLine(text)})), 'all emitted requested lines must survive before bytes are compared') +} diff --git a/bench/gates/pdf.mjs b/bench/gates/pdf.mjs new file mode 100644 index 00000000..f5ae1363 --- /dev/null +++ b/bench/gates/pdf.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { boot } from './host.mjs' +import { publicToolName } from '../../lib/policy.js' +import { capability } from '../../lib/capabilities/registry.js' +export async function bm04(options) { + if (!capability('pdf_read')) return { id: 'BM-04', status: 'blocked', reason: 'The integrated catalogue does not yet contain a reviewed PDF reader; stat lifecycle tests are not PDF acceptance.' } + const { textPdf, show } = await import('../../test/lib/pdf-fixture.mjs') + const root = path.join(options.temporary, 'pdf-discovery'); fs.mkdirSync(root) + const file = path.join(root, 'one-page.pdf'); fs.writeFileSync(file, textPdf([show('BM04 PDF text')], { deflate: true })) + const host = await boot({ ...options, root }) + try { + const owner = await host.agent(), sibling = await host.agent() + const visible = request => request.tools.some(tool => tool.name === publicToolName('pdf_read')) + const run = await host.run(owner, [request => { + assert.equal(visible(request), false, 'no PDF schema before activation') + return [{ name: publicToolName('discover'), args: { query: 'select:' + publicToolName('pdf_read') } }] + }, request => { + assert.equal(visible(request), true, 'PDF must appear in the immediately following provider request') + assert.equal(host.ctx.get('tools').get(publicToolName('pdf_read'), sibling), undefined) + return [{ name: publicToolName('pdf_read'), args: { path: file, offset: 1, limit: 1 } }] + }, []]) + const pdf = run.results.find(item => item.name === publicToolName('pdf_read')).result + assert.equal(pdf.isError, false); assert.match(JSON.stringify(pdf.value.items), /BM04 PDF text/) + return { id: 'BM-04', status: 'passed', initialPdfSchemas: 0, activationRequest: 1, firstAvailableRequest: 2, run } + } finally { await host.close() } +} diff --git a/bench/gates/prepare-baseline.mjs b/bench/gates/prepare-baseline.mjs new file mode 100644 index 00000000..2cbdddbf --- /dev/null +++ b/bench/gates/prepare-baseline.mjs @@ -0,0 +1,34 @@ +/** Write an exact committed baseline tree without checkout newline conversion or tar dependencies. */ +import fs from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { BASELINE_REVISION } from './provenance.mjs' +const repository = fileURLToPath(new URL('../../', import.meta.url)) +export function prepareBaseline(destination) { + const target = path.resolve(destination) + if (fs.existsSync(target)) throw new Error('Refusing to overwrite baseline directory: ' + target) + const entries = execFileSync('git', ['ls-tree', '-r', '-z', BASELINE_REVISION, '--', 'lib', 'native', 'package.json'], { cwd: repository }).toString('utf8').split('\0').filter(Boolean).map(entry => { + const match = /^100\d+ blob ([a-f0-9]+)\t(.+)$/.exec(entry) + if (!match) throw new Error('Unsupported baseline tree entry') + const file = path.resolve(target, match[2]) + if (!file.startsWith(target + path.sep)) throw new Error('Invalid baseline path') + return { hash: match[1], file } + }) + const objects = execFileSync('git', ['cat-file', '--batch'], { cwd: repository, input: entries.map(item => item.hash).join('\n') + '\n', maxBuffer: 32 * 1024 * 1024 }) + fs.mkdirSync(target) + let offset = 0 + for (const entry of entries) { + const end = objects.indexOf(10, offset), header = objects.subarray(offset, end).toString('utf8').split(' '), size = Number(header[2]) + if (header[0] !== entry.hash || header[1] !== 'blob' || !Number.isSafeInteger(size) || size < 0) throw new Error('Invalid baseline blob') + const bytes = objects.subarray(end + 1, end + 1 + size) + if (bytes.length !== size || objects[end + 1 + size] !== 10) throw new Error('Incomplete baseline blob') + fs.mkdirSync(path.dirname(entry.file), { recursive: true }); fs.writeFileSync(entry.file, bytes, { flag: 'wx' }) + offset = end + 2 + size + } + return { revision: BASELINE_REVISION, directory: target, files: entries.length } +} +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + if (process.argv.length !== 3) throw new Error('Usage: node bench/gates/prepare-baseline.mjs NEW_DIRECTORY') + console.log(JSON.stringify(prepareBaseline(process.argv[2]))) +} diff --git a/bench/gates/provenance.mjs b/bench/gates/provenance.mjs new file mode 100644 index 00000000..2ef6f225 --- /dev/null +++ b/bench/gates/provenance.mjs @@ -0,0 +1,25 @@ +import fs from 'node:fs' +import path from 'node:path' +import { execFileSync, spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { fileURLToPath } from 'node:url' +const repository = fileURLToPath(new URL('../../', import.meta.url)) +export const BASELINE_REVISION = '4b51b6333213c376c8b7c173c12f35b408fdec2d' +export function baselineProvenance(source, binary) { + if (!source || !binary) throw new Error('BM-01 requires --baseline-source and --baseline-worker; missing baseline is not a pass') + const revision = execFileSync('git', ['rev-parse', BASELINE_REVISION + '^{commit}'], { cwd: repository, encoding: 'utf8' }).trim() + const entries = execFileSync('git', ['ls-tree', '-r', revision, '--', 'lib', 'native'], { cwd: repository, encoding: 'utf8' }).trim().split('\n') + let verifiedFiles = 0 + for (const entry of entries) { + const match = /^\d+ blob ([a-f0-9]+)\t(.+)$/.exec(entry) + if (!match) throw new Error('Unsupported baseline tree entry: ' + entry) + const file = path.join(source, match[2]), bytes = fs.readFileSync(file) + const actual = createHash('sha1').update(Buffer.from('blob ' + bytes.length + '\0')).update(bytes).digest('hex') + if (actual !== match[1]) throw new Error('Baseline source differs from ' + revision + ': ' + match[2]) + ++verifiedFiles + } + const version = spawnSync(binary, ['--version'], { encoding: 'utf8', timeout: 10000, windowsHide: true }) + if (version.error || version.status !== 0 || version.stdout.trim() !== 'dsh-ops-worker 0.3.1') throw new Error('Baseline must be an explicit source-built 0.3.1 worker') + return { revision, verifiedFiles, source, binary, workerSha256: createHash('sha256').update(fs.readFileSync(binary)).digest('hex'), + binaryAttestation: 'Version and hash recorded; source matches Git blobs. No reproducible-build attestation is claimed.' } +} diff --git a/bench/gates/report.mjs b/bench/gates/report.mjs new file mode 100644 index 00000000..7f5724e4 --- /dev/null +++ b/bench/gates/report.mjs @@ -0,0 +1,23 @@ +const pct = value => (value * 100).toFixed(3) + '%' +export function formatReport(report) { + const lines = ['# OPS 0.4 benchmark evidence', '', `Platform: ${report.platform}-${report.arch}; host ${report.hostVersion}; Node ${report.node}.`, '', + 'Real host AgentLoop and tool dispatch; scripted provider, no paid model calls. Byte counts cover committed tool-result text; provider requests include the final answer request.', '', + `Selected gates passed: ${report.passed}. All five gates accepted: ${report.fullAcceptance}.`, '', '| Gate | Status |', '| --- | --- |', ...report.gates.map(gate => `| ${gate.id} | ${gate.status} |`)] + const read = report.gates.find(gate => gate.id === 'BM-01') + if (read?.cases) { + lines.push('', '| Predeclared case | Candidate bytes | 0.3.1 bytes | Official bytes | Saving vs 0.3.1 | Saving vs official |', '| --- | ---: | ---: | ---: | ---: | ---: |') + for (const item of read.cases) lines.push(`| ${item.scenario.id}${item.scenario.primary ? ' (primary)' : ''} | ${item.rows.candidate.outputBytes} | ${item.rows.baseline.outputBytes} | ${item.rows.official.outputBytes} | ${pct(item.savings.baseline)} | ${pct(item.savings.official)} |`) + const primary = read.cases.find(item => item.scenario.primary) + lines.push('', '| Primary comparator | Tool-bearing rounds | All provider requests |', '| --- | ---: | ---: |', + ...Object.entries(primary.rows).map(([name, row]) => `| ${name} | ${row.toolBearingRounds} | ${row.providerRequests} |`), '', + `Primary lossless source-only lower bound: ${primary.lowerBound.sourceUtf8Bytes} UTF-8 bytes. Illustrative absolute path/range labels add ${primary.lowerBound.absolutePathRangeLabelsBytes} bytes. Shared-base labels may be smaller.`, + ...Object.entries(primary.lowerBound.comparisons).map(([name, facts]) => `${name}: 40% target ${facts.targetBytes.toFixed(1)} bytes; source alone exceeds target: ${facts.sourceAloneExceedsTarget}.`)) + } + for (const gate of report.gates) { + if (gate.measurement) lines.push('', `${gate.id}: full default schema ${gate.measurement.tokens} tokens / ${gate.measurement.bytes} bytes; ${gate.names.join(', ')}.`, `Whole startup message JSON: ${gate.wholeStartupMessages.tokens} tokens / ${gate.wholeStartupMessages.bytes} bytes; this is separate from schema accounting.`) + if (gate.reason || gate.error) lines.push('', `${gate.id}: ${gate.reason ?? gate.error}`) + if (gate.gates) lines.push('', `${gate.id}: ${JSON.stringify(gate.gates)}. ${gate.scopeNote ?? ''}`) + } + lines.push('', 'Earlier worker/renderer-only 3×8 probe: 2256 baseline bytes, 1949 candidate bytes (13.608% reduction), modelRounds=null, hostAuthorityExercised=false. It is historical evidence, not a formal host benchmark pass. Absolute fixture paths and renderer revisions affect byte counts.', '', 'The JSON artifact retains exact inputs, fixture hashes, canonical results, model-visible text, request schemas and comparison provenance. No universal savings or model-quality claim is made.', '') + return lines.join('\n') +} diff --git a/bench/gates/tasks.mjs b/bench/gates/tasks.mjs new file mode 100644 index 00000000..c1b6f64e --- /dev/null +++ b/bench/gates/tasks.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { getEncoding } from 'js-tiktoken' +import { publicToolName } from '../../lib/policy.js' +import { schemaMeasurement, verifyReads, verifyEmittedReads, lowerBound } from './metrics.mjs' +import { boot } from './host.mjs' +import { READ_CASES, readFixture, fiveEdits, hash } from './fixtures.mjs' +const calls = run => run.results.map(item => item.result) +const raw = (name, args) => ({ name, args }) +const text = run => run.committed.map(item => item.text).join('\n') +/** One primary issue-declared task, plus a prespecified range/content sensitivity grid. */ +export async function bm01(options) { + const cases = [] + for (const scenario of READ_CASES) { + const root = path.join(options.temporary, scenario.id); fs.mkdirSync(root) + const files = readFixture(root, scenario), rows = {} + for (const projection of ['candidate', 'baseline', 'official']) { + const host = await boot({ ...options, root, projection, worker: projection === 'baseline' ? options.baselineWorker : options.worker }) + try { + const owner = await host.agent(), ranges = files.map(({ path, offset, limit }) => ({ path, offset, limit })) + const requests = projection === 'candidate' ? [[raw(publicToolName('read_many'), { files: ranges })]] + : ranges.map(({ path, ...range }) => [raw(projection === 'official' ? 'read' : publicToolName('inspect_local_file'), { [projection === 'official' ? 'file_path' : 'path']: path, ...range })]) + const run = await host.run(owner, [...requests, []]); verifyReads(run, files, projection); verifyEmittedReads(run, files, projection) + rows[projection] = { ...run, logicalLines: files.reduce((n, file) => n + file.expected.length, 0) } + if (scenario.primary && projection !== 'candidate') { + const parallel = await host.run(await host.agent(), [requests.flat(), []]); verifyReads(parallel, files, projection); verifyEmittedReads(parallel, files, projection) + rows[projection + 'Parallel'] = parallel + } + } finally { await host.close() } + } + const savings = Object.fromEntries(['baseline', 'official'].map(name => [name, 1 - rows.candidate.outputBytes / rows[name].outputBytes])) + cases.push({ scenario, files, rows, savings, lowerBound: lowerBound(files, rows), passes: { oneToolRound: rows.candidate.toolBearingRounds === 1, + vs031: savings.baseline >= .4, vsOfficial: savings.official >= .4 } }) + } + const primary = cases.find(item => item.scenario.primary) + return { id: 'BM-01', status: Object.values(primary.passes).every(Boolean) ? 'passed' : 'failed', + criterion: 'One tool-bearing provider round; >=40% emitted text-byte reduction on predeclared 3×50 primary task against each separately reported comparator; neither comparator failure is hidden.', + universalSavingsClaim: false, providerModel: 'scripted; no paid calls', cases } +} + +export async function bm02(options) { + const root = path.join(options.temporary, 'five-edits'); fs.mkdirSync(root) + const fixture = fiveEdits(root), host = await boot({ ...options, root, alwaysLoaded: ['edit_many'] }) + try { + const owner = await host.agent() + const applied = await host.run(owner, [[raw(publicToolName('edit_many'), { path: fixture.path, expectedRevision: fixture.revision, edits: fixture.edits })], []]) + assert.equal(calls(applied)[0].isError, false); assert.equal(calls(applied)[0].value.status, 'completed') + assert.equal(fs.readFileSync(fixture.path, 'utf8'), fixture.expected) + assert.equal(applied.toolBearingRounds, 1) + assert.equal(text(applied).includes(fixture.original), false); assert.equal(text(applied).includes(fixture.expected), false) + // Stale expected revisions after external edits must never apply any of the five edits. + const conflicts = [] + for (const suffix of ['\nexternal change', '\n另一个改动']) { + const changed = fixture.original + suffix; fs.writeFileSync(fixture.path, changed) + const run = await host.run(owner, [[raw(publicToolName('edit_many'), { path: fixture.path, expectedRevision: fixture.revision, edits: fixture.edits })], []]) + const result = calls(run)[0].value + assert.equal(result.error?.code, 'REVISION_CONFLICT', JSON.stringify(result)) + assert.equal(fs.readFileSync(fixture.path, 'utf8'), changed) + conflicts.push(run) + } + return { id: 'BM-02', status: 'passed', applied, conflicts, conflictDetection: { rejected: conflicts.length, attempted: conflicts.length }, + originalHash: fixture.revision, expectedHash: hash(fixture.expected), noFullFileEcho: true } + } finally { await host.close() } +} + +export async function bm03(options) { + const root = path.join(options.temporary, 'startup'); fs.mkdirSync(root) + const host = await boot({ ...options, root, withShell: process.platform === 'win32' }) + try { + const run = await host.run(await host.agent(), [[]]), request = run.requests[0] + const measurement = schemaMeasurement(request.tools), names = request.tools.map(tool => tool.name) + const shellReady = process.platform !== 'win32' || names.includes(publicToolName('bash')) + const messages = JSON.stringify(request.messages) + return { id: 'BM-03', status: !shellReady ? 'blocked' : measurement.tokens <= 400 ? 'passed' : 'failed', + criterion: 'Full default Native added schema <=400 tokens; Windows includes actual default Bash registration.', + pendingBudgetScopeDecision: true, shellReady, platform: process.platform, names, measurement, + wholeStartupMessages: { tokens: getEncoding('cl100k_base').encode(messages).length, bytes: Buffer.byteLength(messages), json: messages }, + methodology: 'js-tiktoken@1.0.21 cl100k_base over compact host name/description/parameters JSON, actual provider order; not provider billing.' } + } finally { await host.close() } +} + +export async function bm05(options) { + const root = path.join(options.temporary, 'authority-workspace'), outside = path.join(options.temporary, 'outside') + fs.mkdirSync(root); fs.mkdirSync(outside) + const publicPath = path.join(outside, 'ordinary.txt'), secretPath = path.join(outside, '.env'), target = path.join(outside, 'deny-write.txt') + fs.writeFileSync(publicPath, 'outside ordinary data'); fs.writeFileSync(secretPath, 'BENCHMARK_SECRET_BODY') + const host = await boot({ ...options, root, alwaysLoaded: ['write_file'] }) + try { + const owner = await host.agent() + const read = await host.run(owner, [[raw(publicToolName('read_many'), { files: [{ path: publicPath }] })], []]) + const denied = await host.run(owner, [[raw(publicToolName('read_many'), { files: [{ path: secretPath }] })], []]) + const write = await host.run(owner, [[raw(publicToolName('write_file'), { path: target, content: 'MUST_NOT_APPEAR', expectedRevision: 'absent' })], []]) + const values = calls(read)[0].value.items, deniedRead = calls(denied)[0].value, deniedWrite = calls(write)[0].value + const forbidden = [options.temporary, path.dirname(options.temporary), 'BENCHMARK_SECRET_BODY'] + const denialText = text(write) + text(denied) + const gates = { writeRejected: deniedWrite.status === 'rejected' && deniedWrite.error?.code === 'POLICY_DENIED', + secretRejected: deniedRead?.status === 'rejected', unchanged: !fs.existsSync(target) && fs.readFileSync(secretPath, 'utf8') === 'BENCHMARK_SECRET_BODY', + noParentPathLeak: forbidden.every(value => !denialText.includes(value)) } + return { id: 'BM-05', status: Object.values(gates).every(Boolean) ? 'passed' : 'failed', gates, read, denied, write, + enforcement: 'Real host SandboxPolicy and fixed-operation OPS bridge/worker; no arbitrary-process confinement claim.', + outsideOrdinaryRead: values.find(value => value.path === publicPath), + scopeNote: 'The pinned host authorizes OS-readable ordinary outside reads. Only credential-fenced reads and workspace-escaping writes are unauthorized; this is not a blanket workspace read jail.' } + } finally { await host.close() } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f418c989..f1c84bf3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -28,6 +28,10 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Migrate credential fences, trusted path anchors and permission failures to strict TypeScript without changing emitted entrypoints or authority decisions. Preserve worker effect facts during denial normalization and assert typed host modes/unknown-input narrowing. - 将凭据围栏、可信路径锚点和权限错误迁移为严格 TypeScript,保持生成入口和权限决策不变;拒绝归一化保留 worker 效果事实,并验证宿主模式及未知输入的类型缩窄。 +### Add opt-in real-host acceptance evidence (#261, 16/16) + +- Run BM-01–05 with a scripted provider, exact0.3.1 and pinned official comparators, immutable sensitivity fixtures, visible-content assertions, numerical lossless lower bounds and JSON/Markdown reports. Manual cross-platform CI uploads failed and blocked evidence; no paid model calls or default gate changes. +- 新增真实宿主 BM-01–05 按需基准、独立基线及不可变敏感性夹具;保留准确正文断言、无损字节下界和失败/阻塞证据,不调用付费模型,不改变默认门禁。 ### Keep credential denial paths private (#261, BM-05) diff --git a/docs/benchmark-gates.md b/docs/benchmark-gates.md new file mode 100644 index 00000000..5434990e --- /dev/null +++ b/docs/benchmark-gates.md @@ -0,0 +1,40 @@ +# Scripted acceptance benchmarks + +中文:[基准门禁](基准门禁.md). + +`node bench/cli.mjs gates` runs the BM-01–05 scenarios through the pinned DSH `0.2.0-rc.2` AgentLoop, Session, tool dispatch and authority services. A deterministic provider selects the calls; no paid model or network model request is made. The suite measures tool behavior, text and round counts, not model quality or billing tokens. It is opt-in and is not part of the default functional gate. + +```console +npm ci --ignore-scripts +npm run build +npm run build:ptc +node bench/gates/prepare-baseline.mjs baseline-031 +cargo build --release --locked --manifest-path baseline-031/native/Cargo.toml +node bench/cli.mjs gates --baseline-source baseline-031 --baseline-worker baseline-031/native/target/release/dsh-ops-worker --output benchmark-gates.json +``` + +On Windows append `.exe` to the baseline worker. Use a new output filename for each run. The command refuses to overwrite either JSON evidence or its `.md` summary. It retains its exclusively owned temporary fixture directory for inspection. `--task BM-02` selects one gate; selected success never means that all five passed. Any failed or unavailable selected gate exits 1; malformed arguments or missing build inputs exit 2. + +The manual `Opt-in OPS acceptance benchmarks` workflow runs the same command on Windows x64, Linux x64 and Apple Silicon, independently builds the baseline, and uploads both artifacts even when a gate fails. It changes no default CI requirement and uses no model credentials. + +## Fixed workloads and comparisons + +| Gate | Evidence and criterion | +| --- | --- | +| BM-01 | Three files with 70/80/90 TypeScript lines, each requesting its first 50 lines, as declared in the issue prose. Compare one `read_many` call with separately named OPS 0.3.1 and pinned official `read` results. Canonical and emitted text must retain exactly the requested lines before measuring bytes. Report sequential three-round and parallel one-round baseline controls separately; report total provider requests including the final answer. Both 40% comparisons remain individually visible. | +| BM-02 | Five distinct edits in one file, one successful submission, then two externally changed stale-CAS cases. Verify exact resulting bytes, all conflicts rejected, and no full-file echo. | +| BM-03 | Measure actual first provider schemas for the full default Native surface. Windows includes the published Bash contract with the real jobs service; absence blocks the gate. Count compact `name/description/parameters` JSON with exact `js-tiktoken@1.0.21`, `cl100k_base`. Keep whole startup messages separately. The full ≤400 target remains pending; no narrower scope is silently substituted. | +| BM-04 | A real one-page Helvetica/Flate PDF. No PDF schema initially; discovery request one must expose `pdf_read` in provider request two, without sibling visibility; then verify extracted text. Missing integrated PDF capability is blocked. | +| BM-05 | Real credential-fenced outside reads and workspace-escaping writes must return rejected, preserve files, and reveal no denied parent path or secret body. An ordinary OS-readable outside read is a separately labelled allowed control: the pinned host does not implement a blanket workspace read jail. | + +BM-01 also always reports offsets 13/31/51 with 8 lines, plus 1-line, 32-line, 600-character-line and escaped Unicode/JSON sensitivities. The frozen fixture definitions, file hashes and fixture-source hash appear in evidence. There is no automatic workload selection to obtain a favorable threshold. + +The 0.3.1 baseline is commit `4b51b6333213c376c8b7c173c12f35b408fdec2d`. The preparation script materializes exact Git blobs without checkout newline conversion; provenance rechecks every `lib/` and `native/` blob and records the worker version and SHA-256. Binary reproducibility is not attested. The official comparator uses the installed, pinned host reader separately. The test-only jobs package `@deepseek-ai/dsh-jobs-local@0.2.0-rc.2` was published on 2026-09-29, more than seven days before addition; it is needed to measure the complete Bash schema. + +## Honest limits and retained results + +The earlier worker/renderer-only 3×8 probe recorded 2,256 baseline and 1,949 candidate bytes (13.608% reduction), with `modelRounds: null` and `hostAuthorityExercised: false`. This remains limited historical evidence. + +The subsequent real AgentLoop primary case recorded 8,883 OPS 0.3.1 bytes, 5,457 official-reader bytes and 6,239 candidate bytes. Replaying those unchanged canonical receipts through the compact renderer gives 5,348 bytes: 39.795% below 0.3.1, still below 40%. The final integrated host rerun remains required. + +The primary source lines alone occupy 4,320 UTF-8 bytes. Forty percent below the 5,457-byte official result requires at most 3,274.2 bytes, so that comparison cannot pass without source omission or compression even before path/revision/coverage labels. The report includes this numerical lower bound and an illustrative absolute-path/range label size; shared-base layouts can reduce the latter. Long source lines likewise prevent universal percentage savings. Output paths, host contracts and formatter revisions affect measured bytes, so all exact receipts remain attached. diff --git "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" new file mode 100644 index 00000000..d00aedb4 --- /dev/null +++ "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" @@ -0,0 +1,40 @@ +# 基准门禁 + +English: [Scripted acceptance benchmarks](benchmark-gates.md). + +`node bench/cli.mjs gates` 通过固定 DSH `0.2.0-rc.2` 的真实 AgentLoop、Session、工具派发与权限服务运行 BM-01–05。确定性供应商脚本选择调用,不使用付费模型或网络模型请求。测量对象是工具行为、文本及交互轮数,不代表模型质量或计费 token。本套件按需运行,不进入默认功能门禁。 + +```console +npm ci --ignore-scripts +npm run build +npm run build:ptc +node bench/gates/prepare-baseline.mjs baseline-031 +cargo build --release --locked --manifest-path baseline-031/native/Cargo.toml +node bench/cli.mjs gates --baseline-source baseline-031 --baseline-worker baseline-031/native/target/release/dsh-ops-worker --output benchmark-gates.json +``` + +Windows 的基线 worker 路径须加 `.exe`。每次运行使用新的输出文件名,JSON 证据与 `.md` 摘要都拒绝覆盖。工具保留独占创建的临时夹具目录以便检查。`--task BM-02` 仅选择一个门禁,其成功不能代表五项全部通过。所选项失败或不可用时退出 1;参数错误或缺少构建输入时退出 2。 + +手动工作流 `Opt-in OPS acceptance benchmarks` 在 Windows x64、Linux x64 和 Apple Silicon 上运行同一命令,独立构建基线,并在门禁失败时仍上传两份产物。默认 CI 要求不变,无需模型凭据。 + +## 固定工作负载与比较方式 + +| 门禁 | 证据与条件 | +| --- | --- | +| BM-01 | 三个包含 70/80/90 行的 TypeScript 文件,各取前 50 行,来自议题正文的具体示例。一个 `read_many` 分别对比 OPS 0.3.1 与固定宿主的官方 `read`。先断言规范数据及实际输出都完整保留请求行,再测字节。分别报告基线串行三轮与并行一轮控制组,以及包含最后回答的供应商总请求数。两项 40% 比较各自明确保留。 | +| BM-02 | 单文件五处独立编辑,一次提交成功,然后制造两种外部修改导致的旧 CAS 冲突。验证准确字节、全部冲突被拒绝,以及不复读完整文件。 | +| BM-03 | 测量第一次真实供应商请求中的完整默认 Native Schema。Windows 包含真实 jobs 服务下发布的 Bash 契约,缺失时阻塞。使用固定 `js-tiktoken@1.0.21`、`cl100k_base` 对紧凑 `name/description/parameters` JSON 计数,启动消息总量另列。完整 ≤400 目标仍待解决,不暗中替换为更窄口径。 | +| BM-04 | 真实单页 Helvetica/Flate PDF。初始无 PDF Schema;第一请求发现后,第二请求必须显示 `pdf_read`,兄弟作用域仍不可见,并验证提取文本。尚未集成 PDF 时明确阻塞。 | +| BM-05 | 工作区外凭据围栏读取与越界写入都须返回 rejected、保持文件不变,并且不披露被拒父路径或秘密正文。普通 OS 可读的工作区外文件作为允许读取的独立控制组:固定宿主不提供全面的工作区读取围栏。 | + +BM-01 始终保留偏移 13/31/51、每文件 8 行的案例,以及 1 行、32 行、约 600 字符长行和转义 Unicode/JSON 敏感性案例。夹具定义冻结,证据记录文件散列与夹具源文件散列,不自动挑选有利案例。 + +0.3.1 基线固定为 `4b51b6333213c376c8b7c173c12f35b408fdec2d`。准备脚本直接还原 Git blob,避免 checkout 换行转换;运行前逐一校验 `lib/` 与 `native/`,并记录 worker 版本及 SHA-256,不宣称二进制可重现构建认证。官方比较组独立使用已安装的固定版本宿主读取器。测试专用 `@deepseek-ai/dsh-jobs-local@0.2.0-rc.2` 发布于 2026-09-29,添加时已超过七天,用于完整计量 Bash Schema。 + +## 保留的结果与限制 + +早期只运行 worker/渲染器的 3×8 探针得到基线 2,256、候选 1,949 字节(下降 13.608%),`modelRounds: null`、`hostAuthorityExercised: false`,只作为有限的历史证据保留。 + +随后真实 AgentLoop 的主案例得到 OPS 0.3.1 为 8,883、官方读取器 5,457、候选 6,239 字节。使用同一规范结果重放紧凑渲染器后为 5,348 字节,相比 0.3.1 下降 39.795%,仍未达到 40%;最终集成后的完整宿主重跑仍然必需。 + +主案例仅原始行内容就有 4,320 UTF-8 字节,而比官方 5,457 字节少 40% 要求不超过 3,274.2 字节,因此即使不计路径、版本与覆盖信息,也无法在不省略或压缩原文的前提下通过。报告列出这一数值下界及示例绝对路径/范围标签成本;共享父目录可缩短后者。长行同样使通用百分比节省不成立。路径、宿主契约和渲染版本会影响字节,所以保留全部准确输入与回执。 diff --git a/package-lock.json b/package-lock.json index 301a1f15..4995d732 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@deepseek-ai/dsh-config-editor": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-local": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-observation-policy": "0.2.0-rc.2", + "@deepseek-ai/dsh-jobs-local": "0.2.0-rc.2", "@deepseek-ai/dsh-ptc-runtime-node": "0.2.0-rc.2", "@deepseek-ai/dsh-sandbox-local": "0.2.0-rc.2", "@deepseek-ai/dsh-scope": "0.2.0-rc.2", @@ -2749,6 +2750,44 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/@deepseek-ai/dsh-jobs": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-jobs/-/dsh-jobs-0.2.0-rc.2.tgz", + "integrity": "sha512-SuCCfXZDabBxsKbHoulXEeQrRSYuOYLSfhvnflQf+CYeb68wggKraejkLyO60LrCGnBJsgCqKIdIKLCUvIeN+g==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-agent": "0.2.0-rc.2", + "@deepseek-ai/dsh-brand": "0.2.0-rc.2", + "@deepseek-ai/dsh-invariants": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2", + "@deepseek-ai/dsh-workspace": "0.2.0-rc.2" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-workspace": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/dsh-jobs-local": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-jobs-local/-/dsh-jobs-local-0.2.0-rc.2.tgz", + "integrity": "sha512-sNyolYfcNVodxGyoWUM3G0f+numJexMrqwR0HC4x9kvzzZNR1WZV7ca4lCAzlaRSEvY8UPYxKC6Fi4T+MnBkSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "~3.18.4" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-agent": "0.2.0-rc.2", + "@deepseek-ai/dsh-jobs": "0.2.0-rc.2", + "@deepseek-ai/dsh-scope": "0.2.0-rc.2", + "@deepseek-ai/dsh-timeout": "0.2.0-rc.2" + } } } } diff --git a/package.json b/package.json index aaa567ea..b273f94c 100644 --- a/package.json +++ b/package.json @@ -186,6 +186,7 @@ "@deepseek-ai/dsh-config-editor": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-local": "0.2.0-rc.2", "@deepseek-ai/dsh-fs-observation-policy": "0.2.0-rc.2", + "@deepseek-ai/dsh-jobs-local": "0.2.0-rc.2", "@deepseek-ai/dsh-ptc-runtime-node": "0.2.0-rc.2", "@deepseek-ai/dsh-sandbox-local": "0.2.0-rc.2", "@deepseek-ai/dsh-scope": "0.2.0-rc.2", diff --git a/test/bench-gates.test.mjs b/test/bench-gates.test.mjs new file mode 100644 index 00000000..bab8dfa5 --- /dev/null +++ b/test/bench-gates.test.mjs @@ -0,0 +1,102 @@ +import fs from 'node:fs' +import path from 'node:path' +import { assert, report, test, withTempDir } from './lib/harness.mjs' +import { parse, main } from '../bench/gates/index.mjs' +import { verdict, fullAcceptance, verifyReads, verifyEmittedReads, lowerBound, schemaMeasurement } from '../bench/gates/metrics.mjs' +import { READ_CASES, readFixture } from '../bench/gates/fixtures.mjs' +import { prepareBaseline } from '../bench/gates/prepare-baseline.mjs' +import { formatReport } from '../bench/gates/report.mjs' +import { baselineProvenance } from '../bench/gates/provenance.mjs' + +await test('benchmark evidence requires an explicit destination and named supported tasks', () => { + assert.throws(() => parse([]), /--output is required/) + assert.throws(() => parse(['--output']), /incomplete/) + assert.throws(() => parse(['--output', 'evidence.json', '--task', 'BM-06']), /Unknown benchmark/) + assert.throws(() => parse(['--output', 'evidence.json', '--paid-model', 'yes']), /Unknown/) + assert.equal(parse(['--help']).help, true) + assert.deepEqual(parse(['--output', 'evidence.json', '--task', 'BM-01', '--task', 'BM-01']).tasks, ['BM-01']) + assert.throws(() => baselineProvenance(), /missing baseline is not a pass/) +}) +await test('partial, blocked, failed and duplicate gate sets never claim full acceptance', () => { + const complete = ['BM-01', 'BM-02', 'BM-03', 'BM-04', 'BM-05'].map(id => ({ id, status: 'passed' })) + assert.equal(fullAcceptance(complete), true) + assert.equal(verdict([]), false) + assert.equal(fullAcceptance(complete.slice(0, 4)), false) + assert.equal(fullAcceptance(Array(5).fill(complete[0])), false) + for (const status of ['blocked', 'failed', 'skipped', undefined]) assert.equal(fullAcceptance([...complete.slice(0, 4), { id: 'BM-05', status }]), false) +}) +await test('a rerun cannot overwrite existing evidence, and a missing worker fails before creating it', async () => { + await withTempDir('bench-gates', async root => { + const output = path.join(root, 'report.json'), lines = [], io = { out: line => lines.push(line), err: line => lines.push(line) } + fs.writeFileSync(output, 'previous evidence') + assert.equal(await main(['--output', output], io), 2) + assert.equal(fs.readFileSync(output, 'utf8'), 'previous evidence') + assert.match(lines.join('\n'), /Refusing to overwrite/) + const missingOutput = path.join(root, 'not-created.json') + assert.equal(await main(['--output', missingOutput, '--worker', path.join(root, 'missing-worker')], io), 2) + assert.equal(fs.existsSync(missingOutput), false) + }) +}) +await test('parallel baseline reads compare by requested file, rejecting missing or incorrect logical data', () => { + const files = ['a', 'b', 'c'].map((path, index) => ({ path, expected: [{ line: index + 1, text: path }] })) + const records = [...files].reverse().map(file => ({ arguments: { path: file.path }, result: { isError: false, value: { items: file.expected } } })) + const run = { committed: [1, 2, 3], results: records } + verifyReads(run, files, 'baseline') + assert.throws(() => verifyReads({ ...run, results: [records[0], records[0], records[2]] }, files, 'baseline'), /each requested file/) + const wrong = structuredClone(run); wrong.results[0].result.value.items[0].text = 'truncated' + assert.throws(() => verifyReads(wrong, files, 'baseline'), /requested logical lines/) + const official = { ...run, results: records.map(row => ({ arguments: { file_path: row.arguments.path }, result: { isError: false, value: { lines: row.result.value.items.map(item => ({ number: item.line, text: item.text })) } } })) } + verifyReads(official, files, 'official') +}) +await test('schema accounting includes full descriptions and every contract field in provider order', () => { + const base = { name: 'example', description: 'full description', parameters: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 } } } } + const small = schemaMeasurement([base]), large = schemaMeasurement([base, { ...base, name: 'second', description: 'This full description must remain counted.' }]) + assert.equal(small.bytes, Buffer.byteLength(small.json)) + assert.equal(JSON.parse(small.json)[0].parameters.properties.limit.maximum, 100) + assert.ok(large.tokens > small.tokens) + assert.deepEqual(JSON.parse(large.json).map(tool => tool.name), ['example', 'second']) +}) +await test('the primary workload is declared before measurement and retains short/long-content sensitivities', async () => { + assert.equal(READ_CASES.filter(item => item.primary).length, 1) + const primary = READ_CASES.find(item => item.primary) + assert.equal(primary.limit, 50); assert.equal(primary.first, true) + assert.ok(READ_CASES.some(item => item.limit === 8 && item.style === 'typescript')) + await withTempDir('bench-gates', async root => { + const files = readFixture(root, primary) + assert.equal(files.length, 3); assert.ok(files.every(file => file.expected.length === 50 && file.offset === 1)) + const long = readFixture(root, READ_CASES.find(item => item.style === 'long-lines')) + assert.ok(long.every(file => file.expected.every(line => line.text.length > 600))) + }) +}) +await test('baseline materialization is immutable and source tampering fails provenance before execution', async () => { + await withTempDir('baseline-blobs', async root => { + const directory = path.join(root, 'baseline') + const prepared = prepareBaseline(directory) + assert.ok(prepared.files > 10) + assert.ok(fs.existsSync(path.join(directory, 'native', 'Cargo.lock'))) + assert.throws(() => prepareBaseline(directory), /Refusing to overwrite/) + const file = path.join(directory, 'lib', 'file', 'contract.js') + fs.appendFileSync(file, '\n// changed') + assert.throws(() => baselineProvenance(directory, path.join(root, 'unexecuted-worker')), /Baseline source differs/) + }) +}) +await test('emitted-text verification catches loss hidden by unchanged canonical data and computes the lower bound', async () => { + await withTempDir('visible-lines', async root => { + const files = readFixture(root, READ_CASES.find(item => item.primary)) + const rendered = files.map(file => '=== FILE: ' + JSON.stringify(file.path) + ' (Lines 1-50 of 70) ===\n' + file.expected.map(row => row.line + '|' + row.text).join('\n')).join('\n') + const run = { committed: [{ text: rendered }] } + verifyEmittedReads(run, files, 'candidate') + assert.throws(() => verifyEmittedReads({ committed: [{text: rendered.replace('1|export const value1 = 1000;', '')}] }, files, 'candidate'), /all emitted/) + const facts = lowerBound(files, {baseline:{outputBytes:8883},official:{outputBytes:5457}}) + assert.equal(facts.sourceUtf8Bytes, 4320) + assert.equal(facts.comparisons.official.sourceAloneExceedsTarget, true) + assert.equal(facts.comparisons.baseline.sourceAloneExceedsTarget, false) + assert.throws(() => { READ_CASES[0].limit = 1 }, TypeError) + }) +}) +await test('human-readable reports retain failed and blocked gates and the limited historical probe', () => { + const text = formatReport({platform:'win32',arch:'x64',hostVersion:'0.2.0-rc.2',node:'v24',passed:false,fullAcceptance:false,gates:[{id:'BM-03',status:'failed'},{id:'BM-04',status:'blocked',reason:'PDF unavailable'}]}) + assert.match(text, /BM-03 \| failed/); assert.match(text, /BM-04 \| blocked/) + assert.match(text, /All five gates accepted: false/); assert.match(text, /hostAuthorityExercised=false/) +}) +report('bench-gates') From fc2698f72a45e744fd16ebfcdfea7399bfb03ba3 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:13:24 +0800 Subject: [PATCH 095/118] test(bench): measure the installed optional Bash contract --- .github/workflows/benchmark-gates.yml | 10 ++++++++++ bench/gates/host.mjs | 6 ++++-- bench/gates/index.mjs | 1 + bench/gates/tasks.mjs | 6 ++++-- docs/benchmark-gates.md | 2 +- ...345\237\272\345\207\206\351\227\250\347\246\201.md" | 2 ++ 6 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/benchmark-gates.yml b/.github/workflows/benchmark-gates.yml index cf7fa379..753c75e2 100644 --- a/.github/workflows/benchmark-gates.yml +++ b/.github/workflows/benchmark-gates.yml @@ -30,6 +30,16 @@ jobs: run: npm ci --ignore-scripts - name: Build candidate host run: npm run build + - name: Acquire optional pinned Windows Bash fixture + if: runner.os == 'Windows' + shell: pwsh + run: | + $env:DSH_HOME = Join-Path $env:RUNNER_TEMP 'ops-benchmark-bash' + node bin/dsh-ops.mjs bash-runtime install + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $fixture = node --input-type=module -e "import {pinnedBashExecutable} from './lib/shells/pinned.js'; console.log(pinnedBashExecutable())" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + "DSH_OPS_TEST_BASH=$fixture" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Build candidate worker run: npm run build:ptc - name: Materialize exact 0.3.1 source blobs diff --git a/bench/gates/host.mjs b/bench/gates/host.mjs index ea859aae..e6c06059 100644 --- a/bench/gates/host.mjs +++ b/bench/gates/host.mjs @@ -18,11 +18,13 @@ import * as OfficialTools from '@deepseek-ai/dsh-tool-fs' import * as shell from '../../lib/components/shell/index.js' import * as file from '../../lib/components/file/index.js' import * as discovery from '../../lib/components/discovery/index.js' +import { pinnedBashExecutable } from '../../lib/shells/pinned.js' let identity = 0 const textOf = content => content.filter(block => block.type === 'text').map(block => block.text).join('\n') /** Real AgentLoop, Session, tool dispatch and policy; only the model response is scripted. */ export async function boot({ root, worker, baselineSource, projection = 'candidate', alwaysLoaded = [], withShell = false }) { + const shellFixture = withShell && process.env.DSH_OPS_TEST_BASH ? pinnedBashExecutable({ file: process.env.DSH_OPS_TEST_BASH }) : undefined const ctx = new Context(); ctx.logger.level = 0 const queues = new Map(), requests = [], results = [], events = [], errors = [] for (const plugin of [Agents, Sessions, Projections, Prompt]) await ctx.plugin(plugin, {}) @@ -61,7 +63,7 @@ export async function boot({ root, worker, baselineSource, projection = 'candida } }) } else { await ctx.plugin(file, { binaryPath: worker, required: true }) - if (withShell) await ctx.plugin(shell, {}) + if (withShell) await ctx.plugin(shell, shellFixture ? { bashPath: shellFixture } : {}) await ctx.plugin(discovery, { binaryPath: worker, required: true, alwaysLoaded }) } await ctx.plugin(Loop, { agents: [], maxParallelToolCalls: 3 }) @@ -83,5 +85,5 @@ export async function boot({ root, worker, baselineSource, projection = 'candida return { providerRequests: calls.length, toolBearingRounds: calls.filter(value => value.toolCalls > 0).length, requests: calls, results: results.slice(start.results), committed, outputBytes: committed.reduce((sum, value) => sum + Buffer.byteLength(value.text), 0) } } - return { ctx, agent, run, close: () => ctx.fiber.dispose() } + return { ctx, agent, run, shellFixture, close: () => ctx.fiber.dispose() } } diff --git a/bench/gates/index.mjs b/bench/gates/index.mjs index 341f96ee..62ce9b9e 100644 --- a/bench/gates/index.mjs +++ b/bench/gates/index.mjs @@ -41,6 +41,7 @@ export async function main(argv, io = { out: console.log, err: console.error }) const report = { format: 'ops-040-gates-v1', createdAt: new Date().toISOString(), platform: process.platform, arch: process.arch, node: process.version, hostVersion: '0.2.0-rc.2', model: 'scripted, no network or paid model calls', temporary, candidateRevision: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(), + candidateWorktreeStatus: execFileSync('git', ['status', '--short'], { cwd: root, encoding: 'utf8' }).trim(), fixtureSourceSha256: createHash('sha256').update(fs.readFileSync(new URL('./fixtures.mjs', import.meta.url))).digest('hex'), candidateWorker: { path: options.worker, sha256: createHash('sha256').update(fs.readFileSync(options.worker)).digest('hex') }, gates: [] } const tasks = { 'BM-01': bm01, 'BM-02': bm02, 'BM-03': bm03, 'BM-04': bm04, 'BM-05': bm05 } diff --git a/bench/gates/tasks.mjs b/bench/gates/tasks.mjs index c1b6f64e..a7bb9b4e 100644 --- a/bench/gates/tasks.mjs +++ b/bench/gates/tasks.mjs @@ -70,11 +70,13 @@ export async function bm03(options) { try { const run = await host.run(await host.agent(), [[]]), request = run.requests[0] const measurement = schemaMeasurement(request.tools), names = request.tools.map(tool => tool.name) - const shellReady = process.platform !== 'win32' || names.includes(publicToolName('bash')) + const bash = request.tools.find(tool => tool.name === publicToolName('bash')) + const shellReady = process.platform !== 'win32' || Boolean(bash?.parameters?.required?.includes('description') + && bash.parameters.properties?.run_in_background?.type === 'boolean') const messages = JSON.stringify(request.messages) return { id: 'BM-03', status: !shellReady ? 'blocked' : measurement.tokens <= 400 ? 'passed' : 'failed', criterion: 'Full default Native added schema <=400 tokens; Windows includes actual default Bash registration.', - pendingBudgetScopeDecision: true, shellReady, platform: process.platform, names, measurement, + pendingBudgetScopeDecision: true, shellReady, shellFixture: host.shellFixture ?? null, platform: process.platform, names, measurement, wholeStartupMessages: { tokens: getEncoding('cl100k_base').encode(messages).length, bytes: Buffer.byteLength(messages), json: messages }, methodology: 'js-tiktoken@1.0.21 cl100k_base over compact host name/description/parameters JSON, actual provider order; not provider billing.' } } finally { await host.close() } diff --git a/docs/benchmark-gates.md b/docs/benchmark-gates.md index 5434990e..d73cd457 100644 --- a/docs/benchmark-gates.md +++ b/docs/benchmark-gates.md @@ -13,7 +13,7 @@ cargo build --release --locked --manifest-path baseline-031/native/Cargo.toml node bench/cli.mjs gates --baseline-source baseline-031 --baseline-worker baseline-031/native/target/release/dsh-ops-worker --output benchmark-gates.json ``` -On Windows append `.exe` to the baseline worker. Use a new output filename for each run. The command refuses to overwrite either JSON evidence or its `.md` summary. It retains its exclusively owned temporary fixture directory for inspection. `--task BM-02` selects one gate; selected success never means that all five passed. Any failed or unavailable selected gate exits 1; malformed arguments or missing build inputs exit 2. +On Windows append `.exe` to the baseline worker. To measure the complete Bash surface, first install an optional fixture into a dedicated temporary `DSH_HOME` with `node bin/dsh-ops.mjs bash-runtime install`, then set `DSH_OPS_TEST_BASH` to its validated absolute executable (the release gate uses the same fixture API). The benchmark records this explicit selection; it changes no schema options. A fresh home without an installed Bash correctly blocks BM-03 instead of counting only the file schemas. The manual workflow acquires this fixture explicitly. Use a new output filename for each run. The command refuses to overwrite either JSON evidence or its `.md` summary. It retains its exclusively owned temporary fixture directory for inspection. `--task BM-02` selects one gate; selected success never means that all five passed. Any failed or unavailable selected gate exits 1; malformed arguments or missing build inputs exit 2. The manual `Opt-in OPS acceptance benchmarks` workflow runs the same command on Windows x64, Linux x64 and Apple Silicon, independently builds the baseline, and uploads both artifacts even when a gate fails. It changes no default CI requirement and uses no model credentials. diff --git "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" index d00aedb4..ff2dedea 100644 --- "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" +++ "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" @@ -38,3 +38,5 @@ BM-01 始终保留偏移 13/31/51、每文件 8 行的案例,以及 1 行、32 随后真实 AgentLoop 的主案例得到 OPS 0.3.1 为 8,883、官方读取器 5,457、候选 6,239 字节。使用同一规范结果重放紧凑渲染器后为 5,348 字节,相比 0.3.1 下降 39.795%,仍未达到 40%;最终集成后的完整宿主重跑仍然必需。 主案例仅原始行内容就有 4,320 UTF-8 字节,而比官方 5,457 字节少 40% 要求不超过 3,274.2 字节,因此即使不计路径、版本与覆盖信息,也无法在不省略或压缩原文的前提下通过。报告列出这一数值下界及示例绝对路径/范围标签成本;共享父目录可缩短后者。长行同样使通用百分比节省不成立。路径、宿主契约和渲染版本会影响字节,所以保留全部准确输入与回执。 + +Windows 完整 Bash schema 测量需显式夹具:在独立临时 `DSH_HOME` 运行 `node bin/dsh-ops.mjs bash-runtime install`,将 `DSH_OPS_TEST_BASH` 指向经验证的绝对可执行路径。基准记录该选择,不修改 schema 选项;全新目录未安装 Bash 时,BM-03 明确阻塞,不以文件工具的较小计数冒充通过。手动工作流会显式获取该固定夹具。 From bd39773609c5e4d8be16d3935aec9136e26a447d Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:15:21 +0800 Subject: [PATCH 096/118] docs(bench): record unresolved integrated acceptance gates --- docs/benchmark-gates.md | 18 +++++++++++++++++- ...272\345\207\206\351\227\250\347\246\201.md" | 16 ++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/benchmark-gates.md b/docs/benchmark-gates.md index d73cd457..ea6533c0 100644 --- a/docs/benchmark-gates.md +++ b/docs/benchmark-gates.md @@ -35,6 +35,22 @@ The 0.3.1 baseline is commit `4b51b6333213c376c8b7c173c12f35b408fdec2d`. The pre The earlier worker/renderer-only 3×8 probe recorded 2,256 baseline and 1,949 candidate bytes (13.608% reduction), with `modelRounds: null` and `hostAuthorityExercised: false`. This remains limited historical evidence. -The subsequent real AgentLoop primary case recorded 8,883 OPS 0.3.1 bytes, 5,457 official-reader bytes and 6,239 candidate bytes. Replaying those unchanged canonical receipts through the compact renderer gives 5,348 bytes: 39.795% below 0.3.1, still below 40%. The final integrated host rerun remains required. +The subsequent real AgentLoop primary case recorded 8,883 OPS 0.3.1 bytes, 5,457 official-reader bytes and 6,239 candidate bytes. Replaying those unchanged canonical receipts through the compact renderer gives 5,348 bytes: 39.795% below 0.3.1, still below 40%. The final integrated host rerun confirms those same byte counts and still fails the primary target. The primary source lines alone occupy 4,320 UTF-8 bytes. Forty percent below the 5,457-byte official result requires at most 3,274.2 bytes, so that comparison cannot pass without source omission or compression even before path/revision/coverage labels. The report includes this numerical lower bound and an illustrative absolute-path/range label size; shared-base layouts can reduce the latter. Long source lines likewise prevent universal percentage savings. Output paths, host contracts and formatter revisions affect measured bytes, so all exact receipts remain attached. + +## Integrated Windows result (2026-10-19) + +Candidate `02577ed140c16e1739bf2588172ee38d9370ec52` had a clean worktree, host `0.2.0-rc.2`, Node `25.2.1`, and the source-built worker SHA-256 `b29b8f130e109ec507d808b8a488d9461b08f28353222b41b40631cd062702b8`. The local worker used Cargo `1.99.0`; the manual CI workflow pins `1.97.1` and has not yet supplied cross-platform results. + +| Gate | Result | +| --- | --- | +| BM-01 | Failed: 5,348 candidate / 8,883 OPS 0.3.1 / 5,457 official bytes; 39.795% and 1.997% reductions. Exact source lines retained. | +| BM-02 | Passed: five edits in one tool-bearing round; both stale-CAS cases rejected with exact file bytes preserved. | +| BM-03 | Failed: actual published order 660 tokens / 3,219 bytes with explicit validated optional Bash and real host jobs. The separately ordered schema fixture counts 661 tokens; byte totals agree. | +| BM-04 | Passed: PDF absent initially, available in provider request two after discovery, absent for the sibling, text verified. | +| BM-05 | Passed: credential reads and outside writes rejected, files unchanged, no denied parent-path or secret-body disclosure. | + +The candidate uses one tool-bearing round plus the final response. Sequential comparators use three plus final; parallel comparator controls also use one plus final. The 8-line/1-line/32-line/long-line/Unicode sensitivities save 47.843%/55.768%/40.990%/7.560%/40.358% against 0.3.1; no universal percentage claim follows. A prior fresh-home run without optional Bash reported BM-03 blocked, never passed at 241 tokens. + +The retained raw local artifact is `040-benchmark-final-pinned-bash.json` with SHA-256 `cc94fcb4f444a39858795ef9003553de54735eff128191f3037a21666db1de98`; its companion Markdown summarizes all gates. Full acceptance remains false; the baseline and budget-scope decisions remain unresolved. diff --git "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" index ff2dedea..9a125578 100644 --- "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" +++ "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" @@ -40,3 +40,19 @@ BM-01 始终保留偏移 13/31/51、每文件 8 行的案例,以及 1 行、32 主案例仅原始行内容就有 4,320 UTF-8 字节,而比官方 5,457 字节少 40% 要求不超过 3,274.2 字节,因此即使不计路径、版本与覆盖信息,也无法在不省略或压缩原文的前提下通过。报告列出这一数值下界及示例绝对路径/范围标签成本;共享父目录可缩短后者。长行同样使通用百分比节省不成立。路径、宿主契约和渲染版本会影响字节,所以保留全部准确输入与回执。 Windows 完整 Bash schema 测量需显式夹具:在独立临时 `DSH_HOME` 运行 `node bin/dsh-ops.mjs bash-runtime install`,将 `DSH_OPS_TEST_BASH` 指向经验证的绝对可执行路径。基准记录该选择,不修改 schema 选项;全新目录未安装 Bash 时,BM-03 明确阻塞,不以文件工具的较小计数冒充通过。手动工作流会显式获取该固定夹具。 + +## Windows 集成结果(2026-10-19) + +候选提交 `02577ed140c16e1739bf2588172ee38d9370ec52` 工作区干净;宿主 `0.2.0-rc.2`、Node `25.2.1`。本地 worker 使用 Cargo `1.99.0`,手动 CI 固定 `1.97.1`,尚无本次跨平台结果。 + +| 门禁 | 结果 | +| --- | --- | +| BM-01 | 失败:候选 5348、0.3.1 为 8883、官方为 5457 字节;分别减少 39.795% 和 1.997%,准确正文完整保留。 | +| BM-02 | 通过:一次工具轮次完成五处编辑,两次过期 CAS 均拒绝且文件字节不变。 | +| BM-03 | 失败:显式验证的可选 Bash 与真实宿主 jobs 注册后,实际 provider 顺序为 660 token / 3219 字节;另一排序的 schema 夹具为 661 token,字节数一致。 | +| BM-04 | 通过:PDF 初始不可见,发现后第二次 provider 请求可用,兄弟代理不可见,文本正确。 | +| BM-05 | 通过:凭据读取和越界写入拒绝,无文件副作用,无被拒父目录或秘密正文泄露。 | + +候选为一个工具轮次加最终答复;顺序基线为三个工具轮次加最终答复,并行对照同样为一个加最终答复。8/1/32 行、长行、Unicode 敏感性相对 0.3.1 节约 47.843%/55.768%/40.990%/7.560%/40.358%,不构成普遍百分比保证。此前未安装可选 Bash 的全新目录明确报告 BM-03 阻塞,不将 241 token 视为通过。 + +本地保留原始 `040-benchmark-final-pinned-bash.json`,SHA-256 为 `cc94fcb4f444a39858795ef9003553de54735eff128191f3037a21666db1de98`,并有对应 Markdown。完整验收仍为 false;基线与预算范围决策尚未解决。 From 00521c61e6d61fd992d43339c6aadf91976ee1df Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:47:14 +0800 Subject: [PATCH 097/118] fix(native): preserve literal source escape sequences --- docs/CHANGELOG.md | 5 +++ docs/native-ranges.md | 2 +- ...07\344\273\266\350\214\203\345\233\264.md" | 2 +- src/modes/native/line-encoding.ts | 9 ++++++ src/modes/native/ranges.ts | 5 ++- src/modes/native/render.ts | 7 +++-- test/native-line-encoding.test.mjs | 31 +++++++++++++++++++ 7 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 src/modes/native/line-encoding.ts create mode 100644 test/native-line-encoding.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f1c84bf3..1a623ed1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -28,6 +28,11 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Migrate credential fences, trusted path anchors and permission failures to strict TypeScript without changing emitted entrypoints or authority decisions. Preserve worker effect facts during denial normalization and assert typed host modes/unknown-input narrowing. - 将凭据围栏、可信路径锚点和权限错误迁移为严格 TypeScript,保持生成入口和权限决策不变;拒绝归一化保留 worker 效果事实,并验证宿主模式及未知输入的类型缩窄。 +### Preserve literal backslashes in Native source lines (#261) + +- Share a reversible source-line encoder across compact and general Native output so literal backslash sequences cannot be confused with tabs, control characters or Unicode separators. Canonical data and ordinary ASCII line output remain unchanged. +- 紧凑与通用 Native 正文共用可逆行编码,区分字面反斜杠序列、制表符、控制字符和 Unicode 分隔符;规范数据和普通 ASCII 行输出不变。 + ### Add opt-in real-host acceptance evidence (#261, 16/16) - Run BM-01–05 with a scripted provider, exact0.3.1 and pinned official comparators, immutable sensitivity fixtures, visible-content assertions, numerical lossless lower bounds and JSON/Markdown reports. Manual cross-platform CI uploads failed and blocked evidence; no paid model calls or default gate changes. diff --git a/docs/native-ranges.md b/docs/native-ranges.md index de0c483e..1b0e1988 100644 --- a/docs/native-ranges.md +++ b/docs/native-ranges.md @@ -12,7 +12,7 @@ A fully returned `ops_read_many` range uses one path header, exact numbered text [Notice: revision=cfd1bc81432fd6b88748b827f46bb4242db17cf8655cf4ea90f3ba0e62065355; more lines; nextOffset=3.] ``` -The characters after `N|` are the source line, including leading spaces. Control characters and Unicode line separators are escaped so source text cannot forge extra numbered rows or file headers. LF/CRLF accounting remains the worker's physical-line accounting. +The characters after `N|` are the source line, including leading spaces. Line bodies use reversible JSON escapes: backslashes are doubled, controls and Unicode line separators are escaped, and quotes remain literal. For example, an actual tab appears as `\t`, while the two source characters backslash-plus-t appear as `\\t`. This shared encoding applies to compact ranges and ordinary numbered source output, and prevents source text from forging extra rows or headers. LF/CRLF accounting remains the worker's physical-line accounting. “More lines” means the file has not been exhausted; use the stated `nextOffset`. “End of file; complete=true” means this range reached the end. The aggregate “requested ranges returned” receipt means every requested range body was returned, not that every whole file was read. Full revisions, total line counts and per-file continuation remain visible. Byte sizes and duplicate counters remain available in canonical/PTC data. diff --git "a/docs/\347\264\247\345\207\221\346\226\207\344\273\266\350\214\203\345\233\264.md" "b/docs/\347\264\247\345\207\221\346\226\207\344\273\266\350\214\203\345\233\264.md" index a85c2086..2ccbfddc 100644 --- "a/docs/\347\264\247\345\207\221\346\226\207\344\273\266\350\214\203\345\233\264.md" +++ "b/docs/\347\264\247\345\207\221\346\226\207\344\273\266\350\214\203\345\233\264.md" @@ -12,7 +12,7 @@ English: [Compact Native file ranges](native-ranges.md). [Notice: revision=cfd1bc81432fd6b88748b827f46bb4242db17cf8655cf4ea90f3ba0e62065355; more lines; nextOffset=3.] ``` -`N|` 后紧跟原始行内容,包含原有缩进。控制字符和 Unicode 换行分隔符会被转义,防止源文本伪造额外行号或文件标题;LF/CRLF 物理行规则与 worker 保持一致。 +`N|` 后紧跟原始行内容,包含原有缩进。正文使用可逆 JSON 转义:反斜杠写成双反斜杠,控制字符及 Unicode 换行分隔符被转义,引号保持原样。例如真实制表符显示为 `\t`,而反斜杠加字母 t 的两个源字符显示为 `\\t`。紧凑范围和普通行号正文共用此编码,防止伪造额外行号或文件标题;LF/CRLF 物理行规则与 worker 保持一致。 `more lines` 表示文件尚未读到末尾,按 `nextOffset` 继续;`end of file; complete=true` 表示本范围已经到达文件末尾。汇总的 `requested ranges returned` 仅证明所请求的范围正文全部返回,不代表读取了所有完整文件。完整版本散列、总行数和每个文件的续读位置继续保留;字节大小及重复计数仍在规范数据和 PTC 返回值中。 diff --git a/src/modes/native/line-encoding.ts b/src/modes/native/line-encoding.ts new file mode 100644 index 00000000..10d9c0ae --- /dev/null +++ b/src/modes/native/line-encoding.ts @@ -0,0 +1,9 @@ +/** One physical source line, with reversible JSON escapes for backslashes and controls. + * Quotes stay literal because the line is not enclosed in a JSON string. */ +export function encodeSourceLine(value: string): string { + return value.replace(/[\\\r\n\t\x00-\x1f\x7f\u0085\u2028\u2029]/g, char => { + if (char === '\\') return '\\\\' + if (/[\x7f\u0085\u2028\u2029]/.test(char)) return `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}` + return JSON.stringify(char).slice(1, -1) + }) +} diff --git a/src/modes/native/ranges.ts b/src/modes/native/ranges.ts index b842e70b..ba04e39c 100644 --- a/src/modes/native/ranges.ts +++ b/src/modes/native/ranges.ts @@ -1,10 +1,9 @@ import path from 'node:path' +import { encodeSourceLine } from './line-encoding.js' type Row = Record const record = (value: unknown): value is Row => value !== null && typeof value === 'object' && !Array.isArray(value) const integer = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 const quoted = (value: string): string => JSON.stringify(value).replace(/[\u0085\u2028\u2029]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`) -const content = (value: string): string => value.replace(/[\r\n\t\x00-\x1f\x7f\u0085\u2028\u2029]/g, - char => /[\u0085\u2028\u2029]/.test(char) ? `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}` : JSON.stringify(char).slice(1, -1)) interface Range { path: string; revision: string; total: number; next: number; complete: boolean; lines: { line: number; text: string }[] } /** Compact only a fully evidenced requested range. Physical gaps keep the general diagnostic renderer. */ function range(input: unknown, value: unknown): Range | undefined { @@ -43,7 +42,7 @@ export function compactRanges(input: Row, value: Row): string[] | undefined { for (const item of observed) { const span = item.lines.length ? `${item.lines[0]!.line}-${item.lines.at(-1)!.line}` : 'none' output.push(`=== FILE: ${quoted(base ? path.basename(item.path) : item.path)} (Lines ${span} of ${item.total}) ===`) - for (const line of item.lines) output.push(`${line.line}|${content(line.text)}`) + for (const line of item.lines) output.push(`${line.line}|${encodeSourceLine(line.text)}`) if (!item.lines.length) output.push(item.total === 0 ? '(empty file)' : '(no lines in this range)') output.push(`[Notice: revision=${item.revision}; ${item.complete ? 'end of file; complete=true' : `more lines; nextOffset=${item.next}`}.]`) } diff --git a/src/modes/native/render.ts b/src/modes/native/render.ts index f2d6ce3a..20b5ef93 100644 --- a/src/modes/native/render.ts +++ b/src/modes/native/render.ts @@ -1,7 +1,8 @@ import { Buffer } from 'node:buffer' +import { compactRanges } from './ranges.js' +import { encodeSourceLine } from './line-encoding.js' import { renderDocument } from './documents.js' import { isDocumentTool } from '../../capabilities/documents.js' -import { compactRanges } from './ranges.js' import { renderResult } from './result.js' import { errorPolicy } from '../../kernel/errors.js' import type { EffectState } from '../../kernel/outcome.js' @@ -13,8 +14,8 @@ const record = (value: unknown): value is Row => value !== null && typeof value const rows = (value: unknown): Row[] => Array.isArray(value) ? value.filter(record) : [] const count = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 const label = (value: unknown): string => String(value ?? '').replace(/[\r\n\t\x00-\x1f\x7f]/g, char => JSON.stringify(char).slice(1, -1)) -// Keep physical line labels intact in Markdown, including bare CR and Unicode separators. -const contentLine = (value: unknown): string => label(value).replace(/[\u0085\u2028\u2029]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`) +// Keep physical line labels intact without confusing literal backslashes with escapes. +const contentLine = (value: unknown): string => encodeSourceLine(String(value ?? '')) const field = (value: unknown): string => typeof value === 'string' ? label(value) : JSON.stringify(value) const encodedBytes = (text: string): number => Buffer.byteLength(JSON.stringify([{ type: 'text', text }]), 'utf8') diff --git a/test/native-line-encoding.test.mjs b/test/native-line-encoding.test.mjs new file mode 100644 index 00000000..49433d6f --- /dev/null +++ b/test/native-line-encoding.test.mjs @@ -0,0 +1,31 @@ +import { encodeSourceLine } from '../lib/modes/native/line-encoding.js' +import { renderNative } from '../lib/modes/native/render.js' +import { assert, report, test } from './lib/harness.mjs' +import path from 'node:path' +// Use the independent platform JSON decoder, not a copy of the source encoder. +const decode = line => JSON.parse('"' + line.replaceAll('"', '\\"') + '"') +const pairs = [['\t', '\\t'], ['\r', '\\r'], ['\n', '\\n'], ['\0', '\\u0000'], ['\x7f', '\\u007f'], ['\u0085', '\\u0085'], ['\u2028', '\\u2028'], ['\u2029', '\\u2029']] +const samples = [...pairs.flat(), String.fromCharCode(...Array.from({length:128},(_,i)=>i)), '\\"路径\\😀"', ' ordinary source '] +await test('source line escapes are injective for controls and literal backslash sequences', () => { + for (const [control, literal] of pairs) assert.notEqual(encodeSourceLine(control), encodeSourceLine(literal)) + for (const source of samples) { + const encoded = encodeSourceLine(source) + assert.equal(decode(encoded), source) + assert.doesNotMatch(encoded, /[\r\n\t\x00-\x1f\x7f\u0085\u2028\u2029]/) + } +}) +await test('compact and general Native readers retain independently decodable exact source without changing canonical data', () => { + const file = path.resolve('source.txt') + for (const source of samples) { + const value = {status:'completed',path:file,revision:'a'.repeat(64),lineCount:1,nextOffset:2,complete:true,truncated:false,failed:0,items:[{path:file,line:1,text:source}]} + const batch = {status:'completed',total:1,nextIndex:1,pending:0,failed:0,truncated:false,items:[value]} + const before = JSON.stringify(batch) + for(const text of [renderNative('inspect_local_file',{path:file,limit:1},value),renderNative('read_many',{files:[{path:file,limit:1}]},batch)]) { + const body = text.split('\n').filter(line=>/^1(?: \| |\|)/.test(line)) + assert.equal(body.length,1) + assert.equal(decode(body[0].replace(/^1(?: \| |\|)/,'')),source) + } + assert.equal(JSON.stringify(batch),before) + } +}) +report('native-line-encoding') From 51a16999177e45991f40a12e5dd3e016a9a55337 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:47:30 +0800 Subject: [PATCH 098/118] test(bench): reject missing startup tools and invalid controls --- bench/gates/integrity.mjs | 29 ++++++++++++ bench/gates/metrics.mjs | 7 +-- bench/gates/tasks.mjs | 14 +++--- docs/CHANGELOG.md | 5 +++ docs/benchmark-gates.md | 5 +++ ...72\345\207\206\351\227\250\347\246\201.md" | 5 +++ test/bench-integrity.test.mjs | 45 +++++++++++++++++++ 7 files changed, 100 insertions(+), 10 deletions(-) create mode 100644 bench/gates/integrity.mjs create mode 100644 test/bench-integrity.test.mjs diff --git a/bench/gates/integrity.mjs b/bench/gates/integrity.mjs new file mode 100644 index 00000000..3ddfea80 --- /dev/null +++ b/bench/gates/integrity.mjs @@ -0,0 +1,29 @@ +/** Assertions about the workload's availability and controls, independent of its byte/token score. */ +import { NATIVE_RESIDENT_FILE_TOOLS } from '../../lib/capabilities/registry.js' +import { publicToolName } from '../../lib/policy.js' +export function startupReadiness(tools, platform = process.platform) { + const expected = [...NATIVE_RESIDENT_FILE_TOOLS, 'discover', ...(platform === 'win32' ? ['bash'] : [])].map(publicToolName).sort() + const names = tools.map(tool => tool.name), actual = new Set(names) + const missing = expected.filter(name => !actual.has(name)), unexpected = names.filter(name => !expected.includes(name)) + const duplicates = names.filter((name, index) => names.indexOf(name) !== index) + const bash = tools.find(tool => tool.name === publicToolName('bash')) + const shellReady = platform !== 'win32' || Boolean(bash?.parameters?.required?.includes('description') + && bash.parameters.properties?.run_in_background?.type === 'boolean') + const reasons = [] + if (missing.length) reasons.push('missing required tools: ' + missing.join(', ')) + if (unexpected.length) reasons.push('unexpected startup tools: ' + unexpected.join(', ')) + if (duplicates.length) reasons.push('duplicate startup tools: ' + duplicates.join(', ')) + if (!shellReady) reasons.push('the full host-compatible Bash contract is unavailable') + return { ready: !reasons.length, expected, missing, unexpected, duplicates, shellReady, reason: reasons.join('; ') || undefined } +} +export function readControlPassed(value, file, expectedText) { + if (value?.status !== 'completed' || value.failed !== 0 || value.pending !== 0 || value.truncated !== false + || !Array.isArray(value.items) || value.items.length !== 1) return false + const item = value.items[0] + return item?.status === 'completed' && item.path === file && item.complete === true && item.truncated === false + && item.failed === 0 && item.items?.length === 1 && item.items[0].path === file + && item.items[0].line === 1 && item.items[0].text === expectedText +} +export function rejectedWithoutEffects(value, code) { + return value?.status === 'rejected' && value.error?.code === code && value.error?.effectState === 'none' +} diff --git a/bench/gates/metrics.mjs b/bench/gates/metrics.mjs index c4f0fb75..b4db9345 100644 --- a/bench/gates/metrics.mjs +++ b/bench/gates/metrics.mjs @@ -33,7 +33,8 @@ export function lowerBound(files, rows) { comparisons: Object.fromEntries(['baseline', 'official'].map(name => [name, { targetBytes: rows[name].outputBytes * .6, sourceAloneExceedsTarget: sourceUtf8Bytes > rows[name].outputBytes * .6, maximumSourceOnlySavings: 1 - sourceUtf8Bytes / rows[name].outputBytes }])) } } -const escapedLine = text => text.replace(/[\r\n\t\x00-\x1f\x7f\u0085\u2028\u2029]/g, char => /[\u0085\u2028\u2029]/.test(char) ? '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0') : JSON.stringify(char).slice(1,-1)) +// Decode the documented representation with the platform JSON parser; do not mirror its encoder. +const decodeLine = text => JSON.parse('"' + text.replaceAll('"', '\\"') + '"') /** Measure the actual model-visible text only after every source line is verified there too. */ export function verifyEmittedReads(run, files, projection) { if (projection === 'baseline') { @@ -56,8 +57,8 @@ export function verifyEmittedReads(run, files, projection) { const header = /^=== FILE: (.+) \(Lines .*\) ===$/.exec(line) if (header) { const name = header[1].startsWith('"') ? JSON.parse(header[1]) : header[1]; current = path.isAbsolute(name) ? name : path.join(JSON.parse(base[1]), name); assert.equal(groups.has(current), false); groups.set(current, []); continue } const row = /^(\d+)(?: \| |\|)(.*)$/.exec(line) - if (row) { assert.ok(current); groups.get(current).push({line:Number(row[1]),text:row[2]}) } + if (row) { assert.ok(current); groups.get(current).push({line:Number(row[1]),text:decodeLine(row[2])}) } } assert.equal(groups.size, files.length) - for (const file of files) assert.deepEqual(groups.get(file.path), file.expected.map(({line,text})=>({line,text:escapedLine(text)})), 'all emitted requested lines must survive before bytes are compared') + for (const file of files) assert.deepEqual(groups.get(file.path), file.expected, 'all emitted requested lines must survive before bytes are compared') } diff --git a/bench/gates/tasks.mjs b/bench/gates/tasks.mjs index a7bb9b4e..ae62d24a 100644 --- a/bench/gates/tasks.mjs +++ b/bench/gates/tasks.mjs @@ -5,6 +5,7 @@ import { getEncoding } from 'js-tiktoken' import { publicToolName } from '../../lib/policy.js' import { schemaMeasurement, verifyReads, verifyEmittedReads, lowerBound } from './metrics.mjs' import { boot } from './host.mjs' +import { startupReadiness, readControlPassed, rejectedWithoutEffects } from './integrity.mjs' import { READ_CASES, readFixture, fiveEdits, hash } from './fixtures.mjs' const calls = run => run.results.map(item => item.result) const raw = (name, args) => ({ name, args }) @@ -70,13 +71,11 @@ export async function bm03(options) { try { const run = await host.run(await host.agent(), [[]]), request = run.requests[0] const measurement = schemaMeasurement(request.tools), names = request.tools.map(tool => tool.name) - const bash = request.tools.find(tool => tool.name === publicToolName('bash')) - const shellReady = process.platform !== 'win32' || Boolean(bash?.parameters?.required?.includes('description') - && bash.parameters.properties?.run_in_background?.type === 'boolean') + const readiness = startupReadiness(request.tools) const messages = JSON.stringify(request.messages) - return { id: 'BM-03', status: !shellReady ? 'blocked' : measurement.tokens <= 400 ? 'passed' : 'failed', + return { id: 'BM-03', status: !readiness.ready ? 'blocked' : measurement.tokens <= 400 ? 'passed' : 'failed', criterion: 'Full default Native added schema <=400 tokens; Windows includes actual default Bash registration.', - pendingBudgetScopeDecision: true, shellReady, shellFixture: host.shellFixture ?? null, platform: process.platform, names, measurement, + pendingBudgetScopeDecision: true, readiness, reason: readiness.reason, shellReady: readiness.shellReady, shellFixture: host.shellFixture ?? null, platform: process.platform, names, measurement, wholeStartupMessages: { tokens: getEncoding('cl100k_base').encode(messages).length, bytes: Buffer.byteLength(messages), json: messages }, methodology: 'js-tiktoken@1.0.21 cl100k_base over compact host name/description/parameters JSON, actual provider order; not provider billing.' } } finally { await host.close() } @@ -96,8 +95,9 @@ export async function bm05(options) { const values = calls(read)[0].value.items, deniedRead = calls(denied)[0].value, deniedWrite = calls(write)[0].value const forbidden = [options.temporary, path.dirname(options.temporary), 'BENCHMARK_SECRET_BODY'] const denialText = text(write) + text(denied) - const gates = { writeRejected: deniedWrite.status === 'rejected' && deniedWrite.error?.code === 'POLICY_DENIED', - secretRejected: deniedRead?.status === 'rejected', unchanged: !fs.existsSync(target) && fs.readFileSync(secretPath, 'utf8') === 'BENCHMARK_SECRET_BODY', + const gates = { ordinaryReadSucceeded: readControlPassed(calls(read)[0].value, publicPath, 'outside ordinary data'), + writeRejected: rejectedWithoutEffects(deniedWrite, 'POLICY_DENIED'), + secretRejected: rejectedWithoutEffects(deniedRead, 'CREDENTIAL_DENIED'), unchanged: !fs.existsSync(target) && fs.readFileSync(secretPath, 'utf8') === 'BENCHMARK_SECRET_BODY', noParentPathLeak: forbidden.every(value => !denialText.includes(value)) } return { id: 'BM-05', status: Object.values(gates).every(Boolean) ? 'passed' : 'failed', gates, read, denied, write, enforcement: 'Real host SandboxPolicy and fixed-operation OPS bridge/worker; no arbitrary-process confinement claim.', diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1a623ed1..26f30467 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -28,6 +28,11 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Migrate credential fences, trusted path anchors and permission failures to strict TypeScript without changing emitted entrypoints or authority decisions. Preserve worker effect facts during denial normalization and assert typed host modes/unknown-input narrowing. - 将凭据围栏、可信路径锚点和权限错误迁移为严格 TypeScript,保持生成入口和权限决策不变;拒绝归一化保留 worker 效果事实,并验证宿主模式及未知输入的类型缩窄。 +### Require benchmark readiness and valid controls (#261, 16/16) + +- Block startup scoring when any default Native tool is missing, duplicated or unexpected. Require successful allowed-read controls and exact effect-free denial codes, and independently decode Native source text before comparing bytes. Keep all fixtures, defaults and acceptance thresholds unchanged. +- 默认 Native 工具缺失、重复或多出时阻塞启动评分;要求允许读取对照成功、拒绝代码与无副作用事实正确,并独立解码 Native 正文后再比较字节。不改变夹具、默认值及验收阈值。 + ### Preserve literal backslashes in Native source lines (#261) - Share a reversible source-line encoder across compact and general Native output so literal backslash sequences cannot be confused with tabs, control characters or Unicode separators. Canonical data and ordinary ASCII line output remain unchanged. diff --git a/docs/benchmark-gates.md b/docs/benchmark-gates.md index ea6533c0..d36cb99a 100644 --- a/docs/benchmark-gates.md +++ b/docs/benchmark-gates.md @@ -54,3 +54,8 @@ Candidate `02577ed140c16e1739bf2588172ee38d9370ec52` had a clean worktree, host The candidate uses one tool-bearing round plus the final response. Sequential comparators use three plus final; parallel comparator controls also use one plus final. The 8-line/1-line/32-line/long-line/Unicode sensitivities save 47.843%/55.768%/40.990%/7.560%/40.358% against 0.3.1; no universal percentage claim follows. A prior fresh-home run without optional Bash reported BM-03 blocked, never passed at 241 tokens. The retained raw local artifact is `040-benchmark-final-pinned-bash.json` with SHA-256 `cc94fcb4f444a39858795ef9003553de54735eff128191f3037a21666db1de98`; its companion Markdown summarizes all gates. Full acceptance remains false; the baseline and budget-scope decisions remain unresolved. + + +## Integrity checks after review + +BM-03 now requires the exact resident file catalogue plus discovery (and the full Bash contract on Windows) before a token score can pass. Missing, duplicate or unexpected tools block the gate with a named reason. BM-05 also requires its ordinary outside-read control to succeed with exact text, and checks `CREDENTIAL_DENIED` / `POLICY_DENIED` with `effectState: none` for the denied probes. Native line checks independently decode the documented escapes, including literal backslashes; they no longer mirror the renderer. These checks change no workload, default, budget or threshold. The result snapshot above remains historical evidence for its named commit; it is not reclassified as passing. diff --git "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" index 9a125578..460fd0e1 100644 --- "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" +++ "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" @@ -56,3 +56,8 @@ Windows 完整 Bash schema 测量需显式夹具:在独立临时 `DSH_HOME` 候选为一个工具轮次加最终答复;顺序基线为三个工具轮次加最终答复,并行对照同样为一个加最终答复。8/1/32 行、长行、Unicode 敏感性相对 0.3.1 节约 47.843%/55.768%/40.990%/7.560%/40.358%,不构成普遍百分比保证。此前未安装可选 Bash 的全新目录明确报告 BM-03 阻塞,不将 241 token 视为通过。 本地保留原始 `040-benchmark-final-pinned-bash.json`,SHA-256 为 `cc94fcb4f444a39858795ef9003553de54735eff128191f3037a21666db1de98`,并有对应 Markdown。完整验收仍为 false;基线与预算范围决策尚未解决。 + + +## 审查后的完整性断言 + +BM-03 在评估 token 分数前,要求完整且精确的常驻文件能力目录与发现入口(Windows 还要求完整 Bash 契约)。缺失、重复或意外工具均会阻塞并指出原因。BM-05 同时要求普通外部读取对照成功且正文完全一致,并验证拒绝探测的 `CREDENTIAL_DENIED` / `POLICY_DENIED` 及 `effectState: none`。Native 行检查使用独立解码器验证反斜杠与控制字符,不再复制渲染器逻辑。这些断言不改变工作负载、默认值、预算或阈值;上方快照仍是所列提交的历史证据,不会被改判为通过。 diff --git a/test/bench-integrity.test.mjs b/test/bench-integrity.test.mjs new file mode 100644 index 00000000..5d3ddd3d --- /dev/null +++ b/test/bench-integrity.test.mjs @@ -0,0 +1,45 @@ +/** Negative evidence must not pass a token or permission gate. */ +import path from 'node:path' +import { startupReadiness, readControlPassed, rejectedWithoutEffects } from '../bench/gates/integrity.mjs' +import { verifyEmittedReads } from '../bench/gates/metrics.mjs' +import { NATIVE_RESIDENT_FILE_TOOLS } from '../lib/capabilities/registry.js' +import { publicToolName } from '../lib/policy.js' +import { assert, report, test } from './lib/harness.mjs' +const core = [...NATIVE_RESIDENT_FILE_TOOLS, 'discover'].map(raw => ({name:publicToolName(raw)})) +await test('startup needs the exact default Native surface before any token score can pass', () => { + for(const platform of ['linux','darwin']) { + assert.equal(startupReadiness(core,platform).ready,true) + for(const absent of core) { + const result=startupReadiness(core.filter(tool=>tool!==absent),platform) + assert.equal(result.ready,false) + assert.deepEqual(result.missing,[absent.name]) + assert.match(result.reason,/missing required/) + } + assert.equal(startupReadiness([],platform).ready,false) + assert.equal(startupReadiness([...core,core[0]],platform).ready,false) + assert.equal(startupReadiness([...core,{name:publicToolName('stat')}],platform).ready,false) + } + const bash={name:publicToolName('bash'),parameters:{required:['description'],properties:{run_in_background:{type:'boolean'}}}} + assert.equal(startupReadiness([...core,bash],'win32').ready,true) + assert.equal(startupReadiness(core,'win32').ready,false) + assert.equal(startupReadiness([...core,{name:bash.name,parameters:{}}],'win32').ready,false) +}) +await test('authority gates require a successful allowed control and the exact effect-free denial codes', () => { + const file=path.resolve('ordinary.txt'),item={status:'completed',path:file,complete:true,truncated:false,failed:0,items:[{path:file,line:1,text:'outside ordinary data'}]} + const value={status:'completed',failed:0,pending:0,truncated:false,items:[item]} + assert.equal(readControlPassed(value,file,'outside ordinary data'),true) + for(const changed of [{...value,status:'rejected'},{...value,items:[]},{...value,items:[{...item,items:[{path:file,line:1,text:'wrong'}]}]}]) assert.equal(readControlPassed(changed,file,'outside ordinary data'),false) + for(const code of ['CREDENTIAL_DENIED','POLICY_DENIED']) { + assert.equal(rejectedWithoutEffects({status:'rejected',error:{code,effectState:'none'}},code),true) + assert.equal(rejectedWithoutEffects({status:'rejected',error:{code:'INVALID_ARGUMENT',effectState:'none'}},code),false) + assert.equal(rejectedWithoutEffects({status:'rejected',error:{code,effectState:'unknown'}},code),false) + } +}) +await test('emitted-line verification decodes source instead of accepting colliding escape text', () => { + const file=path.resolve('escapes.txt'),files=[{path:file,expected:[{line:1,text:'\t'},{line:2,text:'\\t'},{line:3,text:'a"b\\c'}]}] + const header='=== FILE: '+JSON.stringify(file)+' (Lines 1-3 of 3) ===\n' + const valid=header+'1|\\t\n2|\\\\t\n3|a"b\\\\c' + verifyEmittedReads({committed:[{text:valid}]},files,'candidate') + assert.throws(()=>verifyEmittedReads({committed:[{text:valid.replace('2|\\\\t','2|\\t')}]},files,'candidate'),/all emitted/) +}) +report('bench-integrity') From c6b48cfa9973cfe0a5c9e7da353d08d3f1e7a151 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:54:17 +0800 Subject: [PATCH 099/118] test(bench): run readiness regressions in manual CI --- .github/workflows/benchmark-gates.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/benchmark-gates.yml b/.github/workflows/benchmark-gates.yml index 753c75e2..96eac063 100644 --- a/.github/workflows/benchmark-gates.yml +++ b/.github/workflows/benchmark-gates.yml @@ -47,7 +47,7 @@ jobs: - name: Build independent 0.3.1 worker run: cargo build --release --locked --manifest-path baseline-031/native/Cargo.toml - name: Verify benchmark harness integrity - run: node test/run.mjs bench-gates + run: node test/run.mjs bench-gates bench-integrity - name: Run all five real-host acceptance gates run: node bench/cli.mjs gates --baseline-source baseline-031 --baseline-worker baseline-031/native/target/release/dsh-ops-worker${{ matrix.suffix }} --output benchmark-gates.json - name: Upload exact evidence even when a gate fails From 86f7b6cf949d4248265fdf7d237a751fe9597d11 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:57:04 +0800 Subject: [PATCH 100/118] test(bench): require exactly five acceptance receipts --- bench/gates/metrics.mjs | 2 +- test/bench-gates.test.mjs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bench/gates/metrics.mjs b/bench/gates/metrics.mjs index b4db9345..a135a575 100644 --- a/bench/gates/metrics.mjs +++ b/bench/gates/metrics.mjs @@ -23,7 +23,7 @@ export function verifyReads(run, files, projection) { } export const verdict = gates => gates.length > 0 && gates.every(gate => gate.status === 'passed') -export const fullAcceptance = gates => verdict(gates) && new Set(gates.map(gate => gate.id)).size === 5 && ['BM-01', 'BM-02', 'BM-03', 'BM-04', 'BM-05'].every(id => gates.some(gate => gate.id === id)) +export const fullAcceptance = gates => gates.length === 5 && verdict(gates) && new Set(gates.map(gate => gate.id)).size === 5 && ['BM-01', 'BM-02', 'BM-03', 'BM-04', 'BM-05'].every(id => gates.some(gate => gate.id === id)) export function lowerBound(files, rows) { const sourceUtf8Bytes = files.reduce((sum, file) => sum + Buffer.byteLength(file.expected.map(line => line.text).join('\n')), 0) diff --git a/test/bench-gates.test.mjs b/test/bench-gates.test.mjs index bab8dfa5..c04103c0 100644 --- a/test/bench-gates.test.mjs +++ b/test/bench-gates.test.mjs @@ -23,6 +23,8 @@ await test('partial, blocked, failed and duplicate gate sets never claim full ac assert.equal(verdict([]), false) assert.equal(fullAcceptance(complete.slice(0, 4)), false) assert.equal(fullAcceptance(Array(5).fill(complete[0])), false) + assert.equal(fullAcceptance([...complete, complete[0]]), false) + assert.equal(fullAcceptance([...complete, { id: 'BM-06', status: 'passed' }]), false) for (const status of ['blocked', 'failed', 'skipped', undefined]) assert.equal(fullAcceptance([...complete.slice(0, 4), { id: 'BM-05', status }]), false) }) await test('a rerun cannot overwrite existing evidence, and a missing worker fails before creating it', async () => { From 745aaf5fd83628f5483957a9f4615711386ec1bd Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:57:57 +0800 Subject: [PATCH 101/118] fix(documents): preserve coverage and default discovery pools --- cordis.patch.yml | 1 - docs/CHANGELOG.md | 4 ++++ docs/native-kernel.md | 7 ++++++- ...37\347\224\237\345\206\205\346\240\270.md" | 6 +++++- native/crates/ops-documents/src/lib.rs | 5 +++-- native/crates/ops-documents/src/tests.rs | 6 ++++-- test/platform-defaults.test.mjs | 19 +++++++++++++++++++ test/table-worker.test.mjs | 3 +++ 8 files changed, 44 insertions(+), 7 deletions(-) diff --git a/cordis.patch.yml b/cordis.patch.yml index aba166ca..0581791a 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -22,7 +22,6 @@ name: dsh-ops/ptc config: required: false - allowedTools: [grep, glob, replace, write_file, edit_file, diff, stat, copy, move, remove, read_many, edit_many, batch] alwaysLoaded: [] - id: dsh-ops-discovery name: dsh-ops/discovery diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f248e1f0..7d2669d8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,10 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Keep table coverage honest beyond EOF and after metadata changes. Fresh PTC bundle pools follow + the implemented catalogue; saved explicit operator pools remain unchanged. +- 表格超出 EOF 时只统计实际跳过的行,文件变化后不声明页面完整;新 PTC 允许池遵循实现目录,保留已保存的显式选择。 + ### Add bounded CSV/TSV worker observations (#261, 15a/16) - Add an ops-documents workspace crate with strict UTF-8/UTF-16 CSV/TSV decoding, positional diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 0e555b2a..b05cc74a 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -159,7 +159,8 @@ bounded decoder read-ahead), scanComplete and pageComplete separately. rowCount/ raw-byte SHA-256 revision appear only after a clean EOF with unchanged observed file metadata. Complete also requires no remaining rows after the requested page. A malformed suffix preserves preceding valid rows with status=partial/error and no invented total, even when the requested page -was already fully observed. Metadata changes invalidate the complete-scan claim. +was already fully observed. Metadata changes invalidate both scanComplete and pageComplete. +The skipped count includes only rows physically observed before the requested range, even beyond EOF. Hard limits are 32 MiB input, 100000 data records plus one bounded EOF lookahead, 1 MiB decoded record, 1024 columns, 8192 serialized header bytes and 30 seconds between records (the host/worker @@ -181,3 +182,7 @@ Malformed/changed input and physical scan limits retain prefix data and recovery continuation that would repeat the same gap. Native reference pages and PTC result pages preserve original table coverage separately from stored-row pagination. Shared admission, current authority, credential checks, cancellation and storage bindings apply unchanged. + +Fresh bundled PTC rows derive their allowed pool from the implemented catalogue; saved explicit +allowedTools arrays remain authoritative across refresh and restart. Document APIs remain unloaded +until requested through the owning scope. diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index c17331e4..cff251f0 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -128,7 +128,8 @@ offset 为不含标题的 1..100000 数据行,默认 1;limit 为 1..1000, 有界物理扫描,只保留所请求的行,并分别报告 rowsObserved、bytesRead(含有界预读)、 scanComplete 和 pageComplete。只有正常 EOF 且文件元数据观察未改变时,才返回 rowCount/total 和原始字节 SHA-256 revision。complete 还要求所请求页之后没有剩余行。即使目标页已观察完整, -非法后缀仍保留此前有效行、返回 status=partial/error,且不编造总数。元数据变化使完整扫描声明失效。 +非法后缀仍保留此前有效行、返回 status=partial/error,且不编造总数。元数据变化使 scanComplete 与 pageComplete 均失效。 +skipped 只统计请求范围之前实际观察到的行数;请求偏移超过 EOF 时也不虚增。 硬上限:32 MiB 输入、100000 条数据记录(另加一次有界 EOF 探测)、1 MiB 解码后记录、1024 列、 8192 字节序列化标题,以及记录间检查的 30 秒截止时间;宿主/worker 请求截止时间仍同时适用。 @@ -146,3 +147,6 @@ Native 用带物理 LF 行范围的原位置 JSON 单元格展示,并明确提 须比较 revision。非法输入、文件变化和物理扫描上限保留前缀数据与恢复提示,不提供会重复 原缺口的续取。Native/PTC 引用页均区分原表格覆盖率与已存记录分页;既有排队、实时权限、 凭据检查、取消与存储绑定继续适用。 + +新安装的 PTC bundle 行从已实现目录生成允许池;已保存的显式 allowedTools 数组在刷新和重启后 +仍然有效。文档 API 仍须由所属作用域请求后才加载。 diff --git a/native/crates/ops-documents/src/lib.rs b/native/crates/ops-documents/src/lib.rs index 713576a9..fc5108fe 100644 --- a/native/crates/ops-documents/src/lib.rs +++ b/native/crates/ops-documents/src/lib.rs @@ -99,14 +99,15 @@ fn observe(request: &Request, args: &Args, format: &str, encoding: Encoding, del } if let Some(error) = &mut failure { recovery(error); } let next = args.offset.saturating_add(selected); - let page_observed = scan_complete || rows >= args.offset.saturating_add(args.limit).saturating_sub(1); + let page_observed = failure.as_ref().is_none_or(|error| error.code != "FILE_CHANGED") + && (scan_complete || rows >= args.offset.saturating_add(args.limit).saturating_sub(1)); let more = !scan_complete || next <= rows; let cause = if let Some(error) = &failure { match error.code.as_str() { "ERR_INVALID_ENCODING" => "encoding", "TABLE_PARSE_ERROR" => "parse-error", "DEADLINE_EXCEEDED" => "time", "FILE_CHANGED" => "changed", _ => "scan-budget", }} else if more { "row-limit" } else { "none" }; let mut output = json!({"status":if failure.is_some() {"partial"} else {"completed"},"phase":"verify","changed":0,"effectState":"none", "path":path,"format":format,"encoding":encoding.name(),"header":args.header,"columns":columns,"columnCount":width.unwrap_or(0), - "bytes":before.len(),"bytesRead":input.bytes_read(),"rowsObserved":rows,"skipped":args.offset-1, + "bytes":before.len(),"bytesRead":input.bytes_read(),"rowsObserved":rows,"skipped":(args.offset-1).min(rows), "scanComplete":scan_complete,"pageComplete":page_observed,"complete":scan_complete && !more,"truncated":more, "truncatedBy":cause,"nextOffset":next,"items":[]}); if scan_complete { output["rowCount"] = json!(rows); output["total"] = json!(rows); output["omitted"] = json!(rows.saturating_sub(next.saturating_sub(1))); output["revision"] = json!(input.revision()); } diff --git a/native/crates/ops-documents/src/tests.rs b/native/crates/ops-documents/src/tests.rs index 30d867e2..4ed8a991 100644 --- a/native/crates/ops-documents/src/tests.rs +++ b/native/crates/ops-documents/src/tests.rs @@ -22,13 +22,15 @@ fn expired_scan_deadline_has_no_total_revision_or_complete_claim() { } #[test] fn changed_file_metadata_invalidates_totals_without_discarding_safe_rows() { - fixture(|path, request, args| { + fixture(|path, request, mut args| { + args.limit = 1; let file = File::open(&path).unwrap(); let before = file.metadata().unwrap(); std::fs::write(&path, "v\nchanged-long\n").unwrap(); let source = Source { path, file, before, started: Instant::now() }; let output = observe(&request, &args, "csv", Encoding::Utf8, ',', source).unwrap(); assert_eq!(output["error"]["code"], "FILE_CHANGED"); assert_eq!(output["scanComplete"], false); - assert_eq!(output["complete"], false); assert_eq!(output["items"][0]["cells"][0], "changed-long"); + assert_eq!(output["complete"], false); assert_eq!(output["pageComplete"], false); + assert_eq!(output["items"][0]["cells"][0], "changed-long"); assert!(output.get("total").is_none()); assert!(output.get("revision").is_none()); }); } diff --git a/test/platform-defaults.test.mjs b/test/platform-defaults.test.mjs index 2a2950d2..d9eb1039 100644 --- a/test/platform-defaults.test.mjs +++ b/test/platform-defaults.test.mjs @@ -6,6 +6,7 @@ import { platformSurface } from '../lib/official-tool-catalog.js' import { COMPONENTS, componentEnabled, SHELL_DISABLED_EXPRESSION } from '../lib/components/defaults.js' import { componentProfile } from './lib/component-profile.mjs' import { shutdown } from './lib/host.mjs' +import { PTC_LOADABLE_TOOLS } from '../lib/ptc/config.js' const patch = fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'), 'utf8') const rows = parse(patch).flatMap(entry => entry.insert ?? []) const manifest = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'dsh-plugin.json'), 'utf8')) @@ -31,7 +32,25 @@ for (const platform of ['win32', 'linux', 'darwin']) await test(`real Loader app assert.equal(entries().length, 7) for (const entry of entries()) assert.equal(!entry.disabled, componentEnabled(entry.options.id.slice('dsh-ops-'.length), platform), entry.options.id) assert.equal(entries().find(entry => entry.options.id === 'dsh-ops-panel').options.config.capture, true) + const ptc = entries().find(entry => entry.options.id === 'dsh-ops-ptc') + assert.deepEqual(ptc.fiber.config.allowedTools, PTC_LOADABLE_TOOLS, 'fresh PTC pool follows the implemented catalogue') + assert.ok(ptc.fiber.config.allowedTools.includes('table_read')) assert.equal(fs.readFileSync(fixture.profile.patchPath, 'utf8'), '[]', 'startup does not write a composed tree') } finally { await shutdown(ctx) } }) +await test('saved explicit PTC pools survive bundle refresh and a fresh profile boot', async () => { + const choices = [{ id: 'dsh-ops-ptc', name: 'dsh-ops/ptc', config: { allowedTools: ['stat'], alwaysLoaded: [] } }] + const fixture = componentProfile('defaults-saved-ptc', patch, choices) + const original = fs.readFileSync(fixture.profile.patchPath, 'utf8') + let host = await fixture.boot('linux') + const check = () => { + const entry = host.entries().find(value => value.options.id === 'dsh-ops-ptc') + assert.deepEqual(entry.fiber.config.allowedTools, ['stat']) + assert.equal(fs.readFileSync(fixture.profile.patchPath, 'utf8'), original) + } + try { + check(); await host.refresh(); check() + await shutdown(host.ctx); host = await fixture.boot('linux'); check() + } finally { await shutdown(host.ctx) } +}) report('platform-defaults') diff --git a/test/table-worker.test.mjs b/test/table-worker.test.mjs index 83c20938..12f28499 100644 --- a/test/table-worker.test.mjs +++ b/test/table-worker.test.mjs @@ -60,6 +60,9 @@ await test('bounded row pages keep full observed totals separate from page compl assert.deepEqual(tail.items.map(item => item.cells), [['499'], ['500']]); assert.equal(tail.complete, true); assert.equal(tail.nextOffset, 501) const after = await run(root, { path: file, offset: 999 }) assert.equal(after.complete, true); assert.deepEqual(after.items, []); assert.equal(after.nextOffset, 999) + assert.equal(after.skipped, 500, "skipped counts observed rows, not the requested offset") + const empty = await run(root, { path: fileAt(root, "empty.csv", "id\n"), offset: 999 }) + assert.equal(empty.skipped, 0); assert.equal(empty.rowsObserved, 0) }) }) await test('UTF-16 byte orders decode strictly and all conflicting BOMs are refused', async () => { From 64141239ed6a6f57ee5ce8f2f9ce48182e28e772 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:08:54 +0800 Subject: [PATCH 102/118] fix(documents): route table reads and validate projection coverage --- docs/CHANGELOG.md | 3 +++ src/policy.ts | 1 + test/file-sdk-render.test.mjs | 4 ++-- test/policy.test.mjs | 7 +++++++ test/ptc-projection-worker.test.mjs | 8 ++++++++ 5 files changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7d2669d8..82cc76dd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,9 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Include each activated document reader in scoped routing guidance; validate real document receipts in the PTC API coverage suite. +- 在作用域路由中显示已激活的文档读取工具,并用真实文档回执验证 PTC API 覆盖。 + - Keep table coverage honest beyond EOF and after metadata changes. Fresh PTC bundle pools follow the implemented catalogue; saved explicit operator pools remain unchanged. - 表格超出 EOF 时只统计实际跳过的行,文件变化后不声明页面完整;新 PTC 允许池遵循实现目录,保留已保存的显式选择。 diff --git a/src/policy.ts b/src/policy.ts index 3bc9046f..8f6bafed 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -48,6 +48,7 @@ export function renderToolingPolicy({ published, mounted, levels, shellComponent } row('discover', 'Find advanced capabilities for the next model step') row('read_many', 'Read UTF-8 ranges from multiple files') + row('table_read', 'Read bounded CSV / TSV lexical rows') row('edit_many', 'Atomic original-text edits in one file (CAS)') row('inspect_local_file', 'Bounded UTF-8 lines / hex') if (hasFiles && names.has('read_image')) lines.push('| Images | read_image (host) |') diff --git a/test/file-sdk-render.test.mjs b/test/file-sdk-render.test.mjs index b4ea3fb3..b8835608 100644 --- a/test/file-sdk-render.test.mjs +++ b/test/file-sdk-render.test.mjs @@ -6,8 +6,8 @@ import { capNativeText, NATIVE_OUTPUT_BYTES } from '../lib/modes/native/render.j import { renderToolingPolicy, publicToolName } from '../lib/policy.js' import { assert, test, report } from './lib/harness.mjs' -await test('all thirteen file argument schemas remain discoverable without unknown types', () => { - assert.equal(FILE_TOOLS.length, 13) +await test('all registered file argument schemas remain discoverable without unknown types', () => { + assert.deepEqual([...FILE_TOOLS].sort(), Object.keys(PARAMETERS).sort()) for (const tool of FILE_TOOLS) { const schema = PARAMETERS[tool] assertSupportedJsonSchema(schema) diff --git a/test/policy.test.mjs b/test/policy.test.mjs index 7ef91592..7addbfe3 100644 --- a/test/policy.test.mjs +++ b/test/policy.test.mjs @@ -24,6 +24,13 @@ await test('file guidance names exactly the available file tools', () => { assert.match(text, /do not switch to a shell workaround/) assert.match(text, /pass only declared fields/) }) +await test('each discovered file capability has routing only while it is visible', () => { + for (const visible of files) { + const text = renderToolingPolicy({ published: [visible] }) + assert.ok(text.includes(visible), visible) + for (const hidden of files.filter(name => name !== visible)) assert.equal(text.includes(hidden), false, hidden) + } +}) await test('the prompt states the real file-tool syntax and result contract (#44)', () => { const text = renderToolingPolicy({ published: files, shellComponent: false }) assert.match(text, /ops_glob `pattern` and the `glob` filters are ARRAYS of globs relative to `path`, e\.g\. `\["\*\*\/\*\.md","!vendor\/\*\*"\]`/) diff --git a/test/ptc-projection-worker.test.mjs b/test/ptc-projection-worker.test.mjs index 222af4e3..57d67507 100644 --- a/test/ptc-projection-worker.test.mjs +++ b/test/ptc-projection-worker.test.mjs @@ -35,6 +35,14 @@ await test('all file observations validate and retain text, hex, stat and search assert.equal((await call(root, 'grep', { path: root, pattern: '[' })).error.code, 'INVALID_ARGUMENT') }) }) +await test('table observations validate actual lexical rows through the PTC output schema', async () => { + await withTempDir('ptc-table', async root => { + const file = path.join(root, 'rows.csv'); fs.writeFileSync(file, 'name,value\nalpha,01\n') + const out = await call(root, 'table_read', { path: file }) + assert.equal(out.status, 'completed'); assert.deepEqual(out.data.items[0].cells, ['alpha', '01']) + assert.equal(out.data.scanComplete, true); assert.equal(out.data.rowCount, 1) + }) +}) await test('mutation receipts and batch plans preserve effects without changing the native kernel', async () => { await withTempDir('ptc-write', async root => { const file = path.join(root, 'a.txt'), copy = path.join(root, 'copy.txt'), moved = path.join(root, 'moved.txt') From 66bb0a95e6786f6abc601abbbf40fa49ec076c45 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:09:54 +0800 Subject: [PATCH 103/118] fix(documents): route DOCX reads and exercise typed receipts --- src/policy.ts | 1 + test/ptc-projection-worker.test.mjs | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/policy.ts b/src/policy.ts index 8f6bafed..56e2f23b 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -49,6 +49,7 @@ export function renderToolingPolicy({ published, mounted, levels, shellComponent row('discover', 'Find advanced capabilities for the next model step') row('read_many', 'Read UTF-8 ranges from multiple files') row('table_read', 'Read bounded CSV / TSV lexical rows') + row('doc_read', 'Read DOCX main-body paragraphs') row('edit_many', 'Atomic original-text edits in one file (CAS)') row('inspect_local_file', 'Bounded UTF-8 lines / hex') if (hasFiles && names.has('read_image')) lines.push('| Images | read_image (host) |') diff --git a/test/ptc-projection-worker.test.mjs b/test/ptc-projection-worker.test.mjs index 57d67507..161ea745 100644 --- a/test/ptc-projection-worker.test.mjs +++ b/test/ptc-projection-worker.test.mjs @@ -1,5 +1,6 @@ /** Validate real canonical worker receipts through the actual PTC output schemas. */ import fs from 'node:fs' +import { docx, paragraph } from './lib/document-fixture.mjs' import path from 'node:path' import { createHash } from 'node:crypto' import { validateJsonSchemaValue, assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools' @@ -43,6 +44,14 @@ await test('table observations validate actual lexical rows through the PTC outp assert.equal(out.data.scanComplete, true); assert.equal(out.data.rowCount, 1) }) }) +await test('DOCX observations validate actual main-body paragraphs through the PTC output schema', async () => { + await withTempDir('ptc-docx', async root => { + const file = path.join(root, 'paragraphs.docx'); fs.writeFileSync(file, docx(paragraph('Exact Unicode 中'))) + const out = await call(root, 'doc_read', { path: file }) + assert.equal(out.status, 'completed'); assert.equal(out.data.items[0].text, 'Exact Unicode 中') + assert.equal(out.data.scanComplete, true); assert.equal(out.data.scope, 'main-body-paragraphs') + }) +}) await test('mutation receipts and batch plans preserve effects without changing the native kernel', async () => { await withTempDir('ptc-write', async root => { const file = path.join(root, 'a.txt'), copy = path.join(root, 'copy.txt'), moved = path.join(root, 'moved.txt') From a8c10be63442376b23099504b84d2b5d60d6ef78 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:11:19 +0800 Subject: [PATCH 104/118] fix(documents): route PDF reads and exercise typed pages --- src/policy.ts | 1 + test/ptc-projection-worker.test.mjs | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/policy.ts b/src/policy.ts index 56e2f23b..2f9b064f 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -50,6 +50,7 @@ export function renderToolingPolicy({ published, mounted, levels, shellComponent row('read_many', 'Read UTF-8 ranges from multiple files') row('table_read', 'Read bounded CSV / TSV lexical rows') row('doc_read', 'Read DOCX main-body paragraphs') + row('pdf_read', 'Read supported PDF text-layer pages') row('edit_many', 'Atomic original-text edits in one file (CAS)') row('inspect_local_file', 'Bounded UTF-8 lines / hex') if (hasFiles && names.has('read_image')) lines.push('| Images | read_image (host) |') diff --git a/test/ptc-projection-worker.test.mjs b/test/ptc-projection-worker.test.mjs index 161ea745..68ded73c 100644 --- a/test/ptc-projection-worker.test.mjs +++ b/test/ptc-projection-worker.test.mjs @@ -1,5 +1,6 @@ /** Validate real canonical worker receipts through the actual PTC output schemas. */ import fs from 'node:fs' +import { textPdf, show } from './lib/pdf-fixture.mjs' import { docx, paragraph } from './lib/document-fixture.mjs' import path from 'node:path' import { createHash } from 'node:crypto' @@ -52,6 +53,14 @@ await test('DOCX observations validate actual main-body paragraphs through the P assert.equal(out.data.scanComplete, true); assert.equal(out.data.scope, 'main-body-paragraphs') }) }) +await test('PDF observations validate actual text-layer pages through the PTC output schema', async () => { + await withTempDir('ptc-pdf', async root => { + const file = path.join(root, 'pages.pdf'); fs.writeFileSync(file, textPdf([show('Exact PDF text')])) + const out = await call(root, 'pdf_read', { path: file }) + assert.equal(out.status, 'completed'); assert.equal(out.data.items[0].text, 'Exact PDF text') + assert.equal(out.data.scanComplete, true); assert.equal(out.data.scope, 'page-text-layer-content-order') + }) +}) await test('mutation receipts and batch plans preserve effects without changing the native kernel', async () => { await withTempDir('ptc-write', async root => { const file = path.join(root, 'a.txt'), copy = path.join(root, 'copy.txt'), moved = path.join(root, 'moved.txt') From d74988044c76a613ad986a0fff811e2bfb19ee00 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:14:01 +0800 Subject: [PATCH 105/118] test(documents): include catalogue readers in CLI routing checks --- test/bin.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/bin.test.mjs b/test/bin.test.mjs index 640be70a..2273e839 100644 --- a/test/bin.test.mjs +++ b/test/bin.test.mjs @@ -23,7 +23,7 @@ import { createHash } from 'node:crypto' import fs from 'node:fs' import path from 'node:path' import { resolveConfig } from '../lib/config.js' -import { renderToolingPolicy, publicToolName } from '../lib/policy.js' +import { FILE_TOOLS, renderToolingPolicy, publicToolName } from '../lib/policy.js' import { resolveShells } from '../lib/shells.js' import { PACKAGE_ROOT, TEST_HOME, assert, report, test, withTempDir } from './lib/harness.mjs' @@ -272,7 +272,7 @@ function assertLadder(report_) { const { levels, rungs } = report_ assert.deepEqual(rungs, [], 'the retired numbered ladder is empty') const published = [ - ...(levels.files ? ['glob', 'grep', 'inspect_local_file', 'replace', 'write_file', 'edit_file', 'diff', 'stat', 'copy', 'move', 'remove', 'read_many', 'edit_many'].map(publicToolName) : []), + ...(levels.files ? FILE_TOOLS.map(publicToolName) : []), ...(levels.bash ? ['ops_bash'] : []), ...(levels.pwsh ? ['pwsh'] : []), ] From 7b06ab40d18df75a98c2cf24981f830cdfbb2a8c Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:19:24 +0800 Subject: [PATCH 106/118] test(native): decode rendered source escapes independently --- test/file-sdk-render.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/file-sdk-render.test.mjs b/test/file-sdk-render.test.mjs index b8835608..69c2203c 100644 --- a/test/file-sdk-render.test.mjs +++ b/test/file-sdk-render.test.mjs @@ -34,7 +34,9 @@ await test('inspect prints one file heading and physical line labels without cha const original = JSON.stringify(value) const view = render('inspect_local_file', value) assert.ok(view.startsWith(`=== FILE: ${path} (Lines 1-200 of 201) ===`)) - assert.ok(view.includes('1 | αβ"\\中')) + const firstBody = /^1 \| (.*)$/m.exec(view)?.[1] + assert.equal(typeof firstBody, 'string') + assert.equal(JSON.parse('"' + firstBody.replaceAll('"', '\\"') + '"'), value.items[0].text) assert.match(view, /revision=a{64}/) assert.match(view, /complete=false/) assert.match(view, /nextOffset=201/) From 8b9df549843b89f1fb3a238851ba40c3164c0000 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:19:26 +0800 Subject: [PATCH 107/118] test(pack): retain child process failure diagnostics --- test/package-source.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/package-source.test.mjs b/test/package-source.test.mjs index 034fa4d0..39e3bba2 100644 --- a/test/package-source.test.mjs +++ b/test/package-source.test.mjs @@ -5,10 +5,13 @@ import { spawnSync } from 'node:child_process' import { assert, test, report, PACKAGE_ROOT } from './lib/harness.mjs' await test('npm pack includes compiled host code and native sources, never host sources or build caches', () => { + const started = performance.now() const result = spawnSync(process.platform === 'win32' ? 'cmd.exe' : 'npm', process.platform === 'win32' ? ['/d', '/s', '/c', 'npm.cmd pack --dry-run --json --ignore-scripts'] : ['pack', '--dry-run', '--json', '--ignore-scripts'], { cwd: PACKAGE_ROOT, encoding: 'utf8', timeout: 30000 }) - assert.equal(result.status, 0, result.stderr) + assert.equal(result.status, 0, JSON.stringify({ elapsedMs: Math.round(performance.now() - started), + signal: result.signal, error: result.error && { code: result.error.code, message: result.error.message }, + stderr: result.stderr, stdoutTail: result.stdout?.slice(-2000) }, null, 2)) const files = JSON.parse(result.stdout)[0].files.map(file => file.path) const required = ['lib/ptc/sdk.d.ts', 'lib/index.js', 'lib/policy.js', 'lib/components/panel/index.js', 'lib/components/panel/package.json', 'lib/components/panel/icon.png', 'lib/panel/panel.html', From e27f6766b97d296d25a7164aad39e227517de8b7 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:19:56 +0800 Subject: [PATCH 108/118] test(documents): isolate concurrent table fixtures --- native/crates/ops-documents/src/tests.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/native/crates/ops-documents/src/tests.rs b/native/crates/ops-documents/src/tests.rs index 4ed8a991..cc811bd0 100644 --- a/native/crates/ops-documents/src/tests.rs +++ b/native/crates/ops-documents/src/tests.rs @@ -1,7 +1,17 @@ use super::*; +fn fixture_name(timestamp: u128) -> String { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let sequence = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + format!("dsh-table-{}-{timestamp}-{sequence}", std::process::id()) +} +#[test] +fn fixture_names_are_distinct_even_with_identical_timestamps() { + let names: std::collections::HashSet<_> = (0..64).map(|_| fixture_name(42)).collect(); + assert_eq!(names.len(), 64); +} fn fixture(test: impl FnOnce(PathBuf, Request, Args)) { let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let root = std::fs::canonicalize(std::env::temp_dir()).unwrap().join(format!("dsh-table-{}-{unique}", std::process::id())); + let root = std::fs::canonicalize(std::env::temp_dir()).unwrap().join(fixture_name(unique)); std::fs::create_dir(&root).unwrap(); let path = root.join("table.csv"); std::fs::write(&path, "v\nsafe\n").unwrap(); let request = serde_json::from_value(json!({"root":root,"scope":"table-unit","tool":"table_read","args":{}})).unwrap(); From 30c45633442d289f9b710b0cb2a7148893d92305 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:20:18 +0800 Subject: [PATCH 109/118] chore(release): prepare 0.4.0 candidate and acceptance record --- README.en.md | 48 ++++-- README.md | 31 ++-- README.zh.md | 32 ++-- ...21\345\270\203\345\200\231\351\200\211.md" | 78 ++++++++++ docs/CHANGELOG.md | 49 ++++-- ...11\351\234\200\345\212\240\350\275\275.md" | 2 +- docs/benchmark-gates.md | 10 +- docs/component-reference.md | 26 +++- docs/configuration-reference.md | 25 +-- docs/discovery.md | 10 +- docs/manual-validation.md | 146 +++++++++--------- docs/ptc-sdk-loading.md | 2 +- docs/release-0.4.0.md | 126 +++++++++++++++ docs/release-process.md | 5 +- docs/roadmap.md | 11 +- docs/usage.md | 16 +- docs/windows-verification.md | 4 +- ...77\347\224\250\346\214\207\345\215\227.md" | 10 +- ...72\345\207\206\351\227\250\347\246\201.md" | 8 +- ...45\345\205\267\345\217\221\347\216\260.md" | 10 +- ...13\345\212\250\351\252\214\346\224\266.md" | 32 ++++ ...04\344\273\266\345\217\202\350\200\203.md" | 26 +++- .../\350\267\257\347\272\277\345\233\276.md" | 23 +++ ...15\347\275\256\345\217\202\350\200\203.md" | 24 +-- dsh-plugin.json | 2 +- native/Cargo.lock | 18 +-- native/Cargo.toml | 2 +- native/crates/ops-batch/Cargo.toml | 2 +- native/crates/ops-documents/Cargo.toml | 2 +- native/crates/ops-files/Cargo.toml | 2 +- native/crates/ops-protocol/Cargo.toml | 2 +- native/crates/ops-results/Cargo.toml | 2 +- native/crates/ops-search/Cargo.toml | 2 +- native/darwin-metadata/Cargo.toml | 2 +- native/windows-metadata/Cargo.toml | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/validate-manifest.mjs | 2 +- src/components/panel/package.json | 2 +- src/components/tui/dsh-plugin.json | 2 +- src/components/tui/package.json | 2 +- src/ptc/runtime.ts | 2 +- 42 files changed, 578 insertions(+), 230 deletions(-) create mode 100644 "docs/0.4.0\345\217\221\345\270\203\345\200\231\351\200\211.md" create mode 100644 docs/release-0.4.0.md create mode 100644 "docs/\346\211\213\345\212\250\351\252\214\346\224\266.md" create mode 100644 "docs/\350\267\257\347\272\277\345\233\276.md" diff --git a/README.en.md b/README.en.md index 977c2c80..effbf076 100644 --- a/README.en.md +++ b/README.en.md @@ -21,23 +21,37 @@ # dsh-ops -A plugin dedicated to improving the token efficiency of DSH plugins, boosting AI performance, reducing token consumption, and cutting total task time. It removes the token, time, and model-attention waste caused by DSH repeatedly hitting PowerShell errors on Windows, and will keep being updated. - -Main features of this plugin: - -- A set of more advanced always-on tools written in Rust. Compared with the built-ins, these tools use fewer input and output tokens, run faster, cover more ground, and impose less mental load on the AI; they also carry brief hash checks, line/position info, and similar messages to reduce token overhead. -- Planned on the [roadmap](docs/roadmap.md): everyday tools such as DOC/PDF operations, web search, and crawl4ai-style HTML-to-Markdown tools, added through progressive disclosure so the AI avoids writing one-off code or scripts to reach its goal. **The current release does not ship them yet.** -- Provides an option to disable the built-in tools, to maximize savings on the AI's mental load and the fixed system prompt. -- On Windows it additionally provides the `ops_bash` tool — the AI is far more fluent with bash than with pwsh. Run `dsh-ops bash-runtime install` to acquire the verified optional Bash, or select an existing Git Bash through `bashPath`. Neither Bash nor PowerShell is an npm payload dependency. `dsh-ops upgrade-host-pwsh` previews a standard Windows PowerShell 7 installation; add `--yes` to execute it, then restart DSH. Custom host `pwshPath` settings remain unchanged. - -Plugin traits: - -- Low intrusion and high compatibility: it does not change runtime logic, inject into the system prompt or modify system variables — it only adds new, easy-to-use high-level tools, so it stays compatible with almost any plugin. - -> [!NOTE] -> A rigorous benchmark and a concrete test plan for token savings and performance gains will ship in the next version, covering most common scenarios. The 97% token reduction reported for the previous version only describes the **"single, complex task delivered from a complete, explicit and specific prompt"** scenario — with short prompts the model's search for a conclusion is too random to control the variables. - -> tips: "ops" here is short for the broad sense of operations (running operations), indicating that this plugin sits in the base-tool position between DSH and system/code/file operations. This is not an operations (ops/SRE) plugin. +OPS adds bounded file and document tools to DSH. Native mode returns compact, line-numbered text; +PTC code receives typed Outcome values from the same one-shot Rust worker. Host integration is +strict TypeScript, compiled to the existing JavaScript package paths. + +> [!IMPORTANT] +> This branch is the **0.4.0 release candidate**, not a published release. Release is blocked: +> BM-01 does not meet its output-reduction threshold, and the full default Windows Native schema +> exceeds BM-03's 400-token budget. Functional CI does not satisfy these performance gates. +> See [release status and limitations](docs/release-0.4.0.md). Commands using `@latest` below select +> the published package; build and pack this checkout to evaluate the candidate. + +- Native starts with `ops_read_many` and `ops_discover`; eligible Windows sessions also receive + `ops_bash`. Advanced tools activate in the owning agent's next provider request. +- The PTC SDK catalogue has 19 APIs: 16 file/document tools, batch, result references and its loader. + Only the configured resident or selected APIs are exposed. Native and PTC use separate outputs. +- Discover bounded CSV/TSV, DOCX main-body paragraphs and a restricted PDF text layer. Binary DOC, + OCR, general PDF rendering, web search and HTML crawling are not implemented. Read the + [document-format limits](docs/native-kernel.md) before choosing a reader. +- Seven independently toggled rows preserve saved profile IDs and settings. Per-tool official + replacements are off by default. [Settings](docs/configuration-reference.md) include validated + built-in forms and optional browser pages with an explicit offline carrier migration. +- Windows Bash is optional: install a verified managed copy with `dsh-ops bash-runtime install` + or choose Git Bash through `bashPath`. PowerShell stays host-managed; its upgrade command previews + the standard installation and requires `--yes` to execute. Neither shell is an npm payload. + +OPS publishes its own scoped tools and optional routing guidance; it does not rewrite the shared +tool registry. Permissions and cancellation are checked per call. Windows restricted Bash retains +its documented partial enforcement and runtime-compatibility limits. + +Historical token-saving claims are not evidence that this candidate meets its release gates. +The name “ops” means general operations, not an operations/SRE product. ## Install, update and uninstall diff --git a/README.md b/README.md index e3b3a32b..b3254b6a 100644 --- a/README.md +++ b/README.md @@ -21,26 +21,21 @@ # dsh-ops -致力优化dsh插件token效率,提升ai性能,减少token消耗,减少任务总时间的插件,解决 Windows 下 DSH 反复使用 PowerShell 报错造成的 token、时间和模型注意力浪费,会持续更新。 +OPS 为 DSH 增加有界文件与文档工具。Native 模式返回紧凑、带行号的文本;PTC 代码从同一个按调用启动的 Rust worker 获取类型化 Outcome。宿主集成使用严格 TypeScript,编译后保持已有 JavaScript 包路径。 -本插件的主要功能: +> [!IMPORTANT] +> 本分支是 **0.4.0 发布候选**,尚未发布。发布仍被阻止:BM-01 未达到输出缩减门槛,完整 Windows Native 默认 schema 也超过 BM-03 的 400 token 预算。功能 CI 通过不等于性能门禁通过。 +> 详情见[发布状态与限制](docs/0.4.0发布候选.md)。下方 `@latest` 命令安装已发布版本;评估候选版本需自行构建本检出目录并打包。 -- 提供一组更高级的tools常驻工具,由rust编写。这些tools工具相对于原版,输入和输出token都更少,运行速度更快,且覆盖面更广,给ai造成的心智负担更低,同时附带简短的哈希校验、行数位置等消息以减少 -- 已在[路线图](docs/roadmap.md)中规划:以渐进式披露的形式补上 doc/pdf 操作、网页搜索、html 转 markdown 类 crawl4ai 工具等日常常用的 tools 工具,让 ai 不必重新编写代码或脚本来实现自己的目标。**当前版本尚未提供这些工具。** -- 提供关闭原版tools以最大化节省ai心智负担和系统固定提示词的可选项 -- 在windows平台下额外提供tools:ops_bash的支持——ai使用bash远比ai使用pwsh更流畅。可运行 `dsh-ops bash-runtime install` 单独下载经过校验的 Bash,或通过 `bashPath` 选择已有 Git Bash;Bash 与 PowerShell 都不再作为 npm 载荷依赖安装。`dsh-ops upgrade-host-pwsh` 预览标准 Windows PowerShell 7 安装,添加 `--yes` 才执行,之后重启 DSH;自定义宿主 `pwshPath` 配置保持不变 +- Native 默认提供 `ops_read_many` 与 `ops_discover`;符合条件的 Windows 会话另有 `ops_bash`。高级工具在所属 agent 的下一次供应商请求中激活。 +- PTC SDK 目录包含 19 个 API:16 个文件/文档工具,加批处理、结果引用和加载器。仅暴露配置为常驻或已选择的 API;Native 与 PTC 分别提供自己的输出形式。 +- 可按需发现有界 CSV/TSV、DOCX 主体段落与受限 PDF 文本层读取。尚不支持二进制 DOC、OCR、通用 PDF 渲染、网页搜索或 HTML 抓取;使用前请查看[文档格式限制](docs/原生内核.md)。 +- 七个组件独立启停,保留已有 profile ID 与配置。逐工具官方替换默认关闭。[设置](docs/配置参考.md)提供严格校验的内置表单;可选浏览器页面需显式执行离线承载行迁移。 +- Windows Bash 为可选运行时:通过 `dsh-ops bash-runtime install` 安装已校验的受管理版本,或用 `bashPath` 选择 Git Bash。PowerShell 由宿主管理;升级命令先预览标准安装,仅加 `--yes` 才执行。两个 shell 均不是 npm 载荷依赖。 -插件特点: +OPS 只发布自身作用域内的工具与可选路由提示,不改写共享工具注册表。每次调用均检查权限与取消状态。Windows 受限 Bash 仍存在文档注明的部分约束与运行时兼容性限制。 -- 低侵入性,高兼容性,不改运行逻辑,不注入系统提示词,不改系统变量,只提供新的易用高级工具,从而几乎可以和任何插件兼容 - -> [!NOTE] -> 关于token节省和性能提升的严谨benchmark与具体测试方案将在下个版本推出,其将会覆盖大多数通用场景。上个版本的97% token减免仅代表 **“提示词全面,明确,具体的单次复杂任务交付”** 这一场景————简短提示词让ai发散性的寻找结论的随机性太大,很难控制变量 - -> [!NOTE] -> dsh 0.2.1 和ds 1017 一起更新后可能导致ops 0.3.1版本表现不佳,我们会尽快修复 - -> tips:本项目的 ops 是广义 operations (运行操作)的缩写,代指本插件处于dsh和系统/代码/文件操作的基础工具位置。本项目不是 operations(运维)插件。 +历史 token 节省说法不能证明本候选版本通过发布门禁。ops 指广义 operations(运行操作),并非运维/SRE 产品。 ## 安装、更新、卸载 @@ -102,8 +97,8 @@ npx --yes dsh-ops@latest uninstall --profile desktop ## 文档 - [使用指南](docs/使用指南.md) · [常见问题](docs/常见问题.md) · [组件参考](docs/组件参考.md) · [配置参考](docs/配置参考.md) -- [PTC 设计与使用](docs/native-ptc.md) · [手动验证](docs/manual-validation.md) -- [贡献指南](docs/贡献指南.md) · [路线图](docs/roadmap.md) · [变更日志](docs/CHANGELOG.md) · [溯源记录](docs/PROVENANCE.md) +- [PTC 设计与使用](docs/native-ptc.md) · [手动验证](docs/手动验收.md) +- [贡献指南](docs/贡献指南.md) · [路线图](docs/路线图.md) · [变更日志](docs/CHANGELOG.md) · [溯源记录](docs/PROVENANCE.md) ## 许可证 diff --git a/README.zh.md b/README.zh.md index 70f502be..b3254b6a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -21,23 +21,21 @@ # dsh-ops -致力优化dsh插件token效率,提升ai性能,减少token消耗,减少任务总时间的插件,解决 Windows 下 DSH 反复使用 PowerShell 报错造成的 token、时间和模型注意力浪费,会持续更新。 +OPS 为 DSH 增加有界文件与文档工具。Native 模式返回紧凑、带行号的文本;PTC 代码从同一个按调用启动的 Rust worker 获取类型化 Outcome。宿主集成使用严格 TypeScript,编译后保持已有 JavaScript 包路径。 -本插件的主要功能: +> [!IMPORTANT] +> 本分支是 **0.4.0 发布候选**,尚未发布。发布仍被阻止:BM-01 未达到输出缩减门槛,完整 Windows Native 默认 schema 也超过 BM-03 的 400 token 预算。功能 CI 通过不等于性能门禁通过。 +> 详情见[发布状态与限制](docs/0.4.0发布候选.md)。下方 `@latest` 命令安装已发布版本;评估候选版本需自行构建本检出目录并打包。 -- 提供一组更高级的tools常驻工具,由rust编写。这些tools工具相对于原版,输入和输出token都更少,运行速度更快,且覆盖面更广,给ai造成的心智负担更低,同时附带简短的哈希校验、行数位置等消息以减少 -- 已在[路线图](docs/roadmap.md)中规划:以渐进式披露的形式补上 doc/pdf 操作、网页搜索、html 转 markdown 类 crawl4ai 工具等日常常用的 tools 工具,让 ai 不必重新编写代码或脚本来实现自己的目标。**当前版本尚未提供这些工具。** -- 提供关闭原版tools以最大化节省ai心智负担和系统固定提示词的可选项 -- 在windows平台下额外提供tools:ops_bash的支持——ai使用bash远比ai使用pwsh更流畅。可运行 `dsh-ops bash-runtime install` 单独下载经过校验的 Bash,或通过 `bashPath` 选择已有 Git Bash;Bash 不再作为 npm 依赖安装 +- Native 默认提供 `ops_read_many` 与 `ops_discover`;符合条件的 Windows 会话另有 `ops_bash`。高级工具在所属 agent 的下一次供应商请求中激活。 +- PTC SDK 目录包含 19 个 API:16 个文件/文档工具,加批处理、结果引用和加载器。仅暴露配置为常驻或已选择的 API;Native 与 PTC 分别提供自己的输出形式。 +- 可按需发现有界 CSV/TSV、DOCX 主体段落与受限 PDF 文本层读取。尚不支持二进制 DOC、OCR、通用 PDF 渲染、网页搜索或 HTML 抓取;使用前请查看[文档格式限制](docs/原生内核.md)。 +- 七个组件独立启停,保留已有 profile ID 与配置。逐工具官方替换默认关闭。[设置](docs/配置参考.md)提供严格校验的内置表单;可选浏览器页面需显式执行离线承载行迁移。 +- Windows Bash 为可选运行时:通过 `dsh-ops bash-runtime install` 安装已校验的受管理版本,或用 `bashPath` 选择 Git Bash。PowerShell 由宿主管理;升级命令先预览标准安装,仅加 `--yes` 才执行。两个 shell 均不是 npm 载荷依赖。 -插件特点: +OPS 只发布自身作用域内的工具与可选路由提示,不改写共享工具注册表。每次调用均检查权限与取消状态。Windows 受限 Bash 仍存在文档注明的部分约束与运行时兼容性限制。 -- 低侵入性,高兼容性,不改运行逻辑,不注入系统提示词,不改系统变量,只提供新的易用高级工具,从而几乎可以和任何插件兼容 - -> [!NOTE] -> 关于token节省和性能提升的严谨benchmark与具体测试方案将在下个版本推出,其将会覆盖大多数通用场景。上个版本的97% token减免仅代表 **“提示词全面,明确,具体的单次复杂任务交付”** 这一场景————简短提示词让ai发散性的寻找结论的随机性太大,很难控制变量 - -> tips:本项目的 ops 是广义 operations (运行操作)的缩写,代指本插件处于dsh和系统/代码/文件操作的基础工具位置。本项目不是 operations(运维)插件。 +历史 token 节省说法不能证明本候选版本通过发布门禁。ops 指广义 operations(运行操作),并非运维/SRE 产品。 ## 安装、更新、卸载 @@ -99,14 +97,14 @@ npx --yes dsh-ops@latest uninstall --profile desktop ## 文档 - [使用指南](docs/使用指南.md) · [常见问题](docs/常见问题.md) · [组件参考](docs/组件参考.md) · [配置参考](docs/配置参考.md) -- [PTC 设计与使用](docs/native-ptc.md) · [手动验证](docs/manual-validation.md) -- [贡献指南](docs/贡献指南.md) · [路线图](docs/roadmap.md) · [变更日志](docs/CHANGELOG.md) · [溯源记录](docs/PROVENANCE.md) +- [PTC 设计与使用](docs/native-ptc.md) · [手动验证](docs/手动验收.md) +- [贡献指南](docs/贡献指南.md) · [路线图](docs/路线图.md) · [变更日志](docs/CHANGELOG.md) · [溯源记录](docs/PROVENANCE.md) ## 许可证 插件源码分发采用 **`MIT AND Apache-2.0`**:`licenses/fastctx/` 之外为 MIT;FastCtx 为 Apache-2.0。见 [NOTICE](NOTICE)、`licenses/fastctx/LICENSE-APACHE` 与 `licenses/fastctx/NOTICE`。 -独立的 Windows 载荷包另按上游组件许可证分发:Git for Windows 包含 GPL 等许可组件,PowerShell 包含 MIT 与第三方组件。其原始许可证、声明及来源记录随载荷保留;插件的 MIT 许可不替代这些许可。详见 [PROVENANCE.md](docs/PROVENANCE.md)。 +Shell 二进制由用户从上游发行方另行获取,并遵守上游许可证:Git for Windows 包含 GPL 等许可组件,PowerShell 包含 MIT 与第三方组件;插件的 MIT 许可不替代这些许可。详见 [PROVENANCE.md](docs/PROVENANCE.md)。 ## 致谢 @@ -146,4 +144,4 @@ Rust 工具基于 [yc-duan](https://github.com/yc-duan) 的 Codex 插件 FastCtx ## Star 趋势 -[![Star 趋势图](https://api.star-history.com/svg?repos=T-Auto/dsh-ops&type=Date)](https://star-history.com/#T-Auto/dsh-ops&Date) \ No newline at end of file +[![Star 趋势图](https://api.star-history.com/svg?repos=T-Auto/dsh-ops&type=Date)](https://star-history.com/#T-Auto/dsh-ops&Date) diff --git "a/docs/0.4.0\345\217\221\345\270\203\345\200\231\351\200\211.md" "b/docs/0.4.0\345\217\221\345\270\203\345\200\231\351\200\211.md" new file mode 100644 index 00000000..98cc55dd --- /dev/null +++ "b/docs/0.4.0\345\217\221\345\270\203\345\200\231\351\200\211.md" @@ -0,0 +1,78 @@ +# 0.4.0 发布候选 + +English: [0.4.0 release candidate](release-0.4.0.md). + +**尚未发布。** 版本字段仅标识候选,不代表允许打 tag、发布 npm 包或合并最终发布 PR。功能 CI 与五项验收基准分别计数;BM-01 和完整默认 BM-03 仍未通过,发布继续被阻止。 + +## 已实现的能力 + +包中有七个稳定组件行:shell、file、PTC、discovery、panel、TUI、官方工具替换。新配置默认开启 file/PTC/discovery;仅 Windows 默认开启 shell;panel/TUI/替换默认关闭。已有带名称覆盖、显式开关与配置在重启后保留。启用新 panel 行即采集;旧配置显式 capture:false 仍不采集。关闭时物理停止排队文件操作。 + +Native 默认只发布 read_many、discover,以及可用的 Windows Bash。高级工具仅在所属 agent 的下一次供应商请求中激活。PTC SDK 目录共19个 API:16个文件/文档工具,加 batch、result、sdk。共享目录另含 bash 与 discover,共21项。目录成员并不等于启动常驻;SDK 与 Native 分别提供类型化数据和文本,实际发布与每次调用仍受作用域、允许池和权限约束。 + +Rust worker 仍按调用启动,每次重新检查权限。Native 文本有界并可逆转义行内容,PTC 返回类型化 Outcome。结果引用保留源扫描、分页与捕获完整性,不授予文件权限。相同内容重读去重要求确切的供应商输入证据,只有消息历史不足以证明可省略正文;见[上下文读取去重](上下文读取去重.md)。 + +全部插件源码均为严格 TypeScript/TSX;构建拒绝 src 下的 JavaScript,输出到 lib,保持已发布 .js 路径。仓库脚本和测试可继续使用 JavaScript;lib/runtime 是构建产物。请按[源码构建步骤](贡献指南.md)安装,不直接使用未构建 Git 检出。 + +## 文档范围 + +| 工具 | 支持范围 | 主要边界与排除项 | +| --- | --- | --- | +| table_read | CSV/TSV 字符串单元格;严格 UTF-8 或 BOM 一致的 UTF-16LE/BE | 输入32MiB、100000数据行、每记录1MiB、1024列、30秒。不支持 XLS/XLSX、公式计算或类型推断。 | +| doc_read | DOCX 主体段落,保留表格坐标 | 归档32MiB、主 XML8MiB、每段1MiB、100000段、30秒。不支持二进制 DOC、宏、加密/ZIP64、外部抓取或落盘解压;不含排版、页眉页脚、注释脚注及元数据。 | +| pdf_read | PDF1.3–1.7 经典 xref 文本层;标准 Helvetica/Courier/Times Type1 与 Standard/WinAnsi | 输入16MiB、32页、每页/流1MiB、解码与处理流各16MiB、30秒。不支持 OCR、图片、通用排版、表单、自定义/嵌入/CID 字体、ToUnicode、加密、增量更新或对象/xref流。 | + +这些是有界读取器,并非通用 Office/PDF 转换器。后续不支持内容只保留此前完整记录,返回错误和下一步;offset 不能跳过物理扫描缺口。仅稳定完整扫描提供 total/revision;展示页或缓存页完整不代表源内容完整。详见[原生内核文档契约](原生内核.md)。 + +## 操作者变更 + +Bash 与 PowerShell 均不是 npm 载荷依赖。受管理 Bash 安装/删除、Git Bash 选择均需显式操作。Windows 受限执行仍是部分约束,且要求兼容的固定 MSYS 运行时;审批不能补足缺少的隔离。宿主 PowerShell 升级先预览标准安装,需显式执行、重启 DSH,并检查自定义 pwshPath。 + +内置行表单通过宿主持久化严格校验后的配置。可选浏览器页面需要显式执行离线 settings-profile 迁移,因为固定宿主仅从裸包名承载行加载浏览器 facet。默认旧文件行仍受支持;迁移保留七行 ID/配置、先备份并精确验证合成结果。关闭承载行卸载全部自定义页面,通过宿主行开关恢复;见[配置参考](配置参考.md)。 + +## 议题 #261 验收矩阵 + +下表将原始 16 步计划映射到集成实现。套件名称是具体回归与证据入口,不代表等待中的最终 CI 或手动检查已经通过;上文及链接契约中的边界仍属于验收要求。 + +| 步骤 | 已实现行为与边界 | 证据 / 待决事项 | +| --- | --- | --- | +| 1 — 构建 | src → lib 与资源构建,保持发布路径;Git 源码安装须先构建。 | build 与打包门禁;临时 JavaScript 迁移许可已在第 16 步移除。 | +| 2 — 契约 | 统一能力目录、Outcome、错误/重试、覆盖与引用类型。 | kernel-types、kernel-errors 和 schema;Native 文本与 PTC 数据保持分离。 | +| 3 — Native 投影 | 可逆转义行号文本、短回执与不完整提示;转义后展示上限 64 KiB。 | native-projection、native-line-encoding、native-ranges;不保证普遍百分比节省。 | +| 4 — Worker | workspace crates 与 v1 有界单次请求,每次重检权限。 | Cargo workspace 与协议回归;持久 IPC 仍待独立安全评审。 | +| 5 — 读写/搜索 | 最多 32 个独立读取范围、单文件原子多段编辑、如实搜索缺口与续取。 | file-batching-worker 与搜索回归;不提供跨文件原子编辑,扫描缺口后不声称全量总数。 | +| 6 — 引用/预算 | 每结果 10 MiB、每会话 100 MiB;两活动/八等待、worker 30 秒上限;审计独立。 | result-references/spill/lifecycle、call-budget、native-result;权限不足清理可能留下已失效临时块。 | +| 7 — PTC | 19 个类型化 SDK API、三个常驻入口、独立允许池与下一步骤加载。 | ptc-outcome/schema/sdk、progressive-loop;当前冻结 SDK 无法调用排队 API。 | +| 8 — 发现 | Native 允许池、常驻子集、单次 1–5 项,仅所属下一请求可见。 | discovery/composition 与真实 AgentLoop BM-04;不授予文件权限。 | +| 9 — 常驻面 | read_many、discover 及符合条件的 Windows Bash;高级工具初始不可见。 | 精确供应商目录;完整 Windows BM-03 为 660 tokens,≤400 仍未解决。 | +| 10 — 重读证据 | 最新成功普通供应商请求中的确切正文,加重新读取文件验证。 | context-read-dedup/many 与真实替换/投影;证据缺失即恢复全文。 | +| 11 — Bash | 宿主审批/jobs/subprocess、绝对执行器、120/600 秒、可选托管/Git Bash。 | shells-host/execution/manager;Windows 仍为部分约束,必须匹配运行时。 | +| 12 — PowerShell | 移除载荷与执行器覆盖;显式标准宿主升级计划与执行。 | 运行时管理器/传输回归;未安装操作者机器,需重启并检查自定义路径。 | +| 13 — 替换 | 显式选择 read/glob/grep,校验确切 OPS 定义及就绪;read 对应 inspect_local_file。 | 真实作用域挂载/就绪回归;write/edit 因 CAS 契约不同不提供替换。 | +| 14 — 迁移/设置 | 七个稳定行、保存覆盖、新默认值、panel 物理停止及内置严格表单。 | platform-defaults、settings-host/UI/client/carrier/runtime、panel-stop;自定义页面需可选离线承载行,真实 TUI 需独立宿主。 | +| 15 — 文档 | 按需 CSV/TSV、DOCX、受限经典 xref PDF,安全前缀如实报告。 | table/docx/pdf worker+host 与 BM-04;不支持格式、排版/OCR 仍按上文明确排除。 | +| 16 — 验收/发布 | 严格 TS/TSX、候选版本、双语文档、独立按需 BM-01–05。 | 功能 CI 与基准验收分别计数;BM-01/BM-03 失败,尚不能批准最终发布。 | + +## 堆叠评审与 staging 集成 + +当前 PR 为限制单次差异而使用前置分支作为评审基线,并非全部直接指向 release/0.4.0。例如 [1/16](https://github.com/T-Auto/dsh-ops/pull/264) 指向 staging,[2/16](https://github.com/T-Auto/dsh-ops/pull/265) 指向工具链分支,[14b/16](https://github.com/T-Auto/dsh-ops/pull/283) 指向 14a 设置分支;15a–d 与 16a 继续以前一切片为基线。PR 编号不是依赖顺序:第 13 部分较早创建,当前基线实际是第 12 部分。 + +维护者应按计划/依赖顺序将前置切片评审并集成到 release/0.4.0:1;2→3 与 4a→4b;随后 5a→5b、6a→6b→6c→6d、7–13、14a→14b、15a→15b→15c→15d、16a→16b→16c。前置已进入 staging 后,将下一评审调整到合适的当前基线,确认 diff 仅包含目标切片,集成有变化则重新运行必需 CI。不能仅因旧分支仍是评审基线,就把后续工作合入过时前置分支;保持共享历史,不由本次工作代替维护者合并 PR。 + +全部功能门禁和验收决策解决后,维护者才应评审 release/0.4.0 → main 的最终发布 PR。BM-01 需要明确处理固定无损工作负载/比较要求;BM-03 需要明确处理完整启动常驻与 400 token 预算的冲突,同时保留契约。本文不代替该决策;若批准变更要求,应公开同步 issue、文档和验收断言后重跑。版本提升、PR ready 或功能 CI 通过均不授权打 tag/发布。 + +## 验收状态 + +以下为固定 issue 工作负载、固定宿主的 Windows 已评审快照,不是通用性能或供应商账单结论。[基准门禁](基准门禁.md)记录复现命令、来源与最终重跑状态。 + +| 门禁 | 已评审结果 | +| --- | --- | +| BM-01 | 失败:候选5348字节,OPS0.3.1为8883字节,减少39.795%;官方读取5457字节,仅减少1.997%。请求源文本本身为4320字节。 | +| BM-02 | 通过:一个带工具轮次完成五处编辑,两个过期 CAS 对照均拒绝。 | +| BM-03 | 失败:完整供应商实际顺序660 tokens/3219字节,包含 Windows Bash 和真实 jobs;要求≤400。目录顺序661是另一种 tokenizer 边界结果。 | +| BM-04 | 通过:PDF 初始不可见,发现后所属供应商下一请求可见,兄弟 agent 不可见,并验证执行结果。 | +| BM-05 | 通过:凭据保护读取与工作区外写入被拒绝且不泄露被拒路径/内容;普通允许的外部读取是明确标注的允许对照。 | + +仅文件/发现入口的241 token 测量不能满足完整默认 BM-03。没有删除 schema 契约、请求源文本、门槛或工作负载来宣称成功。缺少 Bash 测试运行时会阻止完整 Windows 测量,不会缩小统计范围。最终候选须在所有修复后重跑功能与基准门禁;未解决失败必须继续明确记录,由维护者决定如何处理。 + +按[手动验收清单](手动验收.md)复核。本候选不合并 PR、不发布二进制,也不启用被关闭的发布工作流。 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6e5c6010..cc1b05bb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,9 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Prepare the 0.4.0 candidate version across owned npm/plugin and Rust workspace metadata without dependency changes. Audit paired release, usage, component, SDK, discovery and manual-validation documentation; keep BM-01 and full-default BM-03 failures explicit and separate from functional CI. No release/tag is created. +- 将自有 npm/插件与 Rust workspace 版本统一为 0.4.0 候选,不变更依赖;同步双语发布、使用、组件、SDK、发现和手动验收文档。BM-01 与完整默认 BM-03 失败继续明确记录,不以功能 CI 代替验收,不创建发布或 tag。 + - Keep table coverage honest beyond EOF and after metadata changes. Fresh PTC bundle pools follow the implemented catalogue; saved explicit operator pools remain unchanged. - 表格超出 EOF 时只统计实际跳过的行,文件变化后不声明页面完整;新 PTC 允许池遵循实现目录,保留已保存的显式选择。 @@ -32,6 +35,7 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Migrate credential fences, trusted path anchors and permission failures to strict TypeScript without changing emitted entrypoints or authority decisions. Preserve worker effect facts during denial normalization and assert typed host modes/unknown-input narrowing. - 将凭据围栏、可信路径锚点和权限错误迁移为严格 TypeScript,保持生成入口和权限决策不变;拒绝归一化保留 worker 效果事实,并验证宿主模式及未知输入的类型缩窄。 + ### Require benchmark readiness and valid controls (#261, 16/16) - Block startup scoring when any default Native tool is missing, duplicated or unexpected. Require successful allowed-read controls and exact effect-free denial codes, and independently decode Native source text before comparing bytes. Keep all fixtures, defaults and acceptance thresholds unchanged. @@ -44,7 +48,7 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Add opt-in real-host acceptance evidence (#261, 16/16) -- Run BM-01–05 with a scripted provider, exact0.3.1 and pinned official comparators, immutable sensitivity fixtures, visible-content assertions, numerical lossless lower bounds and JSON/Markdown reports. Manual cross-platform CI uploads failed and blocked evidence; no paid model calls or default gate changes. +- Run BM-01–05 with a scripted provider, exact 0.3.1 and pinned official comparators, immutable sensitivity fixtures, visible-content assertions, numerical lossless lower bounds and JSON/Markdown reports. Manual cross-platform CI uploads failed and blocked evidence; no paid model calls or default gate changes. - 新增真实宿主 BM-01–05 按需基准、独立基线及不可变敏感性夹具;保留准确正文断言、无损字节下界和失败/阻塞证据,不调用付费模型,不改变默认门禁。 ### Keep credential denial paths private (#261, BM-05) @@ -75,10 +79,12 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Preserve raw shell configuration through settings validation so ordinary startup and profile reload do not interpret normalized defaults as explicit inputs. - 设置校验保留原始 shell 配置,避免启动和配置重载将规范化默认值误认为显式输入。 + ### Connect runtime settings actions to the host gateway (#261) - Add typed, package-owned gateway descriptors for runtime inventory, Bash selection and explicit install/remove/PowerShell-upgrade previews. Execute only current one-use plans through host subprocess governance; preserve cancellation and partial-effect facts. - 通过宿主网关提供运行时清单、Bash 选择和显式安装/删除/PowerShell 升级预览;仅执行有效的一次性计划,并保留取消和部分副作用信息。 + ### Validate and persist host settings forms (#261) - Expose shell executor, discovery pool/always-loaded and per-tool replacement fields through detached settings descriptors and existing validators. Accepted changes persist through the host profile writer and reload the row; stale/invalid writes preserve the profile. @@ -93,9 +99,10 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Select official read/search replacements individually (#261) - Replace blanket hiding with `replaceTools: []`: explicitly select reviewed `read`, `glob`, or `grep` alternatives. Unselected tools remain visible without warning lists; only selected counterparts are required for scoped readiness. -- Legacy `strict`, `scope`, and `additionalOfficialTools` remain validated but inert. Document argument/coverage differences and exclude write/edit because their CAS contracts differ. Cordis validation is implemented; a browser multi-select is separate UI work. +- Legacy `strict`, `scope`, and `additionalOfficialTools` remain validated but inert. Document argument/coverage differences and exclude write/edit because their CAS contracts differ. Validated built-in row forms are available; optional browser multi-selects use the explicit client carrier described above. - 官方工具改为通过 `replaceTools: []` 逐项选择 `read`、`glob`、`grep` 替代流程;未选工具保持可见且无警告列表,只校验所选替代工具的作用域就绪状态。 -- 旧 `strict`、`scope`、`additionalOfficialTools` 仅校验不生效;文档说明参数与覆盖差异,写入/编辑因 CAS 契约不同而不提供替换。已实现 Cordis 校验,浏览器多选界面另行实现。 +- 旧 `strict`、`scope`、`additionalOfficialTools` 仅校验不生效;文档说明参数与覆盖差异,写入/编辑因 CAS 契约不同而不提供替换。内置行表单已提供严格校验;可选浏览器多选通过上文的显式客户端承载行启用。 + ### Leave PowerShell with its host (#261) - Remove the PowerShell npm payload dependency, shell payload release assembly and automatic @@ -113,7 +120,8 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Migrate the new shell executor, host contract, approvals, jobs and session integration to strict TypeScript. Validate the published definition against the pinned SDK with type-only imports; - retain existing emitted paths and explicit types for the remaining JavaScript adapters. + retain existing emitted paths. The remaining adapters are also strict TypeScript in the completed source migration. +- 将 shell 执行器、宿主契约、审批、jobs 和会话集成迁为严格 TypeScript,以仅类型宿主导入校验公开定义;保持发布路径,剩余适配器也已完成严格迁移。 ### Make Windows Bash an explicit optional runtime (#261) @@ -121,6 +129,7 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm removes only receipt-marked managed versions, and refuses linked managed directories. - Remove the automatic Bash npm payload dependency and release payload assembly. Build, CI and release gates explicitly acquire a pinned fixture, or validate an operator-provided offline one. +- 新增 Bash 状态/安装/删除及 Git/受管理副本选择;删除默认预览,仅移除有收据的非链接目录。移除自动 npm 载荷与发布组装,构建和门禁显式获取固定 fixture 或验证离线提供的副本。 ### Reuse host Bash approvals and background jobs (#261) @@ -130,12 +139,14 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Recheck session authority after approval and confinement; retain absolute executable selection, verified Windows IPC compatibility, honest partial enforcement and fail-closed runtime errors. - Keep script_path, graceMs and includeDiagnostics as documented OPS compatibility extensions. +- 必填命令描述,复用宿主前台/后台/提升结果、jobs、单次审批及 subprocess;默认 120 秒、上限 600 秒。审批与约束后重检权限,保留绝对执行器、IPC 兼容校验、如实部分约束及失败拒绝;script_path、graceMs、includeDiagnostics 为明确的 OPS 兼容扩展。 ### Preserve Bash cancellation through pending confinement preparation (#261) - Bash execution exposes one live handle for observation and settlement. Cancellation settles pending sandbox preparation immediately, and a late provider response cannot start a command. The first timeout or caller cancellation remains the reported stop cause. +- Bash 使用同一活动句柄观察和收敛;取消立即终止等待中的准备,迟到响应不能启动命令,保留最先发生的超时或取消原因。 ### Type optional TUI projections and panel rendering (#261, 16/16) @@ -189,10 +200,10 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Validate a restricted classic-xref PDF grammar, exact object boundaries and bounded values/references before extraction; verify actual Flate output, checksum and terminator. -- Reuse the reviewed exact compression dependency without new packages. Worker text extraction - follows separately; this foundation makes no public PDF-tool or complete-text claim. +- Reuse the reviewed exact compression dependency without new packages. The PDF reader uses + this foundation; the container validator alone makes no complete-text claim. - 新增有界传统 xref PDF 容器基础,校验对象边界、引用与实际解压;复用已核验压缩依赖, - 不新增依赖包,工具提取能力由下一切片接入。 + 不新增依赖包,PDF 读取器已接入此基础,容器校验本身不声明文本完整。 ### Add bounded DOCX main-body worker observations (#261, 15b/16) @@ -227,8 +238,9 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Keep Native startup to the bounded multi-file reader (#261, 9/16) - Publish `ops_read_many` from startup; make the single-file/hex reader, search, mutations and Native result paging discoverable. Routing describes only published tools, and the trial explicitly loads its advanced operations. -- Measure compact file/discovery schemas at 241 tokens and the current full surface with Bash at 610 tokens; the full 400-token BM-03 gate remains unresolved pending the redesigned shell contract. +- Measure compact file/discovery schemas at 241 tokens; the integrated full Windows Bash/jobs provider order is 660 tokens (catalogue order 661). The full 400-token BM-03 gate remains unresolved. - Native 启动仅常驻多文件读取器;单文件/hex、搜索、修改与引用分页按需加载。路由仅描述已发布工具;完整工具面含 Bash 仍超过 400-token 门禁,保留真实测量而不宣称通过。 + ### Publish progressive tools before the next real provider snapshot (#261, 7–8/16) - Activate pending Native and PTC selections at the owning session’s committed step end, before the pinned host captures the next provider schema. Drain owned publication during ordinary assembly without replacing host methods; mark internal probes to avoid recursive waits. @@ -273,6 +285,7 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add edit_many as a single-file atomic CAS edit operation with short receipts. Bound escaped read output using actual metadata bytes; an incomplete line retains its offset and hex guidance. - 新增多文件范围读取与单文件原子多段编辑;逐文件报告版本、错误及未读范围,保持权限边界与短回执。 + ### Spill oversized canonical output into bounded session snapshots (#261, 6b/16) - Preserve a useful head and original operation root, execution facts and mutation effects while @@ -293,14 +306,16 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm the lock and audit namespace and reports partial cleanup honestly. - Preserve Windows private-temp capability isolation; real confined create/reopen/readonly and foreign-session denial assertions cover the protected cache ACL. +- 新增可信信封内的会话引用,单结果 10 MiB/会话 100 MiB、序列化 LRU、物理字节计量与独立审计。每次检查活动权限/生产工具,默认会话生命周期,不嵌套引用;只读不改存储,清理保留锁与审计并如实报告部分失败,Windows 私有临时目录 ACL 有真实隔离回归。 + ### Discover advanced tools in the requesting Native scope (#261, 8/16) - Add `dsh-ops/discovery` and `ops_discover`: deterministic allowed-pool selection, a 1-5 per-call cap, and always-loaded choices. Publish pending capabilities only at the next model-step boundary, with scope isolation, permission withdrawal and owned cleanup. - Coordinate overlapping discovery rows and exact Native ownership with progressive PTC publication. Preserve unrelated external registrations and same-scope file handoff; include current batch reads/edits and the Native reference pager. - 协调重叠发现行、Native 精确归属和渐进式 PTC 发布,保留外部注册与同作用域文件交接;纳入现有批量读写及 Native 引用分页器。 -- Add pinned schema token measurement and real Cordis/native-worker lifecycle checks. Discovery alone measures 120 cl100k_base tokens; this is not a whole-OPS or PDF acceptance claim. +- Add pinned schema token measurement and real Cordis/native-worker lifecycle checks. The current discovery entry alone measures 94 cl100k_base tokens; this is not the full default surface. The real PDF next-request assertion is part of BM-04. - 新增按需发现组件与 `ops_discover`:允许池、单次 1-5 项上限和始终加载选择;仅在下一模型步骤挂载,并保证作用域隔离、权限变更撤销及卸载清理。 -- 新增固定分词器的 schema 计量和真实 Cordis/原生 worker 生命周期测试;发现入口为 120 cl100k_base tokens,不代表全体 OPS 或 PDF 验收通过。 +- 新增固定分词器的 schema 计量和真实 Cordis/原生 worker 生命周期测试;当前发现入口为 94 cl100k_base tokens,不代表完整默认面;真实 PDF 下一请求断言由 BM-04 验证。 ### Render Native file results as bounded, readable text (#261) @@ -321,6 +336,7 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add typed Outcome, search coverage and result-reference contracts plus explicit error/retry policies for subsequent adapter slices. Incomplete searches cannot claim a known total. - Pin Node 22 declarations and check valid/invalid contracts in the regular test runner. +- 统一能力名称、归属、呈现、并发及 schema 目录;策略和文件/PTC 契约迁为 TypeScript,加入 Outcome、搜索覆盖、结果引用及错误/重试类型,固定 Node 22 声明并验证正反类型用例。 ### Report search coverage and validate continuation state (#261) @@ -328,6 +344,7 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm totals after unreadable, encoding, size or execution-budget gaps. Bounded rescan continuations reject changed queries, authority and observed filesystem state; incomplete traversals require a narrower fresh query. Search retains physical LF accounting and bounded output prefixes. +- 搜索区分扫描与分页,统计跳过原因,遇读取/编码/大小/预算缺口不提供全量总数;重扫续取校验查询、权限与文件状态,不完整遍历要求缩小新查询,保留物理 LF 行号和有界前缀。 ### Version the one-shot worker request protocol (#261) @@ -335,6 +352,7 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm budgets while retaining existing host authority checks and canonical success results. Reject malformed envelopes before effects; interrupted or oversized mutation receipts never imply no effects. Keep explicit v0 decoding for benchmark compatibility with bounded transport defaults. +- 单次 worker 请求采用严格 v1、调用 ID、组件来源及执行/输出预算;副作用前拒绝错误信封,修改执行后中断/超限不假装无副作用,保留有界 v0 解码供基准兼容。 ### Split the native kernel into workspace crates (#261) @@ -342,6 +360,7 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm worker and existing authority, metadata, no-follow, and audit rules. Test the complete Cargo workspace and include every path dependency in published source packages. - Include workspace crates and platform metadata adapters when checking for stale benchmark workers. +- 拆分协议、文件、搜索、批处理和引用存储 crate,仍按调用启动并保留权限、元数据、nofollow 与审计;完整 Cargo workspace、发布源包及基准陈旧检查均包含路径依赖和平台元数据适配器。 ### Build plugin source into the existing JavaScript package layout (#261) @@ -351,8 +370,11 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Build before verification and packaging, and document the build step for direct suite runs and source installations in both contributor guides. Published packages keep shipping `lib/` without `src/`; this toolchain slice does not change tool behaviour. +- 源码移至 src、构建到既有 lib 路径并复制资源,验证和打包前先构建;发布包保留 lib 不含 src。初期 JavaScript 迁移支持现已在第 16 步移除,全部插件源为严格 TypeScript/TSX。 + +### Earlier file-tool compatibility repairs retained in 0.4.0 -### File-tool discoverability, honest grep pages and compact inspect rendering +These entries record the earlier eleven-tool baseline; the current catalogue, Native text projection and SDK surface are described above. 以下保留早期十一工具基线修复;当前目录、Native 文本投影与 SDK 以上方 0.4.0 条目为准。 - All eleven file argument schemas render concrete types in the pinned host SDK. Bounds unsupported by that SDK are explicit descriptions backed by native validation; @@ -381,6 +403,8 @@ is single-path, bounded UTF-8 text or hex. A contract regression checks both tab ### The 0.3.2 real-machine findings, fixed (#204, #235, #236, #237, #240) +This is the earlier adapter repair history. In 0.4.0 the public Bash adapter returns schema-valid failure values for invalid arguments; its contract above supersedes the legacy thrown-refusal behavior below. 以下是旧适配器修复历史;0.4.0 公开 Bash 对无效参数返回符合 schema 的失败值,上方契约取代下文旧抛错方式。 + Four findings from the 0.3.2 Linux x64 session run in #204. Three are behaviour changes, and each one is pinned by a suite that would fail without it. @@ -1040,7 +1064,7 @@ No new tools, components, configuration keys, or runtime capabilities are includ Published to the public npm registry on 2026-10-08 as `dsh-ops@0.2.1` with `@dsh-ops/fastctx-win32-x64@0.2.1`, `@dsh-ops/bash-win32-x64@0.2.1` and `@dsh-ops/pwsh-win32-x64@0.2.1`. Packed sizes and registry digests: -[docs/release-0.2.1.md](docs/release-0.2.1.md). +[docs/release-0.2.1.md](release-0.2.1.md). - Assemble complete digest-verified PortableGit/bash and PowerShell 7 runtime packages instead of metadata-only pins; inject all three Windows x64 runtime dependencies in the publish artifact. - Add explicit-profile install/status/uninstall delegation to the official DSH manager; map tui to dsh-tui and use Desktop's installation-owned CLI. @@ -1057,7 +1081,6 @@ Issue #9 showed that a host package-manager prune can leave the bash payload dec The Electron `workspace-write` `0xC0000142` ACL-runner failure is a DSH host sandbox issue outside this plugin's subprocess API. This release documents that boundary and does not claim to repair the host's Windows ACL runner. - ### Changed - Add `ops_bash.script_path` for executing existing scripts without placing their contents in a large JSON string; retain `command` for short commands and pipelines. diff --git "a/docs/PTC\346\214\211\351\234\200\345\212\240\350\275\275.md" "b/docs/PTC\346\214\211\351\234\200\345\212\240\350\275\275.md" index 9ebb6cc3..bb18ae37 100644 --- "a/docs/PTC\346\214\211\351\234\200\345\212\240\350\275\275.md" +++ "b/docs/PTC\346\214\211\351\234\200\345\212\240\350\275\275.md" @@ -13,7 +13,7 @@ PTC 行配置由统一配置入口验证: | binaryPath | 打包的 worker | 可选的绝对 worker 可执行文件路径。 | | required | false | worker 不可用时令组件启动失败。 | -高级名称为 grep、glob、replace、write_file、edit_file、diff、stat、copy、move、remove、read_many、edit_many、batch。每次原子验证 1–5 个名称;未知键、带公开前缀的名称、常驻 API 与未实现功能均拒绝。配置保存为数组,本切片不提供浏览器多选编辑器。 +高级名称为 grep、glob、replace、write_file、edit_file、diff、stat、copy、move、remove、read_many、edit_many、table_read、doc_read、pdf_read、batch。每次原子验证 1–5 个名称;未知键、带公开前缀的名称、常驻 API 与未实现功能均拒绝。内置 PTC 行表单可编辑这些经过验证的数组及 required、binaryPath,并通过宿主持久化。可选自定义浏览器页面目前覆盖发现、替换和 shell,不含 PTC。新 profile 默认派生全部已实现高级 API;已保存的显式 allowedTools 数组继续优先。包括三个常驻入口在内,完整 SDK 有 19 个 API。详见[配置参考](配置参考.md)与[文档边界](原生内核.md)。 选择绑定准确的活动代理;同级与子代理不继承其他代理加载的 SDK。同一代理内,已加载选择可保留至组件或代理卸载。沙箱模式变化会清空排队请求并取消活动调用;每次 worker 调用仍重新检查当前权限。卸载会撤回自身注册并拒绝保留的回调。切换至 Native/both 会移除 PTC API,并仅恢复文件组件此前可见的 Native 定义;重叠 PTC 配置行由最近作用域的行负责,同一作用域内后挂载行优先,卸载后恢复此前的行;不修改共享注册表。 diff --git a/docs/benchmark-gates.md b/docs/benchmark-gates.md index d36cb99a..a7e87362 100644 --- a/docs/benchmark-gates.md +++ b/docs/benchmark-gates.md @@ -35,11 +35,11 @@ The 0.3.1 baseline is commit `4b51b6333213c376c8b7c173c12f35b408fdec2d`. The pre The earlier worker/renderer-only 3×8 probe recorded 2,256 baseline and 1,949 candidate bytes (13.608% reduction), with `modelRounds: null` and `hostAuthorityExercised: false`. This remains limited historical evidence. -The subsequent real AgentLoop primary case recorded 8,883 OPS 0.3.1 bytes, 5,457 official-reader bytes and 6,239 candidate bytes. Replaying those unchanged canonical receipts through the compact renderer gives 5,348 bytes: 39.795% below 0.3.1, still below 40%. The final integrated host rerun confirms those same byte counts and still fails the primary target. +The subsequent real AgentLoop primary case recorded 8,883 OPS 0.3.1 bytes, 5,457 official-reader bytes and 6,239 candidate bytes. Replaying those unchanged canonical receipts through the compact renderer gives 5,348 bytes: 39.795% below 0.3.1, still below 40%. The reviewed host rerun confirms those same byte counts and still fails the primary target. The primary source lines alone occupy 4,320 UTF-8 bytes. Forty percent below the 5,457-byte official result requires at most 3,274.2 bytes, so that comparison cannot pass without source omission or compression even before path/revision/coverage labels. The report includes this numerical lower bound and an illustrative absolute-path/range label size; shared-base layouts can reduce the latter. Long source lines likewise prevent universal percentage savings. Output paths, host contracts and formatter revisions affect measured bytes, so all exact receipts remain attached. -## Integrated Windows result (2026-10-19) +## Historical Windows result (2026-10-19) Candidate `02577ed140c16e1739bf2588172ee38d9370ec52` had a clean worktree, host `0.2.0-rc.2`, Node `25.2.1`, and the source-built worker SHA-256 `b29b8f130e109ec507d808b8a488d9461b08f28353222b41b40631cd062702b8`. The local worker used Cargo `1.99.0`; the manual CI workflow pins `1.97.1` and has not yet supplied cross-platform results. @@ -56,6 +56,12 @@ The candidate uses one tool-bearing round plus the final response. Sequential co The retained raw local artifact is `040-benchmark-final-pinned-bash.json` with SHA-256 `cc94fcb4f444a39858795ef9003553de54735eff128191f3037a21666db1de98`; its companion Markdown summarizes all gates. Full acceptance remains false; the baseline and budget-scope decisions remain unresolved. +## Reviewed complete rerun + +Clean candidate `2865936` includes reversible source escaping and the independent integrity controls. Its retained local evidence is `040-benchmark-reviewed-complete.json`, SHA-256 `380ac6fbd392dd0f7805c03c66aa6ba9d88f88bcf4eb5a60ba01868f152488bb`. BM-01 remains 5,348/8,883/5,457 bytes; BM-03 remains 660 tokens/3,219 bytes in actual provider order. BM-02, BM-04 and BM-05 pass; all-five acceptance remains false. + +The corrected Unicode sensitivity is 2,616 candidate / 4,185 OPS 0.3.1 / 2,469 official bytes: 37.491% below OPS and 5.954% above official (a −5.954% reduction). The older 40.358% result above belongs to non-injective escaping and is historical only. Whole startup message JSON is separately 788 tokens/3,293 bytes; it is not the tool-schema gate. The final versioned release candidate still needs its own rebuilt-worker rerun. + ## Integrity checks after review BM-03 now requires the exact resident file catalogue plus discovery (and the full Bash contract on Windows) before a token score can pass. Missing, duplicate or unexpected tools block the gate with a named reason. BM-05 also requires its ordinary outside-read control to succeed with exact text, and checks `CREDENTIAL_DENIED` / `POLICY_DENIED` with `effectState: none` for the denied probes. Native line checks independently decode the documented escapes, including literal backslashes; they no longer mirror the renderer. These checks change no workload, default, budget or threshold. The result snapshot above remains historical evidence for its named commit; it is not reclassified as passing. diff --git a/docs/component-reference.md b/docs/component-reference.md index c60676ea..94b3d61f 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -7,7 +7,7 @@ What each of the seven components publishes, with parameters and permission requ | Row `name` | Description | Default | Published tools | | --- | --- | --- | --- | | `dsh-ops/shell` | Prefer bash as the terminal, with host PowerShell for operations bash cannot cover | Windows on; Linux/macOS off | `ops_bash`; host `pwsh` keeps its own executor | -| `dsh-ops/file` | Faster, higher-performance Rust file retrieval with more concise, token-efficient output | On | `ops_read_many`; advanced tools load through discovery | +| `dsh-ops/file` | Bounded Rust file retrieval and text output for the model | On | `ops_read_many`; advanced tools load through discovery | | `dsh-ops/ptc` | Typed file APIs and progressive SDK in PTC sessions | On | `ops_inspect_local_file`, `ops_result`, `ops_sdk`; advanced APIs load through the SDK | | `dsh-ops/discovery` | Advanced Native tools on demand | On | `ops_discover` | | `dsh-ops/panel` | Data capture, off at the switch: new conversations write `/.dsh-ops/panel/.json` plus the viewer `panel.html` | Row off; capture defaults true | None; see the [configuration reference](configuration-reference.md) | @@ -20,6 +20,8 @@ The former `dsh-ops/background` component (FastCtx background jobs: `ops_run`, ` ## Tool summary +The shared catalogue has 21 entries. The generated PTC SDK contains 19 APIs: 16 file/document tools plus batch, result and sdk; bash and discover belong to other entry points. Catalogue membership does not mean residency or permission. Fresh PTC pools follow the implemented catalogue; saved explicit pools remain authoritative. Document tools load on demand. + | Tool | Purpose | Full-access requirement | | --- | --- | --- | | `ops_read_many` | Resident bounded UTF-8 ranges from 1-32 files | No command permission gate¹ | @@ -27,12 +29,16 @@ The former `dsh-ops/background` component (FastCtx background jobs: `ops_run`, ` | `ops_grep` | Rust regex search, multiple file filters, counts/summaries | No command permission gate¹ | | `ops_glob` | Multiple path patterns with exclusions | No command permission gate¹ | | `ops_replace` | Mechanical cross-file literal replacement; use host edit for precise changes | No command permission gate¹ | +| `ops_write_file` / `ops_edit_file` / `ops_edit_many` | File writes and atomic edits within one file, retaining revision/CAS | Writes follow current authority¹ | +| `ops_diff` / `ops_stat` | Compare text and inspect metadata | No command permission gate¹ | +| `ops_copy` / `ops_move` / `ops_remove` | Fixed file operations | Writes follow current authority¹ | +| `ops_table_read` / `ops_doc_read` / `ops_pdf_read` | Bounded CSV/TSV, DOCX and restricted PDF readers; see below | No command permission gate¹ | | `ops_bash` | Preferred general bash command executor; `command` for short commands, `script_path` for long scripts | Confined in restricted modes; matched Windows x64 private IPC runtime | | `ops_batch` | Preflight and execute copy / move / remove with audit and recovery | See the PTC notes | | `ops_result` | Bounded result references: put, page, release | See the PTC notes | | Host `pwsh` | Windows-native operations using the host-selected PowerShell | Host policy | -¹ **Fixed file operations use a trusted-operation policy fence, matching DSH fs-sandbox, not arbitrary-code isolation.** OS-readable paths are readable in all modes; read-only rejects replacement, including its dry run; workspace-write permits writes under the workspace and this session's private temporary directory, and full access adds no write fence. The fixed Rust worker uses host subprocess governance plus no-follow traversal, budgets and CAS. Bash/run_code still require OS confinement; no unconfined fallback exists. On Windows, owned workspaces automatically receive directory-only WRITE_OWNER for the host grant prerequisite, with DACL backups; no recursive ACL edits or ownership changes. That preparation runs the package's own script as the current user before sandbox confinement applies, only in workspace-write, with `-ExecutionPolicy Bypass` (Group Policy still wins). +¹ **Fixed file operations use a trusted-operation policy fence, matching DSH fs-sandbox, not arbitrary-code isolation.** OS-readable paths are readable in all modes subject to the credential fence and current scoped authority; read-only rejects replacement, including its dry run; workspace-write permits writes under the workspace and this session's private temporary directory, and full access adds no write fence. The fixed Rust worker uses host subprocess governance plus no-follow traversal, budgets and CAS. Bash/run_code still require OS confinement; no unconfined fallback exists. On Windows, owned workspaces automatically receive directory-only WRITE_OWNER for the host grant prerequisite, with DACL backups; no recursive ACL edits or ownership changes. That preparation runs the package's own script as the current user before sandbox confinement applies, only in workspace-write, with `-ExecutionPolicy Bypass` (Group Policy still wins). --- @@ -40,7 +46,7 @@ The former `dsh-ops/background` component (FastCtx background jobs: `ops_run`, ` Only the bounded multi-file reader `ops_read_many` is resident; `ops_inspect_local_file`, including hex reading, is discoverable. The advanced contracts below are activated through [tool discovery](discovery.md) or explicit always-loaded choices. Routing lists only the tools currently published. -File paths resolve relative to the DSH session cwd. `ops_read_many` takes a `files` array of paths with independent 1-based offsets and limits; single-file operations take `path`. The Native reference pager accepts the original reference `root`. No file tool accepts `workspace`, `cwd` or `file_path` aliases. All are UTF-8 first with bounded output; glob and grep do not traverse symlinks, junctions, reparse points, hidden files or `.git`. +File paths resolve relative to the DSH session cwd. `ops_read_many` takes a `files` array of paths with independent 1-based offsets and limits; single-file operations take `path`. The Native reference pager accepts the original reference `root`. No file tool accepts `workspace`, `cwd` or `file_path` aliases. Text tools are UTF-8 first; document tools use their own bounded format/encoding contracts. glob and grep do not traverse symlinks, junctions, reparse points, hidden files or `.git`. When the host provides its `fs` service, `ops_inspect_local_file` reads and real `ops_replace` writes are recorded in the host's read-before-edit table (`fs/observed`) exactly like host `read`/`edit`: a file read with ops can be edited with host `edit` directly, and an ops write does not make the host's earlier read look like an external change. `ops_grep`, `ops_glob` and `dryRun` record nothing. @@ -48,7 +54,7 @@ When the host provides its `fs` service, `ops_inspect_local_file` reads and real Read bounded UTF-8 text lines, or a hex view of one path. Directories are rejected — use `ops_glob` or `ops_grep` instead. -The model rendering shares a homogeneous page's `path` once at the top level; raw execution/PTC results retain per-item paths. All other fields, including revision, paging and in-line truncation markers, are preserved. Worker byte limits and long-single-line continuation are unchanged; rendered bytes are not measured tokens. +Native emits a file header, numbered escaped source lines and revision/continuation notices. PTC retains canonical typed records. Backslashes, controls and Unicode line separators are reversibly escaped; source text cannot forge extra rows. Worker byte limits and long-line continuation remain explicit. See [Native range rendering](native-ranges.md). Rendered bytes are not measured tokens. | Parameter | Type | Notes | | --- | --- | --- | @@ -106,10 +112,16 @@ Cooperating writers serialise on a persistent, hidden update-lock sidecar beside | `maxReplacements` | integer 1–100000 | Hard replacement limit | | `dryRun` | boolean | Never writes | +### Document readers + +`ops_table_read` reads CSV/TSV string cells (UTF-8 by default, with explicit UTF-16LE/BE support). `ops_doc_read` reads DOCX main-body paragraphs and table coordinates, not legacy binary DOC. Both default to offset 1 and limit 200 (maximum 1000), with a 32 MiB input bound. `ops_pdf_read` handles only PDF 1.3–1.7 classic xref and standard Type 1 font text, up to 16 MiB/32 pages; offset defaults to 1 and limit to 10 (maximum 32). It excludes OCR, embedded fonts and modern object/xref streams. + +Each reader separates scan, page, display and reference-capture completeness. A later error may retain safe preceding complete records, but cannot produce a false full total or revision; an offset cannot bypass a physical scan gap. See [native kernel contracts](native-kernel.md) for all format bounds, encoding rules and recovery guidance. + ### Results and errors -- **File tools, `ops_batch` and `ops_result` never throw.** Failures resolve as values with `status` `"error"` or `"rejected"` (authorization refusal) plus `error.code` / `error.message` / `error.nextAction`; success is `"completed"` (`ops_batch` also reports `planned` and related states). In PTC, a `try/catch` alone misses these failures — check `status`. -- **`ops_bash`** resolves command results as values: `status` (`completed` only for exit code 0 with no error, permission refusal, signal or timeout; otherwise `failed`), `exitCode`, `stdout` / `stderr`, `error`, `permission`. It throws (a `ToolCallError` in PTC) only for **invalid arguments** or an **unavailable executor**. +- **File tools, `ops_batch` and `ops_result` return failures as values.** PTC Outcomes use completed or partial data, or rejected/conflict/error/unknown with an error code, effect state and retry policy. Batch plan/run states remain in typed data. A try/catch alone misses returned failures; check status and coverage before data. Native projects the same facts into text. +- **`ops_bash`** returns host-compatible `foreground`, `background` or `promoted` values. Failures, including invalid arguments and unavailable executors, return values rather than throwing. A foreground failure retains the string `error` plus structured `failure` code, effect state and retry policy. Effects after launch can be unknown; do not replay automatically. Collect background output through host job tools. - Host tools (`read` / `write` / `edit` / `pwsh` …) throw `ToolCallError` on failure. --- @@ -211,7 +223,7 @@ Only the selected OPS counterparts must be registered and visible as exact OPS-o The resident `ops_read_many` is not the reviewed replacement for `read`. Before selecting `read`, configure discovery `alwaysLoaded: [inspect_local_file]` or ensure that single-file capability is already active in that Native scope. Apply the same readiness rule to `glob` and `grep`. Internal presentation inspection lets discovery publish these configured tools during startup; ordinary prompt assembly and dispatch remain blocked until they are ready. -The component exports a real `StandardSchemaV1` validator for Cordis. It does not claim a generic schema creates the host UI: the pinned host requires a browser `plugins.row.config` contribution for a selector. See the [configuration reference](configuration-reference.md#per-tool-official-replacements-dsh-opshide-official). +The component exports validated settings with public JSON schema descriptors for built-in host row forms. Optional browser multiselect pages require the explicit client carrier migration; see [configuration](configuration-reference.md). --- diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index d4f2a9a7..f26d7994 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -83,25 +83,27 @@ A `dsh-ops/background` row left over from an earlier version loads as a no-op st ## 🔴 Which key belongs to which component -`dsh-ops/tui` uses only `maxSessions`, `maxActive` and `recentLimit`; see [sidebar limits](tui-panel.md#limits). The following legacy configuration does not apply to the tui row. The table below gives the **owning component** for each key. Putting a key on a row that does not own it is rejected by configuration validation, which names the offending key. +`dsh-ops/tui` uses only `maxSessions`, `maxActive` and `recentLimit`; see [sidebar limits](tui-panel.md#limits). The table lists where each setting is used. File/root and shell retain their shared legacy validator, so some compatibility keys may be accepted without affecting that row. PTC, discovery, panel, TUI and replacement use their own strict key sets; do not copy general file/shell settings into them. The Loader row switch is disabled, outside config. | Key | Type | Default | Owning component | | --- | --- | --- | --- | -| `configUpdateVersion` | non-negative integer | `0` | any (migration marker) | -| `binaryPath` | string path | — | file / ptc | +| `configUpdateVersion` | non-negative integer | `0` | file / shell (migration marker) | +| `binaryPath` | absolute path | — | file / ptc / discovery | | `bashPath` | string path | — | shell | | `publishBashTool` | boolean | `true` | shell | -| `promptPolicy` | boolean | `true` | all | -| `extraGuidance` | string | empty | all | -| `required` | boolean | `false` | all | +| `promptPolicy` | boolean | `true` | file / shell | +| `extraGuidance` | string | empty | file / shell | +| `required` | boolean | `false` | file / ptc / discovery | | `shellPolicy` | `advise` \| `deny-host-shell` | `advise` | shell | | `deniedHostTools` | string array | `[pwsh, bash, pwsh_persistent]` | shell | | `allowSystemShellFallback` | boolean | `false` | shell | -| `denyCredentialReads` | boolean | `true` | file / ptc | +| `denyCredentialReads` | boolean | `true` | file / discovery | | `capture` | boolean | `true` | panel (row defaults off) | | `textLimit` | integer | `2000` | panel | | `maxSessionBytes` | integer | `8388608` | panel | +PTC accepts exactly binaryPath, required, allowedTools and alwaysLoaded; its credential fence stays enabled. Its always-loaded list is independent of the dynamic allowed pool. Discovery additionally owns enabled, maxResults and denyCredentialReads, and requires alwaysLoaded to be a subset of allowedTools. See [PTC settings](ptc-sdk-loading.md) and [discovery settings](discovery.md) for defaults and exact names. Replacement owns replaceTools plus its inert legacy compatibility keys; panel and TUI have only their documented capture/display fields. + ### The credential fence (`denyCredentialReads`) Preflight credential denials retain `rejected`, `CREDENTIAL_DENIED`, no-effect and no-retry guidance. Their Native text and PTC values omit the denied path, resolved parents and matching policy rules. @@ -129,7 +131,7 @@ config: publishBashTool: true shellPolicy: advise allowSystemShellFallback: false - # bashPath: 'C:\tools\bash.exe' # honoured in danger-full-access only; see bashPath below + # bashPath: 'C:\tools\bash.exe' # restricted Windows execution requires matching MSYS/IPC; see below ``` The `dsh-ops/file` row: @@ -202,14 +204,14 @@ With the slim Native defaults, configure discovery `alwaysLoaded: [inspect_local **Migration from 0.3:** `strict`, `scope`, and `additionalOfficialTools` are accepted only for compatibility and validated with their old types/values. They grant no hiding authority, including `scope: extended` and aliases. An existing row containing only those keys becomes inert. Explicit `replaceTools` is the sole source of selections even if legacy keys remain. No saved profile is rewritten by this component. -Cordis validates configuration through `StandardSchemaV1`; detached public settings descriptors expose editable arrays in the built-in Settings/ConfigEditor. Accepted changes persist and reload the row. The custom browser multi-select remains separate UI work. +Cordis validates configuration through `StandardSchemaV1`; detached public settings descriptors expose editable arrays in the built-in Settings/ConfigEditor. Accepted changes persist and reload the row. The optional browser multi-select uses the client carrier described above; the default legacy row does not activate it. --- ## Prompt registration -Prompts are registered as independent runtime sections, **not written to AGENTS.md**: shell OFF withdraws bash/pwsh routing; file OFF withdraws native file guidance. The file component uses its independent Rust worker. Four native file tools and `ops_bash` are added by default. These changes reduce fixed declaration costs. The Minimal preset overrides additional system sections; PTC mode calls the underlying tools through the SDK with unchanged permission gates. +Prompts are registered as scoped runtime sections, not written to AGENTS.md. Disabling shell withdraws its routing; disabling file withdraws its Native file guidance. Fresh Native file publishes only ops_read_many, discovery publishes ops_discover, and eligible Windows shell scopes add ops_bash. Advanced Native tools are discovered or explicitly always loaded. PTC owns its separate typed SDK and current permission checks. Fixed-schema costs are measured separately; full-default BM-03 remains unresolved. --- @@ -221,8 +223,7 @@ Prompts are registered as independent runtime sections, **not written to AGENTS. ## Host settings forms -The enabled shell, discovery and official-replacement rows expose validated fields to the host -settings service. Saving uses the profile revision the form read; a stale revision or invalid +All seven enabled rows expose their supported validated fields to the host settings service. Saving uses the profile revision the form read; a stale revision or invalid selection is rejected before writing. The host persists accepted changes and reloads the affected row, so the running configuration follows the saved selection. Ordinary row fields are preserved. A disabled row has no editable form; re-enable it with the host's built-in row switch first. diff --git a/docs/discovery.md b/docs/discovery.md index eac7bb1c..19f54903 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -11,7 +11,7 @@ The seven-row bundle enables this component by default and preserves existing na config: enabled: true maxResults: 3 - allowedTools: [inspect_local_file, edit_many, result, glob, grep, replace, write_file, edit_file, diff, stat, copy, move, remove] + allowedTools: [inspect_local_file, edit_many, result, glob, grep, replace, write_file, edit_file, diff, stat, copy, move, remove, table_read, doc_read, pdf_read] alwaysLoaded: [] ``` @@ -24,7 +24,7 @@ The seven-row bundle enables this component by default and preserves existing na | `binaryPath` / `required` | Automatic / `false` | Worker selection and activation failure behavior, as for native file tools. | | `denyCredentialReads` | `true` | Preserve the native file credential fence. | -Configuration arrays are copied and deduplicated; unknown configuration keys are errors naming the key. Search is a deterministic keyword match over reviewed metadata, with stable tie ordering; it does not call a model. `query` is nonempty and at most 512 characters; extra action arguments are rejected. PDF, Word and table readers cannot be discovered until implementations pass their own acceptance review. The current catalogue does not pretend those formats are ready. +Configuration arrays are copied and deduplicated; unknown configuration keys are errors naming the key. Search is a deterministic keyword match over reviewed metadata, with stable tie ordering; it does not call a model. `query` is nonempty and at most 512 characters; extra action arguments are rejected. The implemented table_read, doc_read and pdf_read capabilities are discoverable with the bounded CSV/TSV, DOCX and restricted PDF contracts in [native-kernel.md](native-kernel.md). Binary DOC, OCR and unsupported PDF encodings are not advertised as usable. Every activated file tool keeps its declared path base, argument schema, Native rendering, worker permission checks and revision/CAS contract. Read-only scopes cannot activate mutation tools. A sandbox-mode change clears that session's pending requests, cancels its active calls and re-evaluates registrations; other sessions retain their choices. Disposing an agent or unloading the component removes registrations and invalidates cached definitions. Pure PTC scopes do not publish this Native discovery surface; the PTC adapter owns its SDK. @@ -34,11 +34,11 @@ Only this plugin's scoped `ctx.tools.register`, `restrict`, prompt sections and The deterministic entry measurement uses `js-tiktoken@1.0.21` with `cl100k_base`, encoding compact JSON of the pinned host `ToolSchema` fields (`name`, `description`, `parameters`). The discovery entry is **94 tokens / 410 UTF-8 bytes**. This is a reproducible schema measurement, not provider billing, and excludes tool-message framing, prompt guidance and other OPS components. The tokenizer is a test-only dependency published on 2025-08-09; its locked `base64-js` dependency is also test-only. -The default file/discovery surface is now `ops_read_many` plus `ops_discover`: **241 tokens / 1094 bytes** in actual host assembly order. The reader accepts multiple file ranges in one startup call; `ops_inspect_local_file` (including hex), search, mutations and Native result paging are discoverable. Routing names only published tools. Adding the current Bash contract gives **610 tokens / 2915 bytes**, so the full default BM-03 gate remains unresolved. These figures preserve numeric bounds, defaults, path bases and error contracts. The redesigned Bash contract must be measured again when integrated; operator always-loaded choices also expand the initial surface. +The default file/discovery surface is now `ops_read_many` plus `ops_discover`: **241 tokens / 1094 bytes** in actual host assembly order. The reader accepts multiple file ranges in one startup call; `ops_inspect_local_file` (including hex), search, mutations and Native result paging are discoverable. Routing names only published tools. The final Windows provider order with the full Bash/jobs contract measures **660 tokens / 3219 bytes**; catalogue order gives 661 tokens. Both exceed the full BM-03 limit of 400. Numeric bounds, defaults, path bases and permission contracts remain intact. Operator always-loaded choices expand the initial surface. See [benchmark evidence](benchmark-gates.md). -BM-04 runs through the real Cordis tool/prompt services and native worker: an implemented `stat` capability is absent before discovery, remains absent through current-step assembly, appears in the immediately following real AgentLoop provider request, and executes successfully. PDF is unimplemented, so this is the lifecycle gate, not a claim that PDF acceptance passed. +BM-04 now runs the implemented PDF reader through the real AgentLoop: its schema is absent initially, remains pending in the current round, appears in the owning next provider request, stays absent for a sibling, and then returns the fixture text. This verifies the supported PDF subset, not general PDF/OCR coverage. -The browser configuration multi-select is separate work: plain configuration validation does not automatically generate a host selector. Its UI must use the pinned host's browser plugin configuration APIs. +Built-in settings persist validated arrays. Optional browser multi-selects use the pinned host client APIs and need the explicit offline carrier described in [configuration](configuration-reference.md); they are not activated by the default legacy file row. Overlapping discovery rows use the nearest owning scope; the last row mounted in the same scope wins until it is unloaded. File rows may mount before or after discovery. Only exact OPS-owned Native registrations are yielded or masked; unrelated external registrations remain visible. PTC Native shadows retain their exact origin, and discovered activations cannot be inherited by another agent. Native `result` is page/release only and may be discovered in read-only mode; the worker still rejects forbidden release actions. Resident `read_many` and discoverable `edit_many` retain their existing per-file completeness and CAS contracts. diff --git a/docs/manual-validation.md b/docs/manual-validation.md index c501b6c9..3e4ff065 100644 --- a/docs/manual-validation.md +++ b/docs/manual-validation.md @@ -1,77 +1,69 @@ -# dsh-ops 手动验收清单 - -> **历史记录(0.2.x,FastCtx 时期)。** 本清单描述的是基于 FastCtx 的 0.2.x 版本。FastCtx 后台组件(`dsh-ops/background`、`ops_run`/`ops_run_background`/`ops_job_*`)、FastCtx 进程生命周期以及 `scripts/measure-schemas.mjs` 均已移除,「任务与输出隔离」「进程生命周期」「数字复核」以及涉及后台组件、`enableShellTools`、FastCtx 的条目不再适用;文件工具自 0.3.0 起由独立 Rust worker 提供。当前行为以 [组件参考](组件参考.md)、[配置参考](配置参考.md) 与 `npm run verify` 为准。 - -0.2.5 曾把 `npm run verify` 接成自动门禁。它当前是否由 CI 自动执行,以 `.github/workflows/` 下各工作流的 `on:` 触发器为准(2026-10-17 现取:`ci.yml` 在推送到 `main` 与 pull request 上运行;`release.yml` 的 `on:` 含 `v*` tag,但该工作流当前被单独停用——这一点在 `on:` 里看不出来,见仓库的 Actions 设置,或 `gh api repos/T-Auto/dsh-ops/actions/workflows` 的 `state` 字段);本清单只描述本机怎么跑:安装 devDependencies 后运行 `DSH_OPS_REQUIRE_RUNTIME=1 npm run verify`(PowerShell 用 `$env:DSH_OPS_REQUIRE_RUNTIME='1'; npm run verify`)。以下清单是另行人工验收,不等于这些操作都已执行。0.2.4 时“不补/改测试、不运行旧套件”的要求与记录仅属历史,现已被此次修复替代。替换已加载源码后按宿主要求重载/重启。 - -## 系统 shell 回退与载荷 - -- 默认 `allowSystemShellFallback=false`;临时隔离测试中缺少随包/provisioned bash、但 PATH/常见目录有 bash 时,插件不执行该系统副本,后台工具也不通过 FastCtx 自动探测绕过。 -- `true` 是显式信任系统 bash 的选择;`bashPath` 是另一种显式选择。首次启动把无迁移标记的旧 `true` 改成 `false` 并通过 loader 写回 `configUpdateVersion: 1`;确认之后手动改回 `true`、重启/HMR 仍保留。未启用的后台行首次启用才迁移。更新配置修改区见 README 与 `lib/config-updates.js`。 -- 此开关不控制宿主工具;`shellPolicy=deny-host-shell` 才拒绝宿主 pwsh/bash/pwsh_persistent,即使没有 ops 命令组也生效。它也不是文件系统沙箱。 -- 主包 0.2.5 固定复用三个 0.2.1 载荷;逐个核对 `provenance.json` 与真实二进制哈希。对应表见 PROVENANCE;registry tarball 校验和见 release-0.2.1 文档。 -- issue #4 的外部贡献者报告:DSH 0.2.0-rc.2 desktop / Windows x64 的 0.2.4 真实 profile 安装及无需重启热加载成功。属于外部观测,不冒充维护者本机 0.2.5 的端到端验收。 - -## 工具发布与提示词 - -- 组件列表恰为三个:shell/file 默认开启,background 默认关闭。分别核验组件名称、中文介绍、图标,以及实际版本。 -- `workspace-write`、`read-only`:只有开启的四个 ops 文件工具;没有 ops_bash/后台工具,提示词不广告不存在的命令。 -- `danger-full-access`:默认四个文件工具 + ops_bash;仅后台组件开且 enableShellTools=true 时增加四个后台工具。任何组合都不发布 ops_run。 -- 文件与后台共享一个 FastCtx;关文件仍能管理后台,关后台仍能读搜改,重开不积累注册。 -- `enableShellTools: false`、无 sandboxPolicy 服务、无会话:后台组不出现。 -- 同时开受限会话与完全权限会话:两者目录互不污染。受限子 agent 不继承祖先的命令组。 -- 同一会话切换受限 → 完全权限 → 受限:每次下一请求目录/提示词跟随变化,不重连 FastCtx;旧命令句柄不能继续调用。 -- 受限会话不发布 `ops_bash`;完全权限 + publishBashTool=true + bash 可解析 + subprocess 可用时出现,通用命令优先走它。FastCtx 缺失或 enableShellTools=false 时 bash 层仍独立可用。 -- 提示词按组件分段 `dsh-ops:repository-tooling:{file,shell,background}`:开关只移除对应段;bash/pwsh 路由仅 shell 开时出现,后台关闭不出现后台指导。不写 AGENTS.md,没有旧 host-shell 段或 mcp:fastctx。 -- shell 开时宿主 pwsh-sandbox 采用随包 pwsh7,关时恢复最新宿主原配置;profile 文件不得被改动。开关期间用户更改执行器原配置后也应恢复新值。 -- 极简预设 complete persona 可排除附加提示;PTC 通过 SDK 使用同一工具层,权限/组件开关仍应生效。 -- publishBashTool=false 隐去 bash 这一层;bash 权限动态切换、受限子 agent 继承过滤和卸载清理一起验证。 -- `promptPolicy: false`:不发布路由提示词。 -- `deny-host-shell`:有无 ops 命令组都应拒绝配置的宿主 shell;若工具来自继承层,目录也应遮蔽。无可用执行器时不推荐不可调用的 ops 命令。 - -## 文件工具(以下仅为 0.2.x 历史验收,不是当前能力) - -当前 `ops_inspect_local_file` 仅支持单路径、有界 UTF-8 文本或 hex;没有 `files[]`、多区间、任意编码或 PDF 文本接口。当前回归入口为 `test/inspect-hex.test.mjs` 与 `test/file-contract.test.mjs`,不要对 0.3.x 执行下列旧版用例。 - -- 多文件多区间 `files[]`,各自 offset/limit/encoding;重复路径读取不同区间,结果有序。 -- GBK 文本显式 encoding;单文件 grep encoding、目录 fallback_encoding。 -- grep `pattern` 是字符串,不传数组;`glob[]` 可混正负规则。 -- glob `pattern[]`、`!` 排除、paths/details;没有 count 模式。 -- grep schema 只公开 context;content/count/summary 与分页结果仍符合上游行为。 -- PDF text、pages 翻页、hex;text 模式外不要让 ops 渲染图像。 -- 普通图片路径:错误明确说使用宿主 read_image;改后缀的图像也不能返回成功占位符。图像 `view: hex` 可以读取字节。 -- 跨文件替换先 dry_run,再在临时副本上应用;精确编辑走宿主 edit。 -- 只读工具可并发;替换/命令不应声明并发安全。 - -## 任务与输出隔离 - -- 当前会话没启动 job:ops 结果没有别的会话的 Background 行。 -- 启动 job,保留返回 ID;输出、列表、kill 只操作自己的 ID。 -- A/B 两会话各启动 job:各自 footer/list 只见自己,不能读取/kill 对方 ID。 -- 完成/kill 后仍能读取自己已有 ID 的日志。 -- job_list status/limit/offset 是自己的分页,不暴露全局总数/offset;空列表明确为空。 -- 大全局 job 存储扫描不完整时必须报告 Partial,不冒充完整;原工具结果的 Complete/Partial 续页提示不能丢。 -- 关闭后台组件/卸载:等待已发调用,再尝试终止当前组件已知的自有 ID;不能杀别的会话或组件任务。清理失败有告警。断线后已丢归属的任务不保证终止。 -- 重连后不认领旧 ID;操作者可通过上游工具清理持久 job。权限降级不自动杀已运行任务,须操作者管理。 - -## 进程生命周期(0.2.6) - -- 同一宿主文件/后台组件共用连接;关一个组件连接仍能响应,最后一个释放后其 `serve` PID 消失。 -- 连续短命 CLI/宿主调用后不新增残留 `serve`;正常退出和强杀宿主都核验,测试只追踪自有 PID。 -- `FASTCTX_NO_PARENT_WATCH=1` 即使从宿主环境继承也不能关闭插件客户端的所有者监控。 -- 不把 `runtime-host` 的死父 PID 误判为泄漏,不批量杀它;约十分钟空闲回收是上游设计。旧版本遗留进程不由新版自动清理。 - -## 生命周期与边界 - -- 服务端断线立即撤下工具与对应提示词项,恢复后重新出现。 -- 卸载后工具/提示词/限制全部消失;共享注册表方法未改写,外来同名工具仍可按自己的 scope 注册。 -- 未知配置键报错点名;required=true 的缺失/不可用二进制拒绝激活。 -- 取消/传输超时只表明停止等待,不把它误读为命令已停止。ops_bash 的宿主执行器截止时间独立于 MCP 等待超时。 - -## 数字复核 - -运行 `node scripts/measure-schemas.mjs docs/schema-current.json`,对比 `schema-baseline.json`。不执行真实文件/命令操作。统计的是紧凑 schema JSON 的 UTF-8 字节,tokens≈bytes/4,不是使用频率或准确账单。基线来自修改前 dee3c57 + FastCtx 0.2.6;工具调用频率仍未知,不据此删除 ops_replace。 - -## 不可误读为已解决的边界 - -四个 FastCtx 文件工具仍是非宿主沙箱后端,ops_replace 没有 workspace confinement;命令权限门不解决这件事。没有文件系统约束设计前,不作为不可信受限环境的安全方案。宿主审批策略与 sandbox mode 独立,本插件没有新增逐命令审批升级。 +# 0.4.0 manual acceptance + +中文:[手动验收](手动验收.md). These are operator checks, not claims that every UI or deployment +has been exercised. Use a disposable profile and workspace; retain commands, observed results, +platform/host versions and candidate commit. Do not install or upgrade a real runtime merely to +open its settings page. + +## Build and release evidence + +Run the [full release gate](CONTRIBUTING.md#the-verification-gate) with the required worker and, +on Windows, the verified Bash fixture. A skipped required runtime is not a pass. Run the separate +[BM-01–05 gates](benchmark-gates.md); record individual failed/blocked/passed states and raw receipts. +BM-01 and full-default BM-03 remain release blockers. Do not substitute entry-only schema counts, +a smaller workload, missing Bash or functional CI success for acceptance. + +## Profiles and configuration + +1. Start a saved six-row profile with explicit name-qualified overrides, mixed on/off switches and + unrelated plugin settings. Upgrade and restart twice. Verify seven unique OPS IDs, the unchanged + legacy file name/configuration, preserved switches and the added discovery row. +2. In a fresh profile, verify file/PTC/discovery on, shell on only on Windows, and panel/TUI/replacements + off. Enable a fresh panel row and observe new events; disable it and verify queued collection stops + with no final fold. An explicit legacy capture:false must stay inert until changed by the operator. +3. Save valid built-in row settings and restart. Reject an unknown key or wrong type without changing + saved values. A stale revision must not overwrite a newer edit. Null legacy file/root/shell settings + must keep their defaults. A disabled row must be enabled before editing. +4. Optionally preview settings-profile with absolute profile/host/home paths. Preview writes nothing. + Apply only to the stopped disposable profile with --offline --yes. Verify backup originals, seven + retained IDs/switches/configs, restart and custom-page activation. Disable/re-enable the carrier and + verify custom pages unload/recover. External ambiguous overrides must refuse without writes. + +## Tool publication and results + +1. Inspect the actual first provider request: Native has read_many/discover and eligible Windows Bash; + advanced document schemas are absent. Select one advanced capability and verify it appears only in + the owning next request, not in current-round diagnostics or a sibling agent. +2. PTC starts with inspect_local_file/result/sdk. Load permitted advanced APIs through ops_sdk and + check the next SDK snapshot, typed Outcome status and data. Preserve an operator's explicit allowed + pool; tool listing must not imply filesystem permission. +3. Read three file ranges once. Reconstruct escaped lines exactly, check per-file errors and both + pending-input and in-file continuations. Edit five ranges in one file with a revision; stale or + overlapping edits must change nothing. Never treat a partial response as full coverage. +4. Exercise CSV/TSV, DOCX and a supported text-layer PDF, plus malformed/unsupported later content. + Safe prefixes may survive, but total/revision require a stable complete scan. Check page versus scan + versus display/reference-capture completeness and the explicit excluded document scope. +5. Select an official replacement only when its exact OPS counterpart is ready: read requires + inspect_local_file, not read_many. An unrelated same-name tool must not satisfy readiness. + +## Authority, runtime and teardown + +Use read-only, workspace-write and full-access sessions. Credential-fenced reads must reject. +Writes must reject in read-only, and outside writes must reject in workspace-write, without leaking +denied paths/content or changing files. Full access follows its documented broader write authority. A host-authorized ordinary outside +read is an allowed control, not a failed workspace jail. Verify authority changes and cancellation +stop or invalidate owned work, and unloading removes only this plugin's registrations. + +Bash must keep the host command/description/cwd/timeout contract, approvals, job IDs and output kinds. +Default timeout is 120 seconds, maximum 600. Without jobs, background is absent. With jobs, collect or +stop through host job tools; a timeout promotion must not execute the command twice. Windows restricted +modes have documented partial enforcement and exact MSYS compatibility requirements. + +Runtime management must first show a plan, then require explicit execution. Cancellation after launch +may have unknown effects. Git/managed selection edits a draft until saved. PowerShell upgrades target +the standard host installation, require restart and leave custom pwshPath review to the operator. +Do not claim installation success from opening a page or losing a transport connection. + +Record TUI acceptance separately when a compatible dsh-TUI host is available. The optional host suites +must be reported as skipped when it is absent; synthetic registry tests do not replace terminal UI +acceptance. Preserve every failure and skip in the release review. diff --git a/docs/ptc-sdk-loading.md b/docs/ptc-sdk-loading.md index 34594d7e..787a0e3e 100644 --- a/docs/ptc-sdk-loading.md +++ b/docs/ptc-sdk-loading.md @@ -13,7 +13,7 @@ The PTC row accepts these settings through central config validation: | `binaryPath` | Packaged worker | Optional absolute worker executable selection. | | `required` | `false` | Fail component startup when the worker is unavailable. | -Advanced names are `grep, glob, replace, write_file, edit_file, diff, stat, copy, move, remove, read_many, edit_many, batch`. Requests accept 1–5 names atomically. Unknown keys, public-prefixed names, resident names and unimplemented APIs are rejected. Configuration is stored as arrays; this slice does not add a browser multiselect editor. +Advanced names are `grep, glob, replace, write_file, edit_file, diff, stat, copy, move, remove, read_many, edit_many, table_read, doc_read, pdf_read, batch`. Requests accept 1–5 names atomically. Unknown keys, public-prefixed names, resident names and unimplemented APIs are rejected. The built-in PTC row form edits these validated arrays, plus required and binaryPath, and persists them through the host. Optional custom browser pages currently cover discovery, replacement and shell, not PTC. Fresh profiles derive all implemented advanced APIs; saved explicit allowedTools arrays remain authoritative. The full SDK has 19 APIs, including its three resident entries. See [configuration](configuration-reference.md) and [document limits](native-kernel.md). Selections belong to the exact live agent and persist until its component or agent unloads. Siblings and descendants do not inherit another agent's loaded SDK. A sandbox mode change drops pending selections and cancels active calls; every worker call independently rechecks current authority. Disposal withdraws owned registrations and rejects retained callbacks. Switching to Native/both removes the PTC surface and restores only the file row's previously visible Native definitions. For overlapping configured PTC rows, the nearest row owns its descendant scopes; a later row wins within the same scope, and unload restores the earlier row. The shared registry is never patched. diff --git a/docs/release-0.4.0.md b/docs/release-0.4.0.md new file mode 100644 index 00000000..98711114 --- /dev/null +++ b/docs/release-0.4.0.md @@ -0,0 +1,126 @@ +# 0.4.0 release candidate + +中文:[0.4.0 发布候选](0.4.0发布候选.md). + +**Not released.** Version fields identify this candidate; they do not authorize a tag, npm publication +or a final merge. Functional CI and the five acceptance benchmarks are separate gates. BM-01 and the +full default BM-03 are still failing, so release remains blocked. + +## Implemented surface + +The package declares seven stable rows: shell, file, PTC, discovery, panel, TUI and official-tool +replacement. Fresh file/PTC/discovery rows are on; shell is on only on Windows; panel/TUI/replacement +are off. Existing named overrides and explicit switches survive restart. An enabled fresh panel starts +collection; legacy explicit capture:false remains inert. Turning it off stops queued filesystem work. + +Native starts with read_many and discover, plus usable Windows Bash. Advanced tools activate only in +the owning agent's next provider request. The PTC SDK catalogue contains 19 APIs: 16 file/document tools, +batch, result and sdk. The complete shared catalogue also includes bash and discover (21 entries). +The SDK and Native projections expose separate typed/text outputs; catalogue membership is not startup +residency. Current scopes, allowed pools and permissions still govern publication and every call. + +The one-shot Rust worker checks authority per call. Native text is bounded and uses reversible line +escaping; PTC returns typed Outcomes. References preserve source scan/page/capture completeness and do +not grant filesystem access. Same-content re-read suppression requires exact provider-input evidence; +retained message history alone is insufficient. See [re-read evidence](context-read-dedup.md). + +All plugin source is strict TypeScript/TSX. The build rejects JavaScript under src, compiles to lib, +and preserves shipped .js paths. Repository scripts/tests may remain JavaScript; lib/runtime are build +artifacts. Follow [source-build instructions](CONTRIBUTING.md), not an unbuilt Git install. + +## Document scope + +| Tool | Supported scope | Main bounds and exclusions | +| --- | --- | --- | +| table_read | CSV/TSV lexical string cells; strict UTF-8 or BOM-consistent UTF-16LE/BE | 32 MiB input, 100000 data rows, 1 MiB/record, 1024 columns, 30 s. No XLS/XLSX, formula evaluation or type coercion. | +| doc_read | DOCX main-body paragraphs, including table coordinates | 32 MiB archive, 8 MiB main XML, 1 MiB/paragraph, 100000 paragraphs, 30 s. No binary DOC, macros, encrypted/ZIP64 archives, external fetch or disk extraction. Excludes layout, headers/footers, notes/comments and metadata. | +| pdf_read | PDF 1.3–1.7 classic-xref text layer; standard Helvetica/Courier/Times Type 1 with Standard/WinAnsi | 16 MiB input, 32 pages, 1 MiB/page/stream, 16 MiB decoded and 16 MiB processed streams, 30 s. No OCR, images, general layout, forms, custom/embedded/CID fonts, ToUnicode, encryption, incremental or object/xref streams. | + +These are bounded readers, not general office/PDF converters. Unsupported later content retains safe +preceding complete records with an error and next action. Offsets cannot skip a physical scan gap. +Only a stable full scan yields total/revision; a complete displayed or stored page does not repair +incomplete source coverage. See [detailed document contracts](native-kernel.md). + +## Operator changes + +Bash and PowerShell are not npm payload dependencies. Managed Bash install/removal and Git Bash +selection are explicit. Windows restricted execution remains partially enforced and requires the +compatible pinned MSYS runtime; approval does not create missing confinement. Host PowerShell upgrade +previews the standard installation; execute explicitly, restart DSH, and review any custom pwshPath. + +Built-in row forms persist validated settings through the host. Optional browser pages need the +explicit offline settings-profile migration because the pinned host loads its browser facet only +from a bare package carrier. The default legacy file row remains supported. The migration preserves +seven IDs/configs, makes backups and checks exact composition; disabling the carrier unloads custom +pages. Re-enable through the host row switch. See [configuration](configuration-reference.md). + +## Issue #261 acceptance matrix + +This maps the original 16-part plan to the integrated implementation. Named suites identify concrete +regressions and proof boundaries; they are not a claim that pending final CI or manual checks passed. +The detailed limits above and in linked contracts remain part of acceptance. + +| Plan step | Implemented behavior and limits | Evidence / remaining decision | +| --- | --- | --- | +| 1 — Build | src → lib build and assets preserve published paths; source Git installs require a build. | build and packed-artifact gates; JavaScript migration allowance removed in step 16. | +| 2 — Contracts | One capability catalogue; typed Outcome, error/retry, coverage and reference contracts. | kernel-types, kernel-errors and schema checks; Native text and PTC data remain distinct. | +| 3 — Native projection | Numbered reversible source text, bounded receipts and explicit incomplete output; 64 KiB escaped display cap. | native-projection, native-line-encoding, native-ranges; no universal percentage saving. | +| 4 — Worker | Workspace crates and v1 bounded one-shot requests, authority rechecked per call. | Cargo workspace and protocol regressions; persistent IPC remains deferred to a separate security review. | +| 5 — Reads, edits, search | Up to 32 independent read ranges; atomic multi-edit within one file; honest search gaps/continuations. | file-batching-worker and search regressions; no cross-file atomic edit or complete total after a scan gap. | +| 6 — References/budgets | 10 MiB/result, 100 MiB/session; two active/eight waiting calls and 30-second worker ceiling; audit data separate. | result-references/spill/lifecycle, call-budget, native-result; permission-limited cleanup can leave invalidated temp blobs. | +| 7 — PTC | 19 typed SDK APIs, three resident entries; allowed pools and next-step loading independent of Native. | ptc-outcome/schema/sdk and progressive-loop; current frozen SDK cannot call queued APIs. | +| 8 — Discovery | Native allowed pool, always-loaded subset and 1–5 selections; owning agent's next request only. | discovery/composition and real AgentLoop BM-04; no filesystem permission grant. | +| 9 — Resident surface | read_many plus discover, with eligible Windows Bash; advanced tools absent initially. | Exact provider inventory; full Windows BM-03 is 660 tokens, so the ≤400 target is unresolved. | +| 10 — Re-read evidence | Exact successful body in the latest completed ordinary provider request plus fresh file checks. | context-read-dedup/many with real replacement/projection; missing evidence restores full output. | +| 11 — Bash | Host approvals/jobs/subprocess, absolute executor, 120/600-second timing, optional managed/Git Bash. | shells-host/execution/manager; Windows confinement remains partial and runtime compatibility is mandatory. | +| 12 — PowerShell | No bundled payload or executor overlay; explicit standard host upgrade plan and execution. | Host runtime manager/transport regressions; no real operator installation performed; restart/custom-path review required. | +| 13 — Replacement | Opt-in read/glob/grep with exact OPS definition identity and readiness; read maps to inspect_local_file. | Real scoped mount/readiness regressions; write/edit replacement is excluded because CAS contracts differ. | +| 14 — Migration/settings | Seven stable rows, saved overrides, fresh defaults and physical panel stop; validated built-in forms. | platform-defaults, settings-host/UI/client/carrier/runtime and panel-stop; custom pages need optional offline carrier; real TUI requires a separate host. | +| 15 — Documents | Discoverable CSV/TSV, DOCX and restricted classic-xref PDF readers with honest safe prefixes. | table/docx/pdf worker+host suites and BM-04; unsupported formats/layout/OCR remain excluded, as detailed above. | +| 16 — Acceptance/release | Strict TS/TSX, versioned candidate, paired docs and separate opt-in BM-01–05 evidence. | Functional CI and benchmark acceptance are separate; BM-01/BM-03 fail, so no final release approval. | + +## Stacked review and staging rollout + +The review PRs currently use predecessor branch bases to keep each diff bounded; they do not all +target release/0.4.0 directly. For example, [1/16](https://github.com/T-Auto/dsh-ops/pull/264) +targets release/0.4.0, [2/16](https://github.com/T-Auto/dsh-ops/pull/265) targets its toolchain branch, +and [14b/16](https://github.com/T-Auto/dsh-ops/pull/283) targets the 14a settings branch. The 15a–d +and 16a reviews continue from their preceding slice. PR number alone is not dependency order: part 13 +was opened earlier, while its current base is part 12. + +Maintainers review and integrate prerequisites into release/0.4.0 in plan/dependency order: 1; +2→3 and 4a→4b; then 5a→5b, 6a→6b→6c→6d, 7–13, 14a→14b, 15a→15b→15c→15d and 16a→16b→16c. +When prerequisites are present in staging, retarget the next review to the appropriate current base, +verify that its diff contains only the intended slice, and rerun required CI after any integration +change. Do not merge a successor into an obsolete predecessor merely because it remains the review +base. Keep shared history intact; this work does not merge maintainers' PRs. + +Only after all required functional gates and acceptance decisions are resolved should maintainers +review release/0.4.0 → main as the final release PR. BM-01 needs an explicit decision about the fixed +lossless workload/comparator requirement; BM-03 needs an explicit decision about full startup residency +versus the 400-token budget while preserving contracts. No such decision is assumed here. Any approved +requirement change must update the issue, documentation and acceptance assertions transparently before +rerunning them. A version bump, a ready PR or passing functional CI does not authorize a tag/publication. + +## Acceptance status + +The reviewed Windows snapshot uses the fixed issue workload and pinned host; it is not a universal +speedup or provider-billing measurement. [Benchmark gates](benchmark-gates.md) define reproducible +commands, evidence provenance and current rerun status. + +| Gate | Reviewed result | +| --- | --- | +| BM-01 | Fail: 5348 candidate bytes versus 8883 OPS 0.3.1 bytes (39.795% reduction); official read 5457 bytes (1.997%). Requested source alone is 4320 bytes. | +| BM-02 | Pass: five edits in one tool-bearing round and both stale-CAS controls rejected. | +| BM-03 | Fail: full actual provider order 660 tokens/3219 bytes, including Windows Bash and real jobs; target ≤400. Catalogue order 661 is a separate tokenizer boundary result. | +| BM-04 | Pass: PDF absent initially, available on the owning provider request after discovery, absent for a sibling, then executed. | +| BM-05 | Pass: credential-fenced reads and outside writes rejected without denied-path/content leakage; an ordinary permitted outside read is an explicit allowed control. | + +The file/discovery-only 241-token measurement does not pass the full-default BM-03 gate. No schema +contract, requested source, threshold or workload was removed to claim success. A missing optional +Bash fixture blocks the full Windows measurement rather than reducing its denominator. The final +candidate needs fresh functional and benchmark evidence after all fixes; unresolved failures must +remain explicit until maintainers decide how to resolve them. + +Use the [manual acceptance checklist](manual-validation.md). This candidate does not merge PRs, +publish binaries or enable a disabled release workflow. diff --git a/docs/release-process.md b/docs/release-process.md index 75f1ef87..8b17be86 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -1,8 +1,11 @@ # 发布流程与如何启用 +> 0.4.0 仍是[未发布候选](0.4.0发布候选.md),BM-01/BM-03 未通过。下面的仓库工作流状态是历史记录,实际发布前须重新检查;本次更新不启用发布、不打 tag、不发布包。 +> The [0.4.0 candidate](release-0.4.0.md) remains blocked by BM-01/BM-03. Workflow state below is historical and must be checked before any release; this update does not enable publishing, tag or publish packages. + 对象是 `.github/workflows/release.yml`(workflow 名 `Release`)。发布目标只有一个:**GitHub Release**(tag + 自动 notes + 一个可安装主包 tarball + `SHA256SUMS`)。npm publish 不在这条流水线里,理由见下。 -## 当前状态:`disabled_manually`,推 tag 不会发布 +## 历史状态记录:`disabled_manually` ``` $ gh api repos/T-Auto/dsh-ops/actions/workflows --jq '.workflows[]|"\(.name) | \(.state)"' diff --git a/docs/roadmap.md b/docs/roadmap.md index d8afe23a..e4aec428 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # Roadmap +中文:[路线图](路线图.md)。 + > Directional, not a schedule or a commitment. Priorities can change with > upstream DSH releases and community feedback. Shipped work is recorded in > [CHANGELOG.md](CHANGELOG.md). @@ -11,7 +13,11 @@ (`darwin-arm64`). See [linux-build.md](linux-build.md) and [macos-arm64.md](macos-arm64.md). -## Next +## 0.4.0 candidate + +Separate Native/PTC projections, scoped discovery, bounded CSV/TSV, DOCX and restricted PDF readers, runtime/settings changes and strict TypeScript source are implemented. This is not a release announcement: BM-01 and the full-default BM-03 remain blockers. See [candidate status and format limits](release-0.4.0.md). + +## Later ### Platform coverage @@ -20,8 +26,7 @@ ### Tool surface (progressive disclosure) -- Lightweight DOCX and PDF tools, shipped as opt-in Rust tools that do not - occupy the system prompt when disabled. +- Broader document-format coverage beyond the bounded 0.4.0 DOCX/PDF subsets. - Web search and crawl4ai-style HTML-to-Markdown tools. - More PTC tools and bounded result projections. diff --git a/docs/usage.md b/docs/usage.md index 9bc3f8b3..f3d4a15b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -2,13 +2,24 @@ The complete install, update and remove flow, plus every option of the command-line interface. +## 0.4.0 candidate and component defaults + +This checkout is an unreleased candidate; BM-01 and the full default BM-03 still block release. +See [candidate status](release-0.4.0.md). The seven rows enable file/PTC/discovery, enable shell only +on Windows, and leave panel/TUI/replacements off in fresh profiles. Existing explicit switches and +name-qualified settings survive upgrade. Only read_many/discover are initially resident in Native, +plus available Windows Bash; documents load on demand. The PTC catalogue has 19 typed APIs, with its +own configured pool and SDK loader. [Configuration](configuration-reference.md) describes built-in +forms and the optional browser carrier. Use [manual acceptance](manual-validation.md) for upgrades. + + --- ## Prerequisites | Item | Requirement | | --- | --- | -| OS | Windows x64 or Linux x64 (shell tools: Windows only) | +| OS | Windows x64, Linux x64 or macOS arm64; optional OPS Bash defaults on only on Windows | | Node.js | `^22.19 \|\| >=24` (the `engines` field in `package.json`) | | Host | DSH `0.2.0-rc.2` | | Target application | Initialized | @@ -30,6 +41,7 @@ Then point the shell row at a bash the machine already has: ```yaml - id: dsh-ops-shell name: dsh-ops/shell + disabled: false config: bashPath: /opt/homebrew/bin/bash ``` @@ -193,7 +205,7 @@ This version starts no long-lived server process: the native file worker is owne ## Native file results Native file tools present readable text: `=== FILE: path (Lines a-b of n) ===` -followed by `N | text`. Paths, observed revisions and paging notices remain visible. +followed by `N | text`; fully returned multi-file ranges use compact `N|text` and may share one JSON-quoted parent path. Paths, observed revisions and paging notices remain visible. See [compact ranges](native-ranges.md). PTC execution values remain structured objects; inspect `status` and completeness before using them. Grep context labels use the physical source line, while glob path mode lists one path per line. Stat retains size, revision and metadata facts; diff --git a/docs/windows-verification.md b/docs/windows-verification.md index aed4a7af..5d82a4fa 100644 --- a/docs/windows-verification.md +++ b/docs/windows-verification.md @@ -1,4 +1,6 @@ -# Windows x64 verification checklist +# Historical Windows investigation checklist + +> Historical pre-0.4 investigation, retained for context. Statements below about missing Windows metadata preservation or old CI triggers are not current release claims. Current metadata regression suites and [0.4 manual acceptance](manual-validation.md) are the verification entry points. / 以下为历史调查;Windows 元数据保护与旧 CI 触发说明不代表当前版本,请以[手动验收](手动验收.md)及现有测试为准。 This file was written while adding Linux x64 support and fixing the release-scope gate. Everything below was established on **Debian 13 / x86_64 / Node v24.21.0 / Rust 1.98.1**. diff --git "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" index e78d7e2d..fba35604 100644 --- "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -2,13 +2,18 @@ 安装、更新、卸载的完整流程,以及命令行接口的全部选项。 +## 0.4.0 候选与组件默认值 + +本检出是未发布候选;BM-01 与完整默认 BM-03 仍阻止发布,见[候选状态](0.4.0发布候选.md)。新 profile 的七行默认开启 file/PTC/discovery,仅 Windows 开启 shell,panel/TUI/替换关闭;已有显式开关和带名称配置在升级后保留。Native 初始常驻 read_many/discover 及可用 Windows Bash,文档能力按需加载。PTC 有19个类型化 API,使用独立配置池与 SDK 加载器。[配置参考](配置参考.md)说明内置表单和可选浏览器承载行;升级按[手动验收](手动验收.md)复核。 + + --- ## 前置条件 | 项 | 要求 | | --- | --- | -| 操作系统 | Windows x64 或 Linux x64(shell 工具仅 Windows) | +| 操作系统 | Windows x64、Linux x64 或 macOS Apple Silicon(darwin-arm64) | | Node.js | `^22.19 \|\| >=24`(`package.json` 的 `engines`) | | 宿主 | DSH `0.2.0-rc.2` | | 目标应用 | 已初始化 | @@ -29,6 +34,7 @@ node bin/native-runtime.mjs stage --platform darwin-arm64 ```yaml - id: dsh-ops-shell name: dsh-ops/shell + disabled: false config: bashPath: /opt/homebrew/bin/bash ``` @@ -190,7 +196,7 @@ Without `--profile`, uninstall only cleans legacy provisioned runtime/shell file ## Native 文件结果 -Native 文件工具以 `=== FILE: path (Lines a-b of n) ===` 和 `N | text` +Native 文件工具以 `=== FILE: path (Lines a-b of n) ===` 和 `N | text`(完整多文件范围使用紧凑 `N|text`,同父目录可共用 JSON 引号路径,见[紧凑范围](紧凑文件范围.md)) 显示内容,并保留路径、已观察到的 revision 与分页提示。PTC 的执行值仍是结构化对象, 使用前须检查 `status` 和完整性。Grep 的上下文行号对应真实源文件;glob 的路径模式 每行显示一个路径。Stat 保留大小、revision 与元数据,diff 显示删除/添加的行; diff --git "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" index 460fd0e1..fc892b45 100644 --- "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" +++ "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" @@ -41,7 +41,7 @@ BM-01 始终保留偏移 13/31/51、每文件 8 行的案例,以及 1 行、32 Windows 完整 Bash schema 测量需显式夹具:在独立临时 `DSH_HOME` 运行 `node bin/dsh-ops.mjs bash-runtime install`,将 `DSH_OPS_TEST_BASH` 指向经验证的绝对可执行路径。基准记录该选择,不修改 schema 选项;全新目录未安装 Bash 时,BM-03 明确阻塞,不以文件工具的较小计数冒充通过。手动工作流会显式获取该固定夹具。 -## Windows 集成结果(2026-10-19) +## 历史 Windows 集成结果(2026-10-19) 候选提交 `02577ed140c16e1739bf2588172ee38d9370ec52` 工作区干净;宿主 `0.2.0-rc.2`、Node `25.2.1`。本地 worker 使用 Cargo `1.99.0`,手动 CI 固定 `1.97.1`,尚无本次跨平台结果。 @@ -58,6 +58,12 @@ Windows 完整 Bash schema 测量需显式夹具:在独立临时 `DSH_HOME` 本地保留原始 `040-benchmark-final-pinned-bash.json`,SHA-256 为 `cc94fcb4f444a39858795ef9003553de54735eff128191f3037a21666db1de98`,并有对应 Markdown。完整验收仍为 false;基线与预算范围决策尚未解决。 +## 审查后的完整重跑 + +干净候选 `2865936` 包含可逆源文本转义和独立完整性断言。本地保留 `040-benchmark-reviewed-complete.json`,SHA-256 为 `380ac6fbd392dd0f7805c03c66aa6ba9d88f88bcf4eb5a60ba01868f152488bb`。BM-01 仍为 5348/8883/5457 字节;BM-03 实际供应商顺序仍为 660 tokens/3219 字节。BM-02、BM-04、BM-05 通过,五项完整验收仍为 false。 + +修复后的 Unicode 敏感性为候选 2616、OPS 0.3.1 为 4185、官方 2469 字节:相对 OPS 减少 37.491%,相对官方增加 5.954%(即减少 −5.954%)。上方旧 40.358% 属于不可逆转义,只保留为历史记录。完整启动消息 JSON 另为 788 tokens/3293 字节,不作为工具 schema 门槛。最终版本候选仍需用重新构建的 worker 重跑。 + ## 审查后的完整性断言 BM-03 在评估 token 分数前,要求完整且精确的常驻文件能力目录与发现入口(Windows 还要求完整 Bash 契约)。缺失、重复或意外工具均会阻塞并指出原因。BM-05 同时要求普通外部读取对照成功且正文完全一致,并验证拒绝探测的 `CREDENTIAL_DENIED` / `POLICY_DENIED` 及 `effectState: none`。Native 行检查使用独立解码器验证反斜杠与控制字符,不再复制渲染器逻辑。这些断言不改变工作负载、默认值、预算或阈值;上方快照仍是所列提交的历史证据,不会被改判为通过。 diff --git "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" index ba824bc0..3d0fccbf 100644 --- "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" +++ "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" @@ -11,7 +11,7 @@ English: [Advanced tools on demand](discovery.md). config: enabled: true maxResults: 3 - allowedTools: [inspect_local_file, edit_many, result, glob, grep, replace, write_file, edit_file, diff, stat, copy, move, remove] + allowedTools: [inspect_local_file, edit_many, result, glob, grep, replace, write_file, edit_file, diff, stat, copy, move, remove, table_read, doc_read, pdf_read] alwaysLoaded: [] ``` @@ -24,7 +24,7 @@ English: [Advanced tools on demand](discovery.md). | `binaryPath` / `required` | 自动 / `false` | 与原生文件工具相同的 worker 选择及激活失败行为。 | | `denyCredentialReads` | `true` | 保留原生文件凭据保护。 | -配置数组会复制并去重;未知配置键在错误中点名。搜索按经评审元数据确定性匹配关键词,同分时使用固定顺序,不调用模型。`query` 必须非空且不超过 512 个字符;额外操作参数会被拒绝。PDF、Word、表格读取必须各自实现并通过验收后才可发现;当前目录不会假称这些格式已就绪。 +配置数组会复制并去重;未知配置键在错误中点名。搜索按经评审元数据确定性匹配关键词,同分时使用固定顺序,不调用模型。`query` 必须非空且不超过 512 个字符;额外操作参数会被拒绝。已实现的 table_read、doc_read、pdf_read 可按需发现,分别受[原生内核](原生内核.md)中的 CSV/TSV、DOCX 与受限 PDF 契约约束;不将二进制 DOC、OCR 或未支持 PDF 编码宣称为可用。 每个激活的文件工具保留自身路径基准、参数 schema、Native 渲染、worker 权限检查和修订值/CAS 契约。只读作用域不能激活写操作。沙箱模式变更会清空该会话待激活项、取消其执行中的调用并重新计算注册;其它会话保留选择。销毁 agent 或卸载组件会移除注册,缓存的旧定义也失效。纯 PTC 作用域不发布此 Native 发现工具面;SDK 由 PTC 适配器管理。 @@ -34,11 +34,11 @@ English: [Advanced tools on demand](discovery.md). 确定性的入口测量固定 `js-tiktoken@1.0.21` 和 `cl100k_base`,编码固定宿主 `ToolSchema` 字段(`name`、`description`、`parameters`)的紧凑 JSON。发现入口为 **94 tokens / 410 UTF-8 字节**。这是可复现的 schema 测量,不是供应商账单;不含消息封装、提示词指引和其它 OPS 组件。分词器是测试依赖,发布于 2025-08-09;锁定的 `base64-js` 也仅供测试使用。 -默认文件/发现工具面现在是 `ops_read_many` 加 `ops_discover`,按真实宿主组装顺序为 **241 tokens / 1094 字节**。读取器从启动起即可一次读取多个文件范围;`ops_inspect_local_file`(含 hex)、搜索、修改和 Native 引用分页均按需发现。路由仅列出实际已发布工具。加入当前 Bash 契约后为 **610 tokens / 2915 字节**,因此完整默认工具面的 BM-03 门禁仍未通过。这些测量保留数值边界、默认值、路径基准和错误契约;重新设计的 Bash 契约整合后必须重测,用户的始终加载选择也会扩大初始工具面。 +默认文件/发现工具面为 `ops_read_many` 加 `ops_discover`,**241 tokens / 1094 字节**。完整 Windows Bash/jobs 契约按供应商实际顺序为 **660 tokens / 3219 字节**,目录顺序为661 tokens,均超过完整 BM-03 的400预算。高级读取、搜索、修改、文档与结果分页按需发现;路由只列出已发布工具。数值边界、默认值、路径基准与权限契约保持不变;始终加载项会扩大初始面。见[基准证据](基准门禁.md)。 -BM-04 使用真实 Cordis 工具/提示词服务及原生 worker:已实现的 `stat` 在发现前不可见,在当前步骤组装时仍不可见,紧接着的真实 AgentLoop 供应商请求中出现并能执行。PDF 尚未实现,因此这是生命周期门禁,并不代表 PDF 验收已通过。 +BM-04 已通过真实 AgentLoop 验证已实现 PDF 读取器:初始 schema 不可见,当前轮保持等待,所属供应商下一请求可见,兄弟 agent 不可见,并成功读取 fixture 文本。这只验证已支持 PDF 子集,不代表通用 PDF/OCR 覆盖。 -浏览器配置多选界面属于独立工作:普通配置校验不会自动生成宿主选择器,界面必须使用固定宿主版本的浏览器插件配置 API。 +内置设置可持久化严格校验的数组;可选浏览器多选使用固定宿主客户端 API,需要[配置参考](配置参考.md)中的显式离线承载行迁移,默认旧文件行不启用这些页面。 重叠的发现行由最近作用域拥有;同一作用域最后挂载的行生效,卸载后恢复前一行。文件行可在发现组件之前或之后挂载。仅让出或屏蔽确切属于 OPS 的 Native 注册,外部注册保持可见。PTC 的 Native 投影保留确切来源,发现后的激活项不能被其它 agent 继承。Native `result` 仅含 page/release,可在只读模式发现;worker 仍会拒绝不允许的 release。常驻 `read_many` 和按需 `edit_many` 保留原有逐文件完整性和 CAS 契约。 diff --git "a/docs/\346\211\213\345\212\250\351\252\214\346\224\266.md" "b/docs/\346\211\213\345\212\250\351\252\214\346\224\266.md" new file mode 100644 index 00000000..7aa78918 --- /dev/null +++ "b/docs/\346\211\213\345\212\250\351\252\214\346\224\266.md" @@ -0,0 +1,32 @@ +# 0.4.0 手动验收 + +English: [manual acceptance](manual-validation.md). 以下是操作者检查清单,不代表每种 UI 或部署均已验证。使用一次性 profile 和工作区,记录命令、实际结果、平台/宿主版本及候选 commit。仅查看设置页面不应安装或升级真实运行时。 + +## 构建与发布证据 + +按[贡献指南](贡献指南.md)构建 worker,并在 Windows 提供已校验 Bash fixture,运行完整发布门禁;所需运行时被跳过不能计为通过。单独运行[BM-01–05](基准门禁.md),保留逐项通过/失败/阻塞状态与原始结果。BM-01 和完整默认 BM-03 仍阻止发布;不能用入口 schema、小工作负载、缺少 Bash 或功能 CI 通过代替验收。 + +## Profile 与配置 + +1. 用含带名称覆盖、混合开关和其它插件配置的已保存六行 profile 升级并重启两次;确认七个唯一 OPS ID、旧文件行名称与配置、显式开关均保留,并新增 discovery。 +2. 新 profile 默认 file/PTC/discovery 开,仅 Windows 的 shell 开,panel/TUI/替换关。启用新 panel 行后观察新事件;关闭后排队采集停止,不执行最终归并。旧配置显式 capture:false 必须保持不采集,直到操作者更改。 +3. 内置表单保存合法值后重启验证;未知键、错误类型或过期版本不覆盖已保存值。旧 file/root/shell 的 null 配置保持原默认值。关闭的行先开启才可编辑。 +4. 可选:用绝对 profile/host/home 路径预览 settings-profile,确认零写入。仅对已停止的一次性 profile 用 --offline --yes 应用;核对原始备份、七行 ID/开关/配置及重启后浏览器页面。关闭/重启承载行应卸载/恢复自定义页面;外部歧义覆盖必须拒绝且不写入。 + +## 工具发布与结果 + +1. 检查首次实际供应商请求:Native 有 read_many/discover 及符合条件的 Windows Bash,高级文档 schema 不可见。发现后仅所属下一请求可见,当前轮诊断或兄弟 agent 不可见。 +2. PTC 常驻 inspect_local_file/result/sdk;通过 ops_sdk 加载允许的高级 API,核对下一 SDK 快照及类型化 Outcome 的 status/data。保留操作者显式允许池;工具可见不代表授予文件权限。 +3. 一次读取三个文件范围,精确还原转义行,核对逐文件错误、待处理输入与文件内续读。一个文件五处编辑使用修订值;过期 CAS 或重叠编辑不得更改内容,部分输出不得冒充完整。 +4. 读取 CSV/TSV、DOCX 和受支持 PDF 文本层,再加入后续畸形/不支持内容;允许保留安全前缀,但 total/revision 只来自稳定完整扫描。分别核对扫描、请求页、展示与引用捕获完整性及排除范围。 +5. 只选择已就绪的确切 OPS 官方替代项;read 要求 inspect_local_file,不能用 read_many 代替。同名外来工具不能满足就绪条件。 + +## 权限、运行时与卸载 + +分别测试只读、工作区写与完全权限。凭据保护读取须拒绝;只读模式拒绝写入,工作区写模式拒绝越界写入,不泄露被拒路径/内容且不改文件;完全权限遵循其较宽的写入授权。宿主允许的普通外部读取是允许对照,并非“工作区读取沙箱”失效。权限变化、取消和卸载须停止或失效自身工作,仅撤回本插件注册。 + +Bash 保留宿主 command/description/cwd/timeout 契约、审批、jobs 与输出类型;默认120秒,上限600秒。无 jobs 时不公开后台选项;有 jobs 时通过宿主工具收集或停止,超时提升不能再次执行命令。Windows 受限模式仍有文档注明的部分约束与精确 MSYS 兼容要求。 + +运行时管理先显示计划,再显式执行;启动后取消可能影响未知。Git/managed 选择仅改草稿,保存后生效。PowerShell 升级针对标准宿主安装,要求重启,并提示操作者检查自定义 pwshPath。不能因打开页面或传输断开而宣称安装成功。 + +兼容 dsh-TUI 存在时单独记录终端验收;缺少宿主时,相关可选套件必须注明跳过,合成注册测试不能代替真实终端验收。发布审查须保留所有失败与跳过。 diff --git "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" index cc08330c..8299f694 100644 --- "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" +++ "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" @@ -7,7 +7,7 @@ | 配置行 `name` | 介绍 | 默认 | 发布的工具 | | --- | --- | --- | --- | | `dsh-ops/shell` | 优先使用 bash,无法覆盖时使用宿主 PowerShell | Windows 开;Linux/macOS 关 | `ops_bash`;宿主 `pwsh` 保持其原执行器 | -| `dsh-ops/file` | 更快、更高性能、输出更精简更省 token 的 Rust 文件检索 | 开 | `ops_read_many`;高级工具通过发现加载 | +| `dsh-ops/file` | 有界 Rust 文件检索与针对模型的文本输出 | 开 | `ops_read_many`;高级工具通过发现加载 | | `dsh-ops/ptc` | PTC 会话的类型化文件 API 与渐进式 SDK | 开 | `ops_inspect_local_file`、`ops_result`、`ops_sdk`;高级 API 通过 SDK 加载 | | `dsh-ops/discovery` | 按需加载 Native 高级工具 | 开 | `ops_discover` | | `dsh-ops/panel` | 数据采集,开关默认关:新对话写入 `/.dsh-ops/panel/.json` 与读取它的 `panel.html` | 行关;capture 默认 true | 无;见[配置参考](配置参考.md) | @@ -20,6 +20,8 @@ ## 工具总表 +共享能力目录有 21 项,其中 PTC 生成 SDK 包含 19 个 API:16 个文件/文档工具,加 batch、result 和 sdk;bash 与 discover 属于其他入口。目录成员不表示常驻或已获权限。新 PTC 允许池跟随已实现目录,已有显式池保持不变。文档工具默认按需加载。 + | 工具 | 用途 | 完全权限要求 | | --- | --- | --- | | `ops_read_many` | 常驻读取 1-32 个文件的有界 UTF-8 范围 | 无命令权限门¹ | @@ -27,12 +29,16 @@ | `ops_grep` | Rust 正则搜索、多文件过滤、计数/摘要 | 无命令权限门¹ | | `ops_glob` | 多模式找路径,支持排除 | 无命令权限门¹ | | `ops_replace` | 跨文件字面文本批量替换;精确编辑仍用宿主 edit | 无命令权限门¹ | +| `ops_write_file` / `ops_edit_file` / `ops_edit_many` | 文件写入与单文件原子多段编辑,保留 revision/CAS | 写入遵循当前权限¹ | +| `ops_diff` / `ops_stat` | 比较文本与查询元数据 | 无命令权限门¹ | +| `ops_copy` / `ops_move` / `ops_remove` | 固定文件操作 | 写入遵循当前权限¹ | +| `ops_table_read` / `ops_doc_read` / `ops_pdf_read` | 有界 CSV/TSV、DOCX、受限 PDF 读取;见下文 | 无命令权限门¹ | | `ops_bash` | 优先的通用 bash 命令执行器;短命令用 `command`,长脚本用 `script_path` | 受限模式经 DSH 沙箱;Windows x64 使用匹配的私有 IPC 副本 | | `ops_batch` | 预检并执行 copy / move / remove,带审计与恢复 | 见 PTC 说明 | | `ops_result` | 有界结果引用:写入、分页、释放 | 见 PTC 说明 | | 宿主 `pwsh` | 使用宿主选择的 PowerShell 的 Windows 原生操作 | 沿用宿主策略 | -¹ **固定文件操作遵循 DSH fs-sandbox 同类的可信操作权限检查,不执行模型代码。** 所有模式允许 OS 可读路径的读取;read-only 拒绝替换(含其 dry run),workspace-write 允许工作区及本会话私有临时目录内替换,full-access 不额外限制写路径。通过宿主 subprocess 执行固定 Rust worker,保留无链接遍历、预算及 CAS;Bash/run_code 仍通过 OS 沙箱,绝不在失败后裸执行。Windows 启动前只为当前用户拥有的工作区根准备目录自身 WRITE_OWNER 并备份 DACL,不递归、不改所有者。该准备步骤以当前用户身份、在沙箱约束生效之前运行包内自带的脚本,仅在工作区内修改模式下执行,并传 `-ExecutionPolicy Bypass`(组策略仍优先)。 +¹ **固定文件操作遵循 DSH fs-sandbox 同类的可信操作权限检查,不执行模型代码。** 所有模式在凭据围栏与当前作用域权限允许时可读取 OS 可读路径;read-only 拒绝替换(含其 dry run),workspace-write 允许工作区及本会话私有临时目录内替换,full-access 不额外限制写路径。通过宿主 subprocess 执行固定 Rust worker,保留无链接遍历、预算及 CAS;Bash/run_code 仍通过 OS 沙箱,绝不在失败后裸执行。Windows 启动前只为当前用户拥有的工作区根准备目录自身 WRITE_OWNER 并备份 DACL,不递归、不改所有者。该准备步骤以当前用户身份、在沙箱约束生效之前运行包内自带的脚本,仅在工作区内修改模式下执行,并传 `-ExecutionPolicy Bypass`(组策略仍优先)。 --- @@ -40,7 +46,7 @@ 仅有界多文件读取器 `ops_read_many` 常驻;含 hex 读取的 `ops_inspect_local_file` 按需发现。下述高级工具契约通过[工具发现](工具发现.md)或显式常驻选择激活;路由说明只列出当前实际发布的工具。 -文件路径以 DSH 会话 cwd 为基准解析。`ops_read_many` 接受 `files` 数组,每项有独立路径、从 1 开始的行偏移和行数上限;单文件操作接受 `path`。Native 引用分页接受原始引用的 `root`。文件工具不接受 `workspace`、`cwd` 或 `file_path` 别名。全部为 UTF-8 优先且有界输出;glob 与 grep 不遍历符号链接、junction、重解析点、隐藏文件和 `.git`。 +文件路径以 DSH 会话 cwd 为基准解析。`ops_read_many` 接受 `files` 数组,每项有独立路径、从 1 开始的行偏移和行数上限;单文件操作接受 `path`。Native 引用分页接受原始引用的 `root`。文件工具不接受 `workspace`、`cwd` 或 `file_path` 别名。文本工具优先使用 UTF-8;文档工具遵循各自有界格式/编码契约。glob 与 grep 不遍历符号链接、junction、重解析点、隐藏文件和 `.git`。 宿主提供 `fs` 服务时,`ops_inspect_local_file` 的读取与 `ops_replace` 的实际写入会像宿主 `read`/`edit` 一样登记到宿主的「先读后改」观测表(`fs/observed`):用 OPS 读过的文件可直接用宿主 `edit`,OPS 写过之后宿主也不会把它误判为外部改动。`ops_grep`、`ops_glob` 与 `dryRun` 不登记。 @@ -48,7 +54,7 @@ 读取有界 UTF-8 文本行,或某个路径的 hex 视图。目录会被拒绝——请改用 `ops_glob` 或 `ops_grep`。 -模型展示把同路径页面的 `path` 提到顶层,只显示一次;原始执行/PTC 结果保留每条记录的路径。revision、分页、行内截断等其余字段均保留。worker 字节预算和超长单行续读行为不变;展示字节数不等于实测 token。 +Native 输出文件标题、带行号的可逆转义正文及 revision/续读提示;PTC 保留规范类型化记录。反斜杠、控制字符和 Unicode 行分隔符均转义,正文不能伪造额外行。worker 字节预算和长行续读仍明确保留,见[紧凑文件范围](紧凑文件范围.md)。展示字节不等于实测 token。 | 参数 | 类型 | 说明 | | --- | --- | --- | @@ -106,10 +112,16 @@ | `maxReplacements` | integer 1–100000 | 替换数硬上限 | | `dryRun` | boolean | 不产生写入 | +### 文档读取器 + +`ops_table_read` 读取 CSV/TSV 字符串单元格(默认 UTF-8,支持明确 UTF-16LE/BE);`ops_doc_read` 读取 DOCX 主体段落与表格坐标,不支持旧二进制 DOC。两者 offset 默认 1,limit 默认 200、上限 1000,输入最多 32 MiB。`ops_pdf_read` 仅处理 PDF 1.3–1.7 经典 xref 与标准 Type 1 字体文本,最多 16 MiB/32 页,offset 默认 1,limit 默认 10、上限 32;不支持 OCR、嵌入字体或现代对象/xref 流。 + +所有文档工具分别报告扫描、分页、展示与引用捕获完整性。后续错误可保留此前安全完整记录,但不提供虚假的全量总数或 revision;offset 不能绕过物理扫描缺口。全部格式边界、编码规则及恢复说明见[原生内核](原生内核.md)。 + ### 结果与错误 -- **文件工具、`ops_batch`、`ops_result` 不抛异常**:失败以值返回,`status` 为 `"error"` 或 `"rejected"`(权限拒绝),并带 `error.code` / `error.message` / `error.nextAction`;成功为 `"completed"`(`ops_batch` 另有 `planned` 等状态)。在 PTC 中只包 `try/catch` 会漏掉这些失败,要检查 `status`。 -- **`ops_bash`**:命令结果以值返回,含 `status`(`completed` 仅表示退出码 0 且无错误/权限拒绝/信号/超时,否则为 `failed`)、`exitCode`、`stdout` / `stderr`、`error`、`permission`。只有**参数无效**或**执行器不可用**时才抛出(PTC 中为 `ToolCallError`)。 +- **文件工具、`ops_batch` 与 `ops_result` 以值返回失败。** PTC Outcome 为 completed/partial 数据,或 rejected/conflict/error/unknown,并带错误码、副作用状态和重试策略。批处理 plan/run 状态保留在类型化 data 中;只包 try/catch 会漏掉失败,应先检查 status 与覆盖信息。Native 将同样事实投影为文本。 +- **`ops_bash`** 返回宿主兼容的 `foreground`、`background` 或 `promoted` 值。包括参数无效和执行器不可用在内的失败均返回值,不抛异常;前台失败保留字符串 `error` 并附加 `failure`(错误码、副作用状态、重试策略)。启动后的副作用可能未知,不得自动重放;后台结果使用宿主 job 工具收集。 - 宿主工具(`read` / `write` / `edit` / `pwsh` 等)失败时抛 `ToolCallError`。 --- @@ -213,7 +225,7 @@ OPS 调用始终保留自己的会话 cwd 路径基准、权限检查和输出 常驻的 `ops_read_many` 不是经评审的 `read` 替代项。选择 `read` 前,应配置发现组件 `alwaysLoaded: [inspect_local_file]`,或确保该单文件能力已在当前 Native 作用域激活。`glob` 和 `grep` 同样需要就绪。内部展示模式检查允许发现组件在启动时发布这些已配置工具;普通提示词组装和工具执行在就绪前仍会被拒绝。 -组件导出真实的 `StandardSchemaV1` 校验器供 Cordis 使用,但不会宣称通用 schema 能自动生成界面:固定宿主版本要求浏览器通过 `plugins.row.config` 提供选择器。配置方法见[配置参考](配置参考.md)。 +组件导出经过验证的设置与公开 JSON schema 描述,供宿主内置行表单使用。可选浏览器多选页面需要显式客户端承载行迁移,见[配置参考](配置参考.md)。 --- diff --git "a/docs/\350\267\257\347\272\277\345\233\276.md" "b/docs/\350\267\257\347\272\277\345\233\276.md" new file mode 100644 index 00000000..7237bac2 --- /dev/null +++ "b/docs/\350\267\257\347\272\277\345\233\276.md" @@ -0,0 +1,23 @@ +# 路线图 + +English: [Roadmap](roadmap.md). + +以下是方向,不是交付日期或承诺;优先级会随宿主版本和使用反馈调整。已发布变更见 [CHANGELOG](CHANGELOG.md)。 + +## 已发布 + +- 0.3.0:Windows x64 原生 Rust 文件工具与受约束的 PTC 批处理。 +- 0.3.1:Windows x64、Linux x64 和 macOS Apple Silicon(darwin-arm64)跨平台支持。 + +## 0.4.0 候选 + +Native/PTC 独立投影、作用域发现、有界 CSV/TSV、DOCX 与受限 PDF 读取、运行时/设置变更和严格 TypeScript 源码已实现。本页不是发布公告:BM-01 与完整默认 BM-03 仍阻止发布。范围和格式限制见[候选说明](0.4.0发布候选.md)。 + +## 后续方向 + +- Windows ARM64、Linux ARM64、Intel Mac 平台。 +- 扩展 0.4.0 有界 DOCX/PDF 子集以外的文档支持。 +- Web 搜索、HTML 转 Markdown、更多 PTC 工具与有界结果投影。 +- 同步双语文档并补充故障排查;跟踪上游 DSH 接口变化。 + +提出使用场景请提交 issue;流程见[贡献指南](贡献指南.md)。 diff --git "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" index 870e9891..1ada0237 100644 --- "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" +++ "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" @@ -31,7 +31,7 @@ Bash 安装/删除与 PowerShell 升级按钮首先显示计划,随后需要 客户端承载行使用稳定 ID `dsh-ops-file` 和裸包名 `dsh-ops` 承载这些浏览器页面,服务端仍为文件组件; `dsh-ops/file` 导出保持兼容。关闭此承载行会卸载全部自定义设置页面,请使用宿主的 **内置行开关**重新启用。没有自动表单回退。目标行被关闭时,也必须先重新启用才能编辑设置。 -发现组件行安装后,即可使用其设置页面。 +发现组件行与可选客户端承载行都启用后,才可使用其自定义设置页面。 ### 启用可选浏览器承载行 @@ -77,25 +77,27 @@ Bundle 声明七个独立启停组件,首次安装默认值如下。已有 pro ## 🔴 键与组件的归属关系 -`dsh-ops/tui` 只接受 `maxSessions`、`maxActive`、`recentLimit`,见 [侧栏上限](tui-panel.zh.md#上限)。以下旧组件配置不适用于 tui 行。下表是**每个键属于哪个旧组件**。把键填到不拥有它的那一行,会被配置校验拒绝并点名该键。 +`dsh-ops/tui` 只接受 `maxSessions`、`maxActive`、`recentLimit`,见 [侧栏上限](tui-panel.zh.md#上限)。下表列出字段实际作用的组件。file/root 与 shell 保留共享旧校验器,某些兼容字段可被接受但不影响该行;PTC、discovery、panel、TUI 和替换各有严格字段集,不可复制通用 file/shell 配置进去。Loader 行开关是 config 外的 disabled。 | 配置键 | 类型 | 默认 | 归属组件 | | --- | --- | --- | --- | -| `configUpdateVersion` | 非负整数 | `0` | 任意(迁移标记) | -| `binaryPath` | 字符串路径 | — | file / ptc | +| `configUpdateVersion` | 非负整数 | `0` | file / shell(迁移标记) | +| `binaryPath` | 绝对路径 | — | file / ptc / discovery | | `bashPath` | 字符串路径 | — | shell | | `publishBashTool` | 布尔 | `true` | shell | -| `promptPolicy` | 布尔 | `true` | 全部 | -| `extraGuidance` | 字符串 | 空 | 全部 | -| `required` | 布尔 | `false` | 全部 | +| `promptPolicy` | 布尔 | `true` | file / shell | +| `extraGuidance` | 字符串 | 空 | file / shell | +| `required` | 布尔 | `false` | file / ptc / discovery | | `shellPolicy` | `advise` \| `deny-host-shell` | `advise` | shell | | `deniedHostTools` | 字符串数组 | `[pwsh, bash, pwsh_persistent]` | shell | | `allowSystemShellFallback` | 布尔 | `false` | shell | -| `denyCredentialReads` | 布尔 | `true` | file / ptc | +| `denyCredentialReads` | 布尔 | `true` | file / discovery | | `capture` | 布尔 | `true` | panel(行默认关) | | `textLimit` | 整数 | `2000` | panel | | `maxSessionBytes` | 整数 | `8388608` | panel | +PTC 只接受 binaryPath、required、allowedTools、alwaysLoaded,凭据围栏保持开启;其始终加载列表独立于动态允许池。Discovery 另有 enabled、maxResults、denyCredentialReads,且 alwaysLoaded 必须是 allowedTools 子集。默认值和确切名称见[PTC 设置](PTC按需加载.md)与[发现设置](工具发现.md)。替换拥有 replaceTools 及不生效的旧兼容键;panel/TUI 仅接受其采集/展示字段。 + ### 凭据围栏(`denyCredentialReads`) 凭据预检拒绝仍返回 `rejected`、`CREDENTIAL_DENIED`、无副作用及禁止重试的指引。Native 文本和 PTC 返回值不披露被拒路径、解析后的父目录或匹配规则。 @@ -116,7 +118,7 @@ config: publishBashTool: true shellPolicy: advise allowSystemShellFallback: false - # bashPath: 'C:\tools\bash.exe' # 仅在 danger-full-access 下生效,见下方 bashPath 说明 + # bashPath: 'C:\tools\bash.exe' # Windows 受限执行须匹配 MSYS/IPC,见下文 ``` `dsh-ops/file` 行: @@ -150,7 +152,7 @@ config: - `shellPolicy`:默认 `advise`;`deny-host-shell` 拒绝 `deniedHostTools` 列表中的宿主 shell(默认 `[pwsh, bash, pwsh_persistent]`)。这也会禁用第三层 pwsh,谨慎开启。 - `allowSystemShellFallback`:默认 `false`,随包/已 provision 的 bash 缺失时不探测系统 PATH 或常见安装目录(**POSIX 主机上该键不起作用**:shell 组件本来就解析宿主 bash,这里没有 pin 过的载荷可"回退")。只有显式设 `true` 才使用未固定摘要的系统 bash,请自行信任与维护该执行器。显式 `bashPath` 是另一种操作者选择,并非自动回退。首次升级启动的迁移见 [使用指南](使用指南.md) 的「更新配置修改区」。 - `shellPolicy` 与此开关独立:`deny-host-shell` 遮蔽/拒绝宿主工具;禁止系统回退只约束插件自己的 bash 解析,不关闭宿主 `pwsh`,也不提供文件系统沙箱。 -- `binaryPath`(原生 worker 可执行文件)/ `bashPath`:可选显式路径;不可用时报错,不悄悄换执行器。一般无需配置。 +- `binaryPath`(原生 worker 可执行文件)/ `bashPath`:可选显式绝对路径;不可用时报错,不悄悄换执行器。用 dsh-ops bash-runtime git 或 managed 打印 Bash 选择。 - **POSIX 主机上 `ops_bash` 本就用宿主发行版的 bash**:解析顺序为 `bashPath` → `PATH` → `/bin/bash`、`/usr/bin/bash`、`/usr/local/bin/bash`,随包不提供第二份 shell,因此没有需要"回退"的载荷,`allowSystemShellFallback` 也不影响它。受限模式(`workspace-write` / `read-only`)下 `bashPath` 同样生效,约束由宿主沙箱(bwrap / Landlock)落实;沙箱不可用或无法约束时**拒绝执行**,不会退化为无约束运行。 - `bashPath` **(Windows)在受限模式下必须匹配固定 MSYS。** 只读与工作区内修改模式下,`ops_bash` 只运行已校验 IPC 兼容代理的固定摘要 MSYS(`dsh-ops bash-runtime install` 或兼容的已有副本);其它任何 `msys-2.0.dll`(包括你自己的 Git for Windows)都会按设计返回 `EXECUTOR_UNAVAILABLE`,且绝不裸执行。不要把 `bashPath` 指向自带 Git Bash 来规避受限模式问题。可用 `dsh-ops ladder --config <文件>` 检查所选 bash,报告中的 `restricted: yes/no` 即受限模式能否运行。见 [常见问题](常见问题.md#受限模式下-ops_bash-无法运行自定义-bashpath-或-git-for-windows)。 - `deniedHostTools`:`deny-host-shell` 策略下拒绝的宿主工具名列表。 @@ -196,7 +198,7 @@ Cordis 通过 `StandardSchemaV1` 校验配置;独立的公共设置描述在 ## 提示词注册 -提示词注册为独立运行时段,**不写 AGENTS.md**:Shell 关就撤销 bash/pwsh 路由;文件关就撤销原生文件说明。文件组件使用独立 Rust worker。默认新增四个原生文件工具和 `ops_bash`。这些改变减少固定声明成本。极简预设会覆盖附加 system sections;PTC 模式经 SDK 调用底层工具,权限门不变。 +提示以作用域运行时段落注册,不写入 AGENTS.md。关闭 shell 撤回其路由;关闭 file 撤回 Native 文件指引。新 Native 文件面仅常驻 ops_read_many,发现组件发布 ops_discover,符合条件的 Windows shell 另有 ops_bash。高级工具通过发现或显式始终加载启用。PTC 拥有独立类型化 SDK,并保持当前权限检查。固定 schema 成本单独计量;完整默认 BM-03 仍未解决。 --- diff --git a/dsh-plugin.json b/dsh-plugin.json index 0b25f6f3..3f6356b9 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -3,7 +3,7 @@ "manifestVersion": "0.15", "id": "dsh-ops.native", "name": "dsh-ops", - "version": "0.3.1", + "version": "0.4.0", "license": "MIT AND Apache-2.0", "facets": { "host": { diff --git a/native/Cargo.lock b/native/Cargo.lock index e900770f..c4235554 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -138,7 +138,7 @@ dependencies = [ [[package]] name = "dsh-ops-core" -version = "0.3.1" +version = "0.4.0" dependencies = [ "cap-fs-ext", "cap-std", @@ -153,11 +153,11 @@ dependencies = [ [[package]] name = "dsh-ops-darwin-metadata" -version = "0.1.0" +version = "0.4.0" [[package]] name = "dsh-ops-windows-metadata" -version = "0.1.0" +version = "0.4.0" dependencies = [ "winapi", "windows-permissions", @@ -354,7 +354,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "ops-batch" -version = "0.3.1" +version = "0.4.0" dependencies = [ "cap-std", "ops-files", @@ -366,7 +366,7 @@ dependencies = [ [[package]] name = "ops-documents" -version = "0.3.1" +version = "0.4.0" dependencies = [ "flate2", "ops-files", @@ -380,7 +380,7 @@ dependencies = [ [[package]] name = "ops-files" -version = "0.3.1" +version = "0.4.0" dependencies = [ "cap-fs-ext", "cap-std", @@ -397,7 +397,7 @@ dependencies = [ [[package]] name = "ops-protocol" -version = "0.3.1" +version = "0.4.0" dependencies = [ "regex", "serde", @@ -406,7 +406,7 @@ dependencies = [ [[package]] name = "ops-results" -version = "0.3.1" +version = "0.4.0" dependencies = [ "cap-fs-ext", "cap-std", @@ -421,7 +421,7 @@ dependencies = [ [[package]] name = "ops-search" -version = "0.3.1" +version = "0.4.0" dependencies = [ "cap-fs-ext", "cap-std", diff --git a/native/Cargo.toml b/native/Cargo.toml index 0a36acb7..76e39cc7 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dsh-ops-core" -version = "0.3.1" +version = "0.4.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/native/crates/ops-batch/Cargo.toml b/native/crates/ops-batch/Cargo.toml index 15862b61..fc01ef64 100644 --- a/native/crates/ops-batch/Cargo.toml +++ b/native/crates/ops-batch/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ops-batch" -version = "0.3.1" +version = "0.4.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/native/crates/ops-documents/Cargo.toml b/native/crates/ops-documents/Cargo.toml index e2498636..55dfec55 100644 --- a/native/crates/ops-documents/Cargo.toml +++ b/native/crates/ops-documents/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ops-documents" -version = "0.3.1" +version = "0.4.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/native/crates/ops-files/Cargo.toml b/native/crates/ops-files/Cargo.toml index 7fbf0eda..894b0fd3 100644 --- a/native/crates/ops-files/Cargo.toml +++ b/native/crates/ops-files/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ops-files" -version = "0.3.1" +version = "0.4.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/native/crates/ops-protocol/Cargo.toml b/native/crates/ops-protocol/Cargo.toml index 376e32ac..45f5fdf8 100644 --- a/native/crates/ops-protocol/Cargo.toml +++ b/native/crates/ops-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ops-protocol" -version = "0.3.1" +version = "0.4.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/native/crates/ops-results/Cargo.toml b/native/crates/ops-results/Cargo.toml index a0174f40..27134182 100644 --- a/native/crates/ops-results/Cargo.toml +++ b/native/crates/ops-results/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ops-results" -version = "0.3.1" +version = "0.4.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/native/crates/ops-search/Cargo.toml b/native/crates/ops-search/Cargo.toml index 7add7be6..e26a72f9 100644 --- a/native/crates/ops-search/Cargo.toml +++ b/native/crates/ops-search/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ops-search" -version = "0.3.1" +version = "0.4.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/native/darwin-metadata/Cargo.toml b/native/darwin-metadata/Cargo.toml index 6ed1488f..4c84c45a 100644 --- a/native/darwin-metadata/Cargo.toml +++ b/native/darwin-metadata/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dsh-ops-darwin-metadata" -version = "0.1.0" +version = "0.4.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/native/windows-metadata/Cargo.toml b/native/windows-metadata/Cargo.toml index d36bf002..b6c49dd8 100644 --- a/native/windows-metadata/Cargo.toml +++ b/native/windows-metadata/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dsh-ops-windows-metadata" -version = "0.1.0" +version = "0.4.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/package-lock.json b/package-lock.json index 4995d732..e8725157 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dsh-ops", - "version": "0.3.1", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dsh-ops", - "version": "0.3.1", + "version": "0.4.0", "cpu": [ "x64", "arm64" diff --git a/package.json b/package.json index b273f94c..07bf524e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-ops", - "version": "0.3.1", + "version": "0.4.0", "publishConfig": { "access": "public" }, diff --git a/scripts/validate-manifest.mjs b/scripts/validate-manifest.mjs index 429273ff..afcb2502 100644 --- a/scripts/validate-manifest.mjs +++ b/scripts/validate-manifest.mjs @@ -491,7 +491,7 @@ check(PTC_API_TOOLS.every(t => definition(t, () => {}).parameters.type === 'obje check(JSON.stringify(pkg.os) === '["win32","linux","darwin"]' && JSON.stringify(pkg.cpu) === '["x64","arm64"]' && JSON.stringify(ptc?.platforms) === '["win32","linux","darwin"]' && JSON.stringify(ptc?.architectures) === '["x64","arm64"]' && JSON.stringify(ptc?.targets) === '["win32-x64","linux-x64","darwin-arm64"]', - '0.3.1 exact targets are Windows x64, Linux x64 and macOS Apple Silicon; Intel Mac is unsupported') + 'Supported exact targets are Windows x64, Linux x64 and macOS Apple Silicon; Intel Mac is unsupported') /** Every component states the platforms it really serves, so a Linux claim cannot ride along. */ const platformClaims = id => manifestRaw.contributes?.['x-components']?.find(c => c.id === id)?.platforms check(JSON.stringify(platformClaims('dsh-ops-shell')) === '["win32","linux","darwin"]', diff --git a/src/components/panel/package.json b/src/components/panel/package.json index 5715c9fd..d50fce0c 100644 --- a/src/components/panel/package.json +++ b/src/components/panel/package.json @@ -1,6 +1,6 @@ { "name": "dsh-ops-panel", - "version": "0.3.2", + "version": "0.4.0", "description": "Opt-in capture of new conversations into /.dsh-ops/panel: one JSON data document per session plus a static viewer that reads it.", "icon": "./icon.png", "type": "module" diff --git a/src/components/tui/dsh-plugin.json b/src/components/tui/dsh-plugin.json index 10f385e9..83bdb115 100644 --- a/src/components/tui/dsh-plugin.json +++ b/src/components/tui/dsh-plugin.json @@ -3,7 +3,7 @@ "manifestVersion": "0.15", "id": "dsh-ops-tui", "name": "dsh-ops-tui", - "version": "0.3.1", + "version": "0.4.0", "license": "MIT", "facets": { "host": { diff --git a/src/components/tui/package.json b/src/components/tui/package.json index 7642f6c1..1718e4ee 100644 --- a/src/components/tui/package.json +++ b/src/components/tui/package.json @@ -1,6 +1,6 @@ { "name": "dsh-ops-tui", - "version": "0.3.1", + "version": "0.4.0", "description": "Optional read-only OPS activity sidebar for dsh-TUI.", "icon": "./icon.png", "type": "module" diff --git a/src/ptc/runtime.ts b/src/ptc/runtime.ts index 6bb1cb90..1b72395c 100644 --- a/src/ptc/runtime.ts +++ b/src/ptc/runtime.ts @@ -60,7 +60,7 @@ export function tryResolveWorker(config: WorkerSelection = {}, options: RuntimeO } export function resolveWorker(config: WorkerSelection = {}, { packageRoot = PACKAGE_ROOT, env = process.env, platform = process.platform, arch = process.arch }: RuntimeOptions = {}): string { - if (!PLATFORMS.includes(`${platform}-${arch}`)) throw new Error(`dsh-ops 0.3.1 supports Windows x64, Linux x64 and macOS Apple Silicon (darwin-arm64) only; unsupported runtime target ${platform}-${arch}.`) + if (!PLATFORMS.includes(`${platform}-${arch}`)) throw new Error(`dsh-ops supports Windows x64, Linux x64 and macOS Apple Silicon (darwin-arm64) only; unsupported runtime target ${platform}-${arch}.`) const explicit = config.binaryPath ?? env.DSH_OPS_WORKER_BIN const fromPackage = path.resolve(packageRoot, 'runtime', `${platform}-${arch}`, workerName(platform)) const fromSourceTree = path.resolve(packageRoot, 'native', 'target', 'release', workerName(platform)) From 7e73cb403b6bce2a636b2a3facc27a995667acc3 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:22:27 +0800 Subject: [PATCH 110/118] test(release): follow the declared installer version --- test/bin.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/bin.test.mjs b/test/bin.test.mjs index d1096342..f456141b 100644 --- a/test/bin.test.mjs +++ b/test/bin.test.mjs @@ -29,6 +29,7 @@ import { PACKAGE_ROOT, TEST_HOME, assert, report, test, withTempDir } from './li /** The CLI under test. */ const CLI = path.join(PACKAGE_ROOT, 'bin', 'dsh-ops.mjs') +const PACKAGE_VERSION = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')).version /** * Run the CLI with one throwaway home and one redirected user home. @@ -752,7 +753,7 @@ await test('on POSIX the profile CLI fallback launches dsh, not the Windows cmd }) assert.match(run.stdout, /Official CLI: dsh;/, `${run.stdout}${run.stderr}`) assert.equal(fs.existsSync(marker), true, `the POSIX CLI must have been launched: ${run.stdout}${run.stderr}`) - assert.equal(fs.readFileSync(marker, 'utf8').trim(), 'plugin --profile web add dsh-ops@0.3.1') + assert.equal(fs.readFileSync(marker, 'utf8').trim(), `plugin --profile web add dsh-ops@${PACKAGE_VERSION}`) assert.equal(run.status, 0, `${run.stdout}${run.stderr}`) }) }) From 5faed5780cc35a877427961a1a101a277aa43bea Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:27:06 +0800 Subject: [PATCH 111/118] docs(release): align native document overview with implemented readers --- docs/native-kernel.md | 10 +++++----- ...345\216\237\347\224\237\345\206\205\346\240\270.md" | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/native-kernel.md b/docs/native-kernel.md index b58e8104..bc22b01b 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -12,7 +12,7 @@ and writes one JSON result. Splitting the kernel does not introduce a persistent | `ops-files` | No-follow handles, reads, CAS publication, metadata, tree operations, directory sync | protocol | | `ops-search` | Glob, grep and replacement orchestration over guarded file primitives | protocol, files | | `ops-results` | Bounded generation store, result pages and immutable job-audit access | protocol, files | -| `ops-documents` | Strict bounded CSV/TSV Unicode decoding and typed positional rows | protocol, files | +| `ops-documents` | Bounded CSV/TSV rows, DOCX main-body paragraphs and PDF text observations | protocol, files | | `ops-batch` | Plans, write-ahead transitions and conservative recovery | protocol, files, results | The root crate routes requests and opens audit storage only for batch/result capabilities. @@ -133,9 +133,9 @@ retain the legacy transport contract; the host supplies recovery guidance when s The new ops-documents crate handles `table_read` in the same one-shot worker. The capability stays outside the default schema: Native uses `ops_discover` and PTC uses `ops_sdk` to activate `table_read` -for the next agent step. Siblings and descendants require their own selection. It adds no external packages: -serde/serde_json and sha2 reuse the existing locked workspace dependency graph. PDF/DOC parsing -requires separate dependency review. +for the next agent step. Siblings and descendants require their own selection. The table slice adds no external packages: +serde/serde_json and sha2 reuse the existing locked workspace dependency graph. The completed DOCX and PDF +slices below record their reviewed dependencies in [document-dependencies.md](document-dependencies.md). Arguments are `{ path, format?, encoding?, header?, offset?, limit? }`. The host resolves path against current cwd before sending an absolute path. Format is csv or tsv, inferred as tsv only @@ -253,7 +253,7 @@ of the input. No size advertised by a PDF author bypasses these limits. This slice exposes no tool and makes no text-coverage claim. Page-tree interpretation, the supported font/encoding subset, partial results and worker authority checks are -introduced together in the following PDF extraction slice. +implemented together in the PDF extraction slice documented below. ## PDF text worker (15d/16) diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index f3f86dcf..3bbb00f7 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -11,7 +11,7 @@ English: [native-kernel.md](native-kernel.md). | `ops-files` | 禁止跟随链接的句柄、读取、CAS 发布、元数据、树操作、目录同步 | protocol | | `ops-search` | 基于受保护文件原语的 glob、grep 和替换编排 | protocol、files | | `ops-results` | 有界的代际存储、结果分页和只读作业审计访问 | protocol、files | -| `ops-documents` | 严格有界 CSV/TSV Unicode 解码与保留位置的类型化行 | protocol、files | +| `ops-documents` | 有界 CSV/TSV 行、DOCX 正文段落及 PDF 文本观察 | protocol、files | | `ops-batch` | 计划、预写状态转移和保守恢复 | protocol、files、results | 根 crate 负责分派请求,仅为 batch/result 能力打开审计存储。作业归属校验与协议记录放在一起, @@ -109,8 +109,8 @@ complete=true 后 JSON.parse;须验证连续索引、偏移及结束标记。S 新增 ops-documents crate,在同一个单次 worker 内处理 table_read。该能力不进入默认工具 schema: Native 经 `ops_discover`、PTC 经 `ops_sdk` 选择 table_read 后,于代理下一步骤生效;兄弟及 -子代理必须独立选择。没有新增外部包:serde/serde_json 与 sha2 复用既有锁定依赖。 -PDF/DOC 解析仍是需要独立依赖审查的后续工作。 +子代理必须独立选择。表格切片没有新增外部包:serde/serde_json 与 sha2 复用既有锁定依赖。 +下文已实现的 DOCX 与 PDF 切片,其依赖审查记录见[文档解析依赖.md](文档解析依赖.md)。 参数为 `{ path, format?, encoding?, header?, offset?, limit? }`。宿主先相对于当前 cwd 解析 path,再发送绝对路径。format 为 csv/tsv;仅 .tsv 扩展名(忽略大小写)推断为 tsv,否则为 csv。 @@ -201,7 +201,7 @@ FILE_CHANGED 始终使所请求页不完整。引用页生成的 source 类型 并核验校验和、zlib 结束标记及完整输入消费;文档声明长度不能绕过实际边界。 本切片不暴露工具,也不宣称提取了完整文本。页面树、支持的字体/编码子集、 -部分结果及 worker 权限检查将在下一 PDF 提取切片中一并接入。 +部分结果及 worker 权限检查已在下文的 PDF 提取切片中一并实现。 ## PDF 文本 worker(15d/16) From c547ab717035e91fa6a0aced1cff37366e8ccf39 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:29:50 +0800 Subject: [PATCH 112/118] fix(ci): fetch pinned baseline history for acceptance checks --- .github/workflows/ci.yml | 6 ++++++ docs/CHANGELOG.md | 3 +++ docs/benchmark-gates.md | 2 ++ "docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" | 2 ++ 4 files changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 484ff427..b9a3af13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,9 @@ jobs: DSH_OPS_RETRY_TIMEOUTS: '1' steps: - uses: actions/checkout@v7 + with: + # The provenance regression verifies exact blobs from the pinned 0.3.1 commit. + fetch-depth: 0 - uses: actions/setup-node@v7 with: node-version: '24' @@ -265,6 +268,9 @@ jobs: DSH_OPS_RETRY_TIMEOUTS: '1' steps: - uses: actions/checkout@v7 + with: + # The provenance regression verifies exact blobs from the pinned 0.3.1 commit. + fetch-depth: 0 - uses: actions/setup-node@v7 with: node-version: '22.19' diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2160e9cd..ffdb8a1e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,9 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Fetch baseline Git history in full CI gates so exact 0.3.1 provenance checks work on fresh checkouts. +- 完整 CI 门禁获取基线 Git 历史,确保全新检出能校验 0.3.1 的精确来源。 + - Include each activated document reader in scoped routing guidance; validate real document receipts in the PTC API coverage suite. - 在作用域路由中显示已激活的文档读取工具,并用真实文档回执验证 PTC API 覆盖。 diff --git a/docs/benchmark-gates.md b/docs/benchmark-gates.md index d36cb99a..0873ec8f 100644 --- a/docs/benchmark-gates.md +++ b/docs/benchmark-gates.md @@ -29,6 +29,8 @@ The manual `Opt-in OPS acceptance benchmarks` workflow runs the same command on BM-01 also always reports offsets 13/31/51 with 8 lines, plus 1-line, 32-line, 600-character-line and escaped Unicode/JSON sensitivities. The frozen fixture definitions, file hashes and fixture-source hash appear in evidence. There is no automatic workload selection to obtain a favorable threshold. +The checkout must contain the pinned 0.3.1 Git history before running the functional provenance test or preparing the benchmark baseline. For a shallow clone, run `git fetch --unshallow` first. The CI full-gate jobs and manual benchmark workflow fetch full history; missing baseline objects are a failure, never a skip. + The 0.3.1 baseline is commit `4b51b6333213c376c8b7c173c12f35b408fdec2d`. The preparation script materializes exact Git blobs without checkout newline conversion; provenance rechecks every `lib/` and `native/` blob and records the worker version and SHA-256. Binary reproducibility is not attested. The official comparator uses the installed, pinned host reader separately. The test-only jobs package `@deepseek-ai/dsh-jobs-local@0.2.0-rc.2` was published on 2026-09-29, more than seven days before addition; it is needed to measure the complete Bash schema. ## Honest limits and retained results diff --git "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" index 460fd0e1..5123467d 100644 --- "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" +++ "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" @@ -29,6 +29,8 @@ Windows 的基线 worker 路径须加 `.exe`。每次运行使用新的输出文 BM-01 始终保留偏移 13/31/51、每文件 8 行的案例,以及 1 行、32 行、约 600 字符长行和转义 Unicode/JSON 敏感性案例。夹具定义冻结,证据记录文件散列与夹具源文件散列,不自动挑选有利案例。 +运行功能门禁中的来源校验测试或准备基准基线前,检出必须包含固定的 0.3.1 Git 历史。浅克隆请先运行 `git fetch --unshallow`。CI 完整门禁任务与手动基准工作流获取完整历史;缺少基线对象属于失败,不会跳过。 + 0.3.1 基线固定为 `4b51b6333213c376c8b7c173c12f35b408fdec2d`。准备脚本直接还原 Git blob,避免 checkout 换行转换;运行前逐一校验 `lib/` 与 `native/`,并记录 worker 版本及 SHA-256,不宣称二进制可重现构建认证。官方比较组独立使用已安装的固定版本宿主读取器。测试专用 `@deepseek-ai/dsh-jobs-local@0.2.0-rc.2` 发布于 2026-09-29,添加时已超过七天,用于完整计量 Bash Schema。 ## 保留的结果与限制 From 245d738aea2f9a8792eb6bc814d8e00cd220d15b Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:32:47 +0800 Subject: [PATCH 113/118] fix(ci): include baseline history in remaining gate callers --- .github/workflows/ci.yml | 3 +++ .github/workflows/release.yml | 3 +++ docs/CHANGELOG.md | 4 ++-- docs/benchmark-gates.md | 2 +- "docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9a3af13..2ea7102c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,6 +167,9 @@ jobs: CARGO_BUILD_JOBS: '3' steps: - uses: actions/checkout@v7 + with: + # Included benchmark tests verify the pinned historical baseline. + fetch-depth: 0 - uses: actions/setup-node@v7 with: node-version: '24' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77523966..73b5e92e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,6 +128,9 @@ jobs: DIST: dist-release steps: - uses: actions/checkout@v7 + with: + # Included benchmark tests verify the pinned historical baseline. + fetch-depth: 0 - uses: actions/setup-node@v7 with: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ffdb8a1e..142287fc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,8 +7,8 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] -- Fetch baseline Git history in full CI gates so exact 0.3.1 provenance checks work on fresh checkouts. -- 完整 CI 门禁获取基线 Git 历史,确保全新检出能校验 0.3.1 的精确来源。 +- Fetch baseline Git history in full CI gates, optional benchmark smoke and the release gate so exact 0.3.1 provenance checks work on fresh checkouts. +- 完整 CI 门禁、可选基准冒烟与发布门禁获取基线 Git 历史,确保全新检出能校验 0.3.1 的精确来源。 - Include each activated document reader in scoped routing guidance; validate real document receipts in the PTC API coverage suite. - 在作用域路由中显示已激活的文档读取工具,并用真实文档回执验证 PTC API 覆盖。 diff --git a/docs/benchmark-gates.md b/docs/benchmark-gates.md index 0873ec8f..0e3a1c10 100644 --- a/docs/benchmark-gates.md +++ b/docs/benchmark-gates.md @@ -29,7 +29,7 @@ The manual `Opt-in OPS acceptance benchmarks` workflow runs the same command on BM-01 also always reports offsets 13/31/51 with 8 lines, plus 1-line, 32-line, 600-character-line and escaped Unicode/JSON sensitivities. The frozen fixture definitions, file hashes and fixture-source hash appear in evidence. There is no automatic workload selection to obtain a favorable threshold. -The checkout must contain the pinned 0.3.1 Git history before running the functional provenance test or preparing the benchmark baseline. For a shallow clone, run `git fetch --unshallow` first. The CI full-gate jobs and manual benchmark workflow fetch full history; missing baseline objects are a failure, never a skip. +The checkout must contain the pinned 0.3.1 Git history before running the functional provenance test or preparing the benchmark baseline. For a shallow clone, run `git fetch --unshallow` first. The CI full-gate jobs, optional benchmark smoke, release gate and manual benchmark workflow fetch full history; missing baseline objects are a failure, never a skip. The 0.3.1 baseline is commit `4b51b6333213c376c8b7c173c12f35b408fdec2d`. The preparation script materializes exact Git blobs without checkout newline conversion; provenance rechecks every `lib/` and `native/` blob and records the worker version and SHA-256. Binary reproducibility is not attested. The official comparator uses the installed, pinned host reader separately. The test-only jobs package `@deepseek-ai/dsh-jobs-local@0.2.0-rc.2` was published on 2026-09-29, more than seven days before addition; it is needed to measure the complete Bash schema. diff --git "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" index 5123467d..82c9f01c 100644 --- "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" +++ "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" @@ -29,7 +29,7 @@ Windows 的基线 worker 路径须加 `.exe`。每次运行使用新的输出文 BM-01 始终保留偏移 13/31/51、每文件 8 行的案例,以及 1 行、32 行、约 600 字符长行和转义 Unicode/JSON 敏感性案例。夹具定义冻结,证据记录文件散列与夹具源文件散列,不自动挑选有利案例。 -运行功能门禁中的来源校验测试或准备基准基线前,检出必须包含固定的 0.3.1 Git 历史。浅克隆请先运行 `git fetch --unshallow`。CI 完整门禁任务与手动基准工作流获取完整历史;缺少基线对象属于失败,不会跳过。 +运行功能门禁中的来源校验测试或准备基准基线前,检出必须包含固定的 0.3.1 Git 历史。浅克隆请先运行 `git fetch --unshallow`。CI 完整门禁、可选基准冒烟、发布门禁与手动基准工作流获取完整历史;缺少基线对象属于失败,不会跳过。 0.3.1 基线固定为 `4b51b6333213c376c8b7c173c12f35b408fdec2d`。准备脚本直接还原 Git blob,避免 checkout 换行转换;运行前逐一校验 `lib/` 与 `native/`,并记录 worker 版本及 SHA-256,不宣称二进制可重现构建认证。官方比较组独立使用已安装的固定版本宿主读取器。测试专用 `@deepseek-ai/dsh-jobs-local@0.2.0-rc.2` 发布于 2026-09-29,添加时已超过七天,用于完整计量 Bash Schema。 From 4f92a16362f48ff7f6d8ff4cdf0e2a900bf530d6 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:48:56 +0800 Subject: [PATCH 114/118] docs(settings): name the validated Standard Schema contract --- docs/component-reference.md | 2 +- "docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/component-reference.md b/docs/component-reference.md index 94b3d61f..decbd7da 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -223,7 +223,7 @@ Only the selected OPS counterparts must be registered and visible as exact OPS-o The resident `ops_read_many` is not the reviewed replacement for `read`. Before selecting `read`, configure discovery `alwaysLoaded: [inspect_local_file]` or ensure that single-file capability is already active in that Native scope. Apply the same readiness rule to `glob` and `grep`. Internal presentation inspection lets discovery publish these configured tools during startup; ordinary prompt assembly and dispatch remain blocked until they are ready. -The component exports validated settings with public JSON schema descriptors for built-in host row forms. Optional browser multiselect pages require the explicit client carrier migration; see [configuration](configuration-reference.md). +The component exports StandardSchemaV1 validation together with public JSON schema descriptors for built-in host row forms. Optional browser multiselect pages require the explicit client carrier migration; see [configuration](configuration-reference.md). --- diff --git "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" index 8299f694..c5f52c77 100644 --- "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" +++ "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" @@ -225,7 +225,7 @@ OPS 调用始终保留自己的会话 cwd 路径基准、权限检查和输出 常驻的 `ops_read_many` 不是经评审的 `read` 替代项。选择 `read` 前,应配置发现组件 `alwaysLoaded: [inspect_local_file]`,或确保该单文件能力已在当前 Native 作用域激活。`glob` 和 `grep` 同样需要就绪。内部展示模式检查允许发现组件在启动时发布这些已配置工具;普通提示词组装和工具执行在就绪前仍会被拒绝。 -组件导出经过验证的设置与公开 JSON schema 描述,供宿主内置行表单使用。可选浏览器多选页面需要显式客户端承载行迁移,见[配置参考](配置参考.md)。 +组件同时导出 StandardSchemaV1 验证器与公开 JSON schema 描述,供宿主内置行表单使用。可选浏览器多选页面需要显式客户端承载行迁移,见[配置参考](配置参考.md)。 --- From 186a8fe6ca37d9f91a3cd9635c33e3a60e6ec664 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:49:32 +0800 Subject: [PATCH 115/118] fix(native): compact per-file revision receipts --- docs/CHANGELOG.md | 3 +++ src/modes/native/ranges.ts | 2 +- test/native-ranges.test.mjs | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 142287fc..b3671c36 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,9 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Compact each fully returned Native range's metadata receipt by removing its repeated notice label; keep exact source text, full revisions, continuation/EOF facts and the aggregate notice. +- 删除 Native 完整返回范围的逐文件元数据回执中重复的提示标签;保留精确正文、完整版本标识、续读/文件结束事实及汇总提示。 + - Fetch baseline Git history in full CI gates, optional benchmark smoke and the release gate so exact 0.3.1 provenance checks work on fresh checkouts. - 完整 CI 门禁、可选基准冒烟与发布门禁获取基线 Git 历史,确保全新检出能校验 0.3.1 的精确来源。 diff --git a/src/modes/native/ranges.ts b/src/modes/native/ranges.ts index ba04e39c..3342fee6 100644 --- a/src/modes/native/ranges.ts +++ b/src/modes/native/ranges.ts @@ -44,7 +44,7 @@ export function compactRanges(input: Row, value: Row): string[] | undefined { output.push(`=== FILE: ${quoted(base ? path.basename(item.path) : item.path)} (Lines ${span} of ${item.total}) ===`) for (const line of item.lines) output.push(`${line.line}|${encodeSourceLine(line.text)}`) if (!item.lines.length) output.push(item.total === 0 ? '(empty file)' : '(no lines in this range)') - output.push(`[Notice: revision=${item.revision}; ${item.complete ? 'end of file; complete=true' : `more lines; nextOffset=${item.next}`}.]`) + output.push(`[revision=${item.revision}; ${item.complete ? 'end of file; complete=true' : `more lines; nextOffset=${item.next}`}]`) } output.push(`[Notice: ${observed.length}/${input.files.length} requested ranges returned.]`) return output diff --git a/test/native-ranges.test.mjs b/test/native-ranges.test.mjs index 0b955381..eddf2e53 100644 --- a/test/native-ranges.test.mjs +++ b/test/native-ranges.test.mjs @@ -15,6 +15,8 @@ await test('real bounded ranges keep exact body, shared path base, full revision for (const [index, item] of value.items.entries()) { assert.ok(text.includes(`=== FILE: ${JSON.stringify(path.basename(files[index].path))}`)) assert.ok(text.includes('revision=' + item.revision)) + const continuation = item.complete ? 'end of file; complete=true' : `more lines; nextOffset=${item.nextOffset}` + assert.ok(text.split('\n').includes(`[revision=${item.revision}; ${continuation}]`), 'compact per-file receipt retains the full revision and continuation without a repeated notice label') assert.equal(item.bytes, Buffer.byteLength(inputs[index]), 'byte metadata remains canonical') } assert.match(text, /^1\|one$/m); assert.match(text, /^2\|two\\rthree$/m) @@ -22,6 +24,7 @@ await test('real bounded ranges keep exact body, shared path base, full revision assert.match(text, /more lines; nextOffset=3/); assert.match(text, /\(empty file\)/) assert.match(text, /end of file; complete=true/); assert.match(text, /3\/3 requested ranges returned/) assert.doesNotMatch(text, /worker-limit|bytes=|lineCount=|matched=/) + assert.equal(text.split('\n').filter(line => line.startsWith('[Notice:')).length, 1, 'one aggregate notice covers all fully returned ranges') assert.equal(JSON.stringify(value), before) })) await test('different parents retain absolute quoted paths; path control characters cannot inject headers', () => { From 8389c3a3a1dd5b39a24d76e81e6aa0503e80c758 Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:51:36 +0800 Subject: [PATCH 116/118] docs(release): record approved benchmark scope and receipt contract --- README.en.md | 7 +-- README.md | 2 +- README.zh.md | 2 +- ...21\345\270\203\345\200\231\351\200\211.md" | 18 +++---- docs/CHANGELOG.md | 8 +-- docs/configuration-reference.md | 2 +- docs/discovery.md | 4 +- docs/manual-validation.md | 3 +- docs/native-ranges.md | 4 +- docs/release-0.4.0.md | 49 +++++++++++-------- docs/release-process.md | 4 +- docs/roadmap.md | 2 +- docs/usage.md | 2 +- ...77\347\224\250\346\214\207\345\215\227.md" | 2 +- ...45\345\205\267\345\217\221\347\216\260.md" | 4 +- ...13\345\212\250\351\252\214\346\224\266.md" | 2 +- ...07\344\273\266\350\214\203\345\233\264.md" | 4 +- .../\350\267\257\347\272\277\345\233\276.md" | 2 +- ...15\347\275\256\345\217\202\350\200\203.md" | 2 +- 19 files changed, 65 insertions(+), 58 deletions(-) diff --git a/README.en.md b/README.en.md index effbf076..fa8169a3 100644 --- a/README.en.md +++ b/README.en.md @@ -26,9 +26,10 @@ PTC code receives typed Outcome values from the same one-shot Rust worker. Host strict TypeScript, compiled to the existing JavaScript package paths. > [!IMPORTANT] -> This branch is the **0.4.0 release candidate**, not a published release. Release is blocked: -> BM-01 does not meet its output-reduction threshold, and the full default Windows Native schema -> exceeds BM-03's 400-token budget. Functional CI does not satisfy these performance gates. +> This branch is the **0.4.0 release candidate**, not a published release. Approved acceptance requires +> at least 40% less BM-01 output than OPS 0.3.x; the official reader is a separate comparison. +> BM-03 limits file/discovery schemas to 400 tokens while keeping Bash at startup and reporting the +> full total separately. Final measured evidence and functional CI are both required. > See [release status and limitations](docs/release-0.4.0.md). Commands using `@latest` below select > the published package; build and pack this checkout to evaluate the candidate. diff --git a/README.md b/README.md index b3254b6a..7888d92c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ OPS 为 DSH 增加有界文件与文档工具。Native 模式返回紧凑、带行号的文本;PTC 代码从同一个按调用启动的 Rust worker 获取类型化 Outcome。宿主集成使用严格 TypeScript,编译后保持已有 JavaScript 包路径。 > [!IMPORTANT] -> 本分支是 **0.4.0 发布候选**,尚未发布。发布仍被阻止:BM-01 未达到输出缩减门槛,完整 Windows Native 默认 schema 也超过 BM-03 的 400 token 预算。功能 CI 通过不等于性能门禁通过。 +> 本分支是 **0.4.0 发布候选**,尚未发布。已批准的验收范围为:BM-01 相比 OPS 0.3.x 输出减少至少 40%,官方读取另列对照;BM-03 对文件/发现 schema 执行 400 token 上限,同时保留 Bash 启动常驻并单列完整总数。最终实测证据与功能 CI 缺一不可。 > 详情见[发布状态与限制](docs/0.4.0发布候选.md)。下方 `@latest` 命令安装已发布版本;评估候选版本需自行构建本检出目录并打包。 - Native 默认提供 `ops_read_many` 与 `ops_discover`;符合条件的 Windows 会话另有 `ops_bash`。高级工具在所属 agent 的下一次供应商请求中激活。 diff --git a/README.zh.md b/README.zh.md index b3254b6a..7888d92c 100644 --- a/README.zh.md +++ b/README.zh.md @@ -24,7 +24,7 @@ OPS 为 DSH 增加有界文件与文档工具。Native 模式返回紧凑、带行号的文本;PTC 代码从同一个按调用启动的 Rust worker 获取类型化 Outcome。宿主集成使用严格 TypeScript,编译后保持已有 JavaScript 包路径。 > [!IMPORTANT] -> 本分支是 **0.4.0 发布候选**,尚未发布。发布仍被阻止:BM-01 未达到输出缩减门槛,完整 Windows Native 默认 schema 也超过 BM-03 的 400 token 预算。功能 CI 通过不等于性能门禁通过。 +> 本分支是 **0.4.0 发布候选**,尚未发布。已批准的验收范围为:BM-01 相比 OPS 0.3.x 输出减少至少 40%,官方读取另列对照;BM-03 对文件/发现 schema 执行 400 token 上限,同时保留 Bash 启动常驻并单列完整总数。最终实测证据与功能 CI 缺一不可。 > 详情见[发布状态与限制](docs/0.4.0发布候选.md)。下方 `@latest` 命令安装已发布版本;评估候选版本需自行构建本检出目录并打包。 - Native 默认提供 `ops_read_many` 与 `ops_discover`;符合条件的 Windows 会话另有 `ops_bash`。高级工具在所属 agent 的下一次供应商请求中激活。 diff --git "a/docs/0.4.0\345\217\221\345\270\203\345\200\231\351\200\211.md" "b/docs/0.4.0\345\217\221\345\270\203\345\200\231\351\200\211.md" index 98cc55dd..311caa22 100644 --- "a/docs/0.4.0\345\217\221\345\270\203\345\200\231\351\200\211.md" +++ "b/docs/0.4.0\345\217\221\345\270\203\345\200\231\351\200\211.md" @@ -2,7 +2,7 @@ English: [0.4.0 release candidate](release-0.4.0.md). -**尚未发布。** 版本字段仅标识候选,不代表允许打 tag、发布 npm 包或合并最终发布 PR。功能 CI 与五项验收基准分别计数;BM-01 和完整默认 BM-03 仍未通过,发布继续被阻止。 +**尚未发布。** 版本字段仅标识候选,不代表允许打 tag、发布 npm 包或合并最终发布 PR。功能 CI 与五项验收基准分别计数。已批准 BM-01 相比 OPS 0.3.x 输出至少减少 40%,官方读取另列对照;BM-03 对文件/发现 schema 执行 ≤400 token 上限,Bash 保持启动常驻,其成本与完整启动总数另列。最终实测证据仍是必需条件。 ## 已实现的能力 @@ -44,14 +44,14 @@ Bash 与 PowerShell 均不是 npm 载荷依赖。受管理 Bash 安装/删除、 | 6 — 引用/预算 | 每结果 10 MiB、每会话 100 MiB;两活动/八等待、worker 30 秒上限;审计独立。 | result-references/spill/lifecycle、call-budget、native-result;权限不足清理可能留下已失效临时块。 | | 7 — PTC | 19 个类型化 SDK API、三个常驻入口、独立允许池与下一步骤加载。 | ptc-outcome/schema/sdk、progressive-loop;当前冻结 SDK 无法调用排队 API。 | | 8 — 发现 | Native 允许池、常驻子集、单次 1–5 项,仅所属下一请求可见。 | discovery/composition 与真实 AgentLoop BM-04;不授予文件权限。 | -| 9 — 常驻面 | read_many、discover 及符合条件的 Windows Bash;高级工具初始不可见。 | 精确供应商目录;完整 Windows BM-03 为 660 tokens,≤400 仍未解决。 | +| 9 — 常驻面 | read_many、discover 及符合条件的 Windows Bash;高级工具初始不可见。 | 精确供应商目录;已批准 BM-03 对文件/发现小计执行 ≤400 上限,含 Bash 的完整总数另列。 | | 10 — 重读证据 | 最新成功普通供应商请求中的确切正文,加重新读取文件验证。 | context-read-dedup/many 与真实替换/投影;证据缺失即恢复全文。 | | 11 — Bash | 宿主审批/jobs/subprocess、绝对执行器、120/600 秒、可选托管/Git Bash。 | shells-host/execution/manager;Windows 仍为部分约束,必须匹配运行时。 | | 12 — PowerShell | 移除载荷与执行器覆盖;显式标准宿主升级计划与执行。 | 运行时管理器/传输回归;未安装操作者机器,需重启并检查自定义路径。 | | 13 — 替换 | 显式选择 read/glob/grep,校验确切 OPS 定义及就绪;read 对应 inspect_local_file。 | 真实作用域挂载/就绪回归;write/edit 因 CAS 契约不同不提供替换。 | | 14 — 迁移/设置 | 七个稳定行、保存覆盖、新默认值、panel 物理停止及内置严格表单。 | platform-defaults、settings-host/UI/client/carrier/runtime、panel-stop;自定义页面需可选离线承载行,真实 TUI 需独立宿主。 | | 15 — 文档 | 按需 CSV/TSV、DOCX、受限经典 xref PDF,安全前缀如实报告。 | table/docx/pdf worker+host 与 BM-04;不支持格式、排版/OCR 仍按上文明确排除。 | -| 16 — 验收/发布 | 严格 TS/TSX、候选版本、双语文档、独立按需 BM-01–05。 | 功能 CI 与基准验收分别计数;BM-01/BM-03 失败,尚不能批准最终发布。 | +| 16 — 验收/发布 | 严格 TS/TSX、候选版本、双语文档、独立按需 BM-01–05。 | 功能 CI 与基准验收分别计数;最终评审前按明确批准的标准重跑全部五项门禁。 | ## 堆叠评审与 staging 集成 @@ -59,20 +59,20 @@ Bash 与 PowerShell 均不是 npm 载荷依赖。受管理 Bash 安装/删除、 维护者应按计划/依赖顺序将前置切片评审并集成到 release/0.4.0:1;2→3 与 4a→4b;随后 5a→5b、6a→6b→6c→6d、7–13、14a→14b、15a→15b→15c→15d、16a→16b→16c。前置已进入 staging 后,将下一评审调整到合适的当前基线,确认 diff 仅包含目标切片,集成有变化则重新运行必需 CI。不能仅因旧分支仍是评审基线,就把后续工作合入过时前置分支;保持共享历史,不由本次工作代替维护者合并 PR。 -全部功能门禁和验收决策解决后,维护者才应评审 release/0.4.0 → main 的最终发布 PR。BM-01 需要明确处理固定无损工作负载/比较要求;BM-03 需要明确处理完整启动常驻与 400 token 预算的冲突,同时保留契约。本文不代替该决策;若批准变更要求,应公开同步 issue、文档和验收断言后重跑。版本提升、PR ready 或功能 CI 通过均不授权打 tag/发布。 +全部功能门禁与已批准验收标准通过后,维护者才应评审 release/0.4.0 → main 的最终发布 PR。操作者已明确批准 OPS 0.3.x 为 BM-01 权威基线、官方读取另列对照,以及文件/发现 ≤400 token 的 BM-03 范围并保留 Bash 启动常驻。固定无损工作负载、完整默认工具清单、schema 契约和权限检查保持不变。下文保留之前完整范围失败,不将它悄然重分类。版本提升、PR ready 或单独功能 CI 通过均不授权打 tag/发布。 -## 验收状态 +## 验收证据 -以下为固定 issue 工作负载、固定宿主的 Windows 已评审快照,不是通用性能或供应商账单结论。[基准门禁](基准门禁.md)记录复现命令、来源与最终重跑状态。 +最终候选须按上文已批准标准保留全部五项结果。以下 Windows 快照早于范围批准与逐文件回执精简,是历史证据,不是更新候选的最终结论。它采用固定 issue 工作负载与固定宿主,不代表通用性能或供应商账单结论。[基准门禁](基准门禁.md)记录复现命令、来源与当前重跑状态。 -| 门禁 | 已评审结果 | +| 门禁 | 批准调整前的历史结果 | | --- | --- | | BM-01 | 失败:候选5348字节,OPS0.3.1为8883字节,减少39.795%;官方读取5457字节,仅减少1.997%。请求源文本本身为4320字节。 | | BM-02 | 通过:一个带工具轮次完成五处编辑,两个过期 CAS 对照均拒绝。 | -| BM-03 | 失败:完整供应商实际顺序660 tokens/3219字节,包含 Windows Bash 和真实 jobs;要求≤400。目录顺序661是另一种 tokenizer 边界结果。 | +| BM-03 | 之前完整范围失败:含 Windows Bash 与真实 jobs 的供应商实际顺序为660 tokens/3219字节,超过当时完整范围≤400目标。已批准的文件/发现小计为241 tokens/1094字节;目录顺序661描述的是另一顺序的完整工具面。 | | BM-04 | 通过:PDF 初始不可见,发现后所属供应商下一请求可见,兄弟 agent 不可见,并验证执行结果。 | | BM-05 | 通过:凭据保护读取与工作区外写入被拒绝且不泄露被拒路径/内容;普通允许的外部读取是明确标注的允许对照。 | -仅文件/发现入口的241 token 测量不能满足完整默认 BM-03。没有删除 schema 契约、请求源文本、门槛或工作负载来宣称成功。缺少 Bash 测试运行时会阻止完整 Windows 测量,不会缩小统计范围。最终候选须在所有修复后重跑功能与基准门禁;未解决失败必须继续明确记录,由维护者决定如何处理。 +已批准的范围调整改变与400比较的 BM-03 小计,不从启动面或确切就绪清单删除 Bash;应同时记录文件/发现分数、Bash 成本与完整供应商总数。缺少 Bash 测试运行时仍阻塞 Windows 测量。紧凑回执仅移除逐文件重复的 `Notice: ` 前缀与末尾句点,保留完整版本、续读偏移、EOF/完整性及汇总提示;请求正文、工作负载、schema 与权限契约不变。调整后的新结果须明确记录;历史失败本身不能判定更新候选通过或失败。 按[手动验收清单](手动验收.md)复核。本候选不合并 PR、不发布二进制,也不启用被关闭的发布工作流。 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1605eac1..ccd81aa3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,8 +7,8 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] -- Prepare the 0.4.0 candidate version across owned npm/plugin and Rust workspace metadata without dependency changes. Audit paired release, usage, component, SDK, discovery and manual-validation documentation; keep BM-01 and full-default BM-03 failures explicit and separate from functional CI. No release/tag is created. -- 将自有 npm/插件与 Rust workspace 版本统一为 0.4.0 候选,不变更依赖;同步双语发布、使用、组件、SDK、发现和手动验收文档。BM-01 与完整默认 BM-03 失败继续明确记录,不以功能 CI 代替验收,不创建发布或 tag。 +- Prepare the 0.4.0 candidate version across owned npm/plugin and Rust workspace metadata without dependency changes. Audit paired release, usage, component, SDK, discovery and manual-validation documentation; record the explicitly approved benchmark scope and retain historical failures separately from functional CI. No release/tag is created. +- 将自有 npm/插件与 Rust workspace 版本统一为 0.4.0 候选,不变更依赖;同步双语发布、使用、组件、SDK、发现和手动验收文档。记录明确批准的基准范围并保留历史失败,不以功能 CI 代替验收,不创建发布或 tag。 - Fetch baseline Git history in full CI gates, optional benchmark smoke and the release gate so exact 0.3.1 provenance checks work on fresh checkouts. - 完整 CI 门禁、可选基准冒烟与发布门禁获取基线 Git 历史,确保全新检出能校验 0.3.1 的精确来源。 @@ -244,8 +244,8 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Keep Native startup to the bounded multi-file reader (#261, 9/16) - Publish `ops_read_many` from startup; make the single-file/hex reader, search, mutations and Native result paging discoverable. Routing describes only published tools, and the trial explicitly loads its advanced operations. -- Measure compact file/discovery schemas at 241 tokens; the integrated full Windows Bash/jobs provider order is 660 tokens (catalogue order 661). The full 400-token BM-03 gate remains unresolved. -- Native 启动仅常驻多文件读取器;单文件/hex、搜索、修改与引用分页按需加载。路由仅描述已发布工具;完整工具面含 Bash 仍超过 400-token 门禁,保留真实测量而不宣称通过。 +- Measure compact file/discovery schemas at 241 tokens; the integrated full Windows Bash/jobs provider order is 660 tokens (catalogue order 661). The earlier full-surface 400-token requirement failed; the operator subsequently approved a file/discovery-only budget with startup Bash and full totals retained. +- Native 启动仅常驻多文件读取器;单文件/hex、搜索、修改与引用分页按需加载。路由仅描述已发布工具;含 Bash 的完整工具面曾超过原400-token门禁;操作者随后批准仅对文件/发现小计设预算,保留 Bash 启动常驻与完整总数。 ### Publish progressive tools before the next real provider snapshot (#261, 7–8/16) diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index f26d7994..a0f5d393 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -211,7 +211,7 @@ Cordis validates configuration through `StandardSchemaV1`; detached public setti ## Prompt registration -Prompts are registered as scoped runtime sections, not written to AGENTS.md. Disabling shell withdraws its routing; disabling file withdraws its Native file guidance. Fresh Native file publishes only ops_read_many, discovery publishes ops_discover, and eligible Windows shell scopes add ops_bash. Advanced Native tools are discovered or explicitly always loaded. PTC owns its separate typed SDK and current permission checks. Fixed-schema costs are measured separately; full-default BM-03 remains unresolved. +Prompts are registered as scoped runtime sections, not written to AGENTS.md. Disabling shell withdraws its routing; disabling file withdraws its Native file guidance. Fresh Native file publishes only ops_read_many, discovery publishes ops_discover, and eligible Windows shell scopes add ops_bash. Advanced Native tools are discovered or explicitly always loaded. PTC owns its separate typed SDK and current permission checks. The approved BM-03 budget measures file/discovery schemas at ≤400 tokens. Bash startup residency is unchanged; its schema and the complete startup total are reported separately, and missing Bash cannot satisfy the Windows readiness check. --- diff --git a/docs/discovery.md b/docs/discovery.md index 19f54903..92f55f22 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -34,7 +34,7 @@ Only this plugin's scoped `ctx.tools.register`, `restrict`, prompt sections and The deterministic entry measurement uses `js-tiktoken@1.0.21` with `cl100k_base`, encoding compact JSON of the pinned host `ToolSchema` fields (`name`, `description`, `parameters`). The discovery entry is **94 tokens / 410 UTF-8 bytes**. This is a reproducible schema measurement, not provider billing, and excludes tool-message framing, prompt guidance and other OPS components. The tokenizer is a test-only dependency published on 2025-08-09; its locked `base64-js` dependency is also test-only. -The default file/discovery surface is now `ops_read_many` plus `ops_discover`: **241 tokens / 1094 bytes** in actual host assembly order. The reader accepts multiple file ranges in one startup call; `ops_inspect_local_file` (including hex), search, mutations and Native result paging are discoverable. Routing names only published tools. The final Windows provider order with the full Bash/jobs contract measures **660 tokens / 3219 bytes**; catalogue order gives 661 tokens. Both exceed the full BM-03 limit of 400. Numeric bounds, defaults, path bases and permission contracts remain intact. Operator always-loaded choices expand the initial surface. See [benchmark evidence](benchmark-gates.md). +The default file/discovery surface is now `ops_read_many` plus `ops_discover`: **241 tokens / 1094 bytes** in actual host assembly order. The reader accepts multiple file ranges in one startup call; `ops_inspect_local_file` (including hex), search, mutations and Native result paging are discoverable. Routing names only published tools. The final Windows provider order with the full Bash/jobs contract measures **660 tokens / 3219 bytes**; catalogue order gives 661 tokens. These full totals remain visible under the explicitly approved BM-03 scope: the ≤400-token gate applies to file/discovery schemas, while Bash remains at startup. Numeric bounds, defaults, path bases and permission contracts remain intact. Operator always-loaded choices expand the initial surface. See [benchmark evidence](benchmark-gates.md). BM-04 now runs the implemented PDF reader through the real AgentLoop: its schema is absent initially, remains pending in the current round, appears in the owning next provider request, stays absent for a sibling, and then returns the fixture text. This verifies the supported PDF subset, not general PDF/OCR coverage. @@ -42,4 +42,4 @@ Built-in settings persist validated arrays. Optional browser multi-selects use t Overlapping discovery rows use the nearest owning scope; the last row mounted in the same scope wins until it is unloaded. File rows may mount before or after discovery. Only exact OPS-owned Native registrations are yielded or masked; unrelated external registrations remain visible. PTC Native shadows retain their exact origin, and discovered activations cannot be inherited by another agent. Native `result` is page/release only and may be discovered in read-only mode; the worker still rejects forbidden release actions. Resident `read_many` and discoverable `edit_many` retain their existing per-file completeness and CAS contracts. -BM-03 remains a full default Native-surface gate, including Windows Bash when integrated. The entry-only measurement above does not satisfy that gate. +BM-03 now gates the approved file/discovery subtotal, and separately reports Bash and the full default Native surface. The exact default inventory remains mandatory, including usable Windows Bash and its host jobs contract; absence blocks the measurement rather than earning a smaller score. Scope approval does not change tool defaults, arguments or permission boundaries. diff --git a/docs/manual-validation.md b/docs/manual-validation.md index 3e4ff065..cdb81e14 100644 --- a/docs/manual-validation.md +++ b/docs/manual-validation.md @@ -10,8 +10,7 @@ open its settings page. Run the [full release gate](CONTRIBUTING.md#the-verification-gate) with the required worker and, on Windows, the verified Bash fixture. A skipped required runtime is not a pass. Run the separate [BM-01–05 gates](benchmark-gates.md); record individual failed/blocked/passed states and raw receipts. -BM-01 and full-default BM-03 remain release blockers. Do not substitute entry-only schema counts, -a smaller workload, missing Bash or functional CI success for acceptance. +Apply the explicitly approved criteria: BM-01 requires at least 40% fewer bytes than OPS 0.3.x on the fixed lossless workload; retain the official reader as a separate comparison. BM-03 applies ≤400 tokens to the file/discovery schemas while keeping Bash at startup. Record both the scoped score and the full actual provider total, and require the exact default tool inventory including usable Windows Bash. A smaller workload, missing Bash or functional CI success cannot replace these measurements. ## Profiles and configuration diff --git a/docs/native-ranges.md b/docs/native-ranges.md index 1b0e1988..2b3f39f6 100644 --- a/docs/native-ranges.md +++ b/docs/native-ranges.md @@ -9,11 +9,11 @@ A fully returned `ops_read_many` range uses one path header, exact numbered text === FILE: "server.ts" (Lines 1-2 of 80) === 1|import { createServer } from 'http'; 2|const port = 8080; -[Notice: revision=cfd1bc81432fd6b88748b827f46bb4242db17cf8655cf4ea90f3ba0e62065355; more lines; nextOffset=3.] +[revision=cfd1bc81432fd6b88748b827f46bb4242db17cf8655cf4ea90f3ba0e62065355; more lines; nextOffset=3] ``` The characters after `N|` are the source line, including leading spaces. Line bodies use reversible JSON escapes: backslashes are doubled, controls and Unicode line separators are escaped, and quotes remain literal. For example, an actual tab appears as `\t`, while the two source characters backslash-plus-t appear as `\\t`. This shared encoding applies to compact ranges and ordinary numbered source output, and prevents source text from forging extra rows or headers. LF/CRLF accounting remains the worker's physical-line accounting. -“More lines” means the file has not been exhausted; use the stated `nextOffset`. “End of file; complete=true” means this range reached the end. The aggregate “requested ranges returned” receipt means every requested range body was returned, not that every whole file was read. Full revisions, total line counts and per-file continuation remain visible. Byte sizes and duplicate counters remain available in canonical/PTC data. +“More lines” means the file has not been exhausted; use the stated `nextOffset`. “End of file; complete=true” means this range reached the end. The aggregate “requested ranges returned” receipt means every requested range body was returned, not that every whole file was read. Full revisions, total line counts and per-file continuation remain visible. Byte sizes and duplicate counters remain available in canonical/PTC data. Per-file revision receipts omit the repeated `Notice: ` prefix and terminal period; the aggregate `Notice` and every revision/continuation/completeness fact remain intact. Physical clipping, missing files, unexpected coverage, stored references and other uncertain results retain the diagnostic renderer and explicit recovery notices. Final display clipping removes the successful aggregate claim and asks for the original range with a smaller limit. Deduplication recognizes both `N|` and the existing single-file `N | ` format; only an exact prior body in current provider context permits an unchanged-reference receipt. diff --git a/docs/release-0.4.0.md b/docs/release-0.4.0.md index 98711114..a6f89727 100644 --- a/docs/release-0.4.0.md +++ b/docs/release-0.4.0.md @@ -3,8 +3,10 @@ 中文:[0.4.0 发布候选](0.4.0发布候选.md). **Not released.** Version fields identify this candidate; they do not authorize a tag, npm publication -or a final merge. Functional CI and the five acceptance benchmarks are separate gates. BM-01 and the -full default BM-03 are still failing, so release remains blocked. +or a final merge. Functional CI and the five acceptance benchmarks are separate gates. The approved +BM-01 threshold is at least 40% fewer output bytes than OPS 0.3.x; the official reader remains a +separate comparison. BM-03 applies ≤400 tokens to file/discovery schemas while retaining Bash at +startup and reporting its cost and the full startup total separately. Final measured evidence is required. ## Implemented surface @@ -70,14 +72,14 @@ The detailed limits above and in linked contracts remain part of acceptance. | 6 — References/budgets | 10 MiB/result, 100 MiB/session; two active/eight waiting calls and 30-second worker ceiling; audit data separate. | result-references/spill/lifecycle, call-budget, native-result; permission-limited cleanup can leave invalidated temp blobs. | | 7 — PTC | 19 typed SDK APIs, three resident entries; allowed pools and next-step loading independent of Native. | ptc-outcome/schema/sdk and progressive-loop; current frozen SDK cannot call queued APIs. | | 8 — Discovery | Native allowed pool, always-loaded subset and 1–5 selections; owning agent's next request only. | discovery/composition and real AgentLoop BM-04; no filesystem permission grant. | -| 9 — Resident surface | read_many plus discover, with eligible Windows Bash; advanced tools absent initially. | Exact provider inventory; full Windows BM-03 is 660 tokens, so the ≤400 target is unresolved. | +| 9 — Resident surface | read_many plus discover, with eligible Windows Bash; advanced tools absent initially. | Exact provider inventory; approved BM-03 gates the file/discovery subtotal at ≤400 and separately reports the full Bash-inclusive total. | | 10 — Re-read evidence | Exact successful body in the latest completed ordinary provider request plus fresh file checks. | context-read-dedup/many with real replacement/projection; missing evidence restores full output. | | 11 — Bash | Host approvals/jobs/subprocess, absolute executor, 120/600-second timing, optional managed/Git Bash. | shells-host/execution/manager; Windows confinement remains partial and runtime compatibility is mandatory. | | 12 — PowerShell | No bundled payload or executor overlay; explicit standard host upgrade plan and execution. | Host runtime manager/transport regressions; no real operator installation performed; restart/custom-path review required. | | 13 — Replacement | Opt-in read/glob/grep with exact OPS definition identity and readiness; read maps to inspect_local_file. | Real scoped mount/readiness regressions; write/edit replacement is excluded because CAS contracts differ. | | 14 — Migration/settings | Seven stable rows, saved overrides, fresh defaults and physical panel stop; validated built-in forms. | platform-defaults, settings-host/UI/client/carrier/runtime and panel-stop; custom pages need optional offline carrier; real TUI requires a separate host. | | 15 — Documents | Discoverable CSV/TSV, DOCX and restricted classic-xref PDF readers with honest safe prefixes. | table/docx/pdf worker+host suites and BM-04; unsupported formats/layout/OCR remain excluded, as detailed above. | -| 16 — Acceptance/release | Strict TS/TSX, versioned candidate, paired docs and separate opt-in BM-01–05 evidence. | Functional CI and benchmark acceptance are separate; BM-01/BM-03 fail, so no final release approval. | +| 16 — Acceptance/release | Strict TS/TSX, versioned candidate, paired docs and separate opt-in BM-01–05 evidence. | Functional CI and benchmark acceptance remain separate; rerun all five gates under the explicitly approved criteria before final review. | ## Stacked review and staging rollout @@ -95,32 +97,37 @@ verify that its diff contains only the intended slice, and rerun required CI aft change. Do not merge a successor into an obsolete predecessor merely because it remains the review base. Keep shared history intact; this work does not merge maintainers' PRs. -Only after all required functional gates and acceptance decisions are resolved should maintainers -review release/0.4.0 → main as the final release PR. BM-01 needs an explicit decision about the fixed -lossless workload/comparator requirement; BM-03 needs an explicit decision about full startup residency -versus the 400-token budget while preserving contracts. No such decision is assumed here. Any approved -requirement change must update the issue, documentation and acceptance assertions transparently before -rerunning them. A version bump, a ready PR or passing functional CI does not authorize a tag/publication. +Only after all required functional gates and approved acceptance criteria pass should maintainers +review release/0.4.0 → main as the final release PR. The operator explicitly approved OPS 0.3.x as the +authoritative BM-01 comparator, with the official reader reported separately, and the file/discovery +≤400-token BM-03 scope while keeping Bash at startup. The fixed lossless workload, full default tool +inventory, schema contracts and authority checks remain unchanged. The earlier full-scope failure +is retained below; it is not silently reclassified. A version bump, a ready PR or passing functional +CI alone does not authorize a tag/publication. -## Acceptance status +## Acceptance evidence -The reviewed Windows snapshot uses the fixed issue workload and pinned host; it is not a universal -speedup or provider-billing measurement. [Benchmark gates](benchmark-gates.md) define reproducible -commands, evidence provenance and current rerun status. +Apply the approved criteria above to each final candidate and retain all five gate results. The +following Windows snapshot predates the scope approval and compact per-file receipt refinement; it +is historical evidence, not a verdict for the updated candidate. It uses the fixed issue workload +and pinned host, not a universal speedup or provider-billing measurement. [Benchmark gates](benchmark-gates.md) +define reproducible commands, evidence provenance and current rerun status. -| Gate | Reviewed result | +| Gate | Historical result before the approved refinements | | --- | --- | | BM-01 | Fail: 5348 candidate bytes versus 8883 OPS 0.3.1 bytes (39.795% reduction); official read 5457 bytes (1.997%). Requested source alone is 4320 bytes. | | BM-02 | Pass: five edits in one tool-bearing round and both stale-CAS controls rejected. | -| BM-03 | Fail: full actual provider order 660 tokens/3219 bytes, including Windows Bash and real jobs; target ≤400. Catalogue order 661 is a separate tokenizer boundary result. | +| BM-03 | Prior full-scope failure: actual provider order 660 tokens/3219 bytes, including Windows Bash and real jobs, exceeded the then-full ≤400 target. The approved file/discovery subtotal was 241 tokens/1094 bytes; catalogue order 661 describes the full surface in a different order. | | BM-04 | Pass: PDF absent initially, available on the owning provider request after discovery, absent for a sibling, then executed. | | BM-05 | Pass: credential-fenced reads and outside writes rejected without denied-path/content leakage; an ordinary permitted outside read is an explicit allowed control. | -The file/discovery-only 241-token measurement does not pass the full-default BM-03 gate. No schema -contract, requested source, threshold or workload was removed to claim success. A missing optional -Bash fixture blocks the full Windows measurement rather than reducing its denominator. The final -candidate needs fresh functional and benchmark evidence after all fixes; unresolved failures must -remain explicit until maintainers decide how to resolve them. +The approved scope changes which BM-03 subtotal is compared with 400; it does not remove Bash from +startup or from the exact readiness inventory. Report the file/discovery score, Bash cost and complete +provider total together. A missing optional Bash fixture still blocks the Windows measurement. +The compact receipt removes only a repeated per-file `Notice: ` prefix and terminal period; full +revisions, continuation offsets, EOF/completeness and the aggregate notice remain. Requested source, +workload and schema/authority contracts are unchanged. Fresh results after these refinements must be +reported explicitly; historical failures alone neither pass nor fail the revised candidate. Use the [manual acceptance checklist](manual-validation.md). This candidate does not merge PRs, publish binaries or enable a disabled release workflow. diff --git a/docs/release-process.md b/docs/release-process.md index 8b17be86..f71c79b2 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -1,7 +1,7 @@ # 发布流程与如何启用 -> 0.4.0 仍是[未发布候选](0.4.0发布候选.md),BM-01/BM-03 未通过。下面的仓库工作流状态是历史记录,实际发布前须重新检查;本次更新不启用发布、不打 tag、不发布包。 -> The [0.4.0 candidate](release-0.4.0.md) remains blocked by BM-01/BM-03. Workflow state below is historical and must be checked before any release; this update does not enable publishing, tag or publish packages. +> 0.4.0 仍是[未发布候选](0.4.0发布候选.md),验收采用已批准的 OPS 0.3.x BM-01 基线与文件/发现 BM-03 预算,最终结果须单独核验。下面的仓库工作流状态是历史记录,实际发布前须重新检查;本次更新不启用发布、不打 tag、不发布包。 +> The [0.4.0 candidate](release-0.4.0.md) uses the approved OPS 0.3.x BM-01 comparator and file/discovery BM-03 budget; verify final results separately. Workflow state below is historical and must be checked before any release; this update does not enable publishing, tag or publish packages. 对象是 `.github/workflows/release.yml`(workflow 名 `Release`)。发布目标只有一个:**GitHub Release**(tag + 自动 notes + 一个可安装主包 tarball + `SHA256SUMS`)。npm publish 不在这条流水线里,理由见下。 diff --git a/docs/roadmap.md b/docs/roadmap.md index e4aec428..cce809aa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,7 +15,7 @@ ## 0.4.0 candidate -Separate Native/PTC projections, scoped discovery, bounded CSV/TSV, DOCX and restricted PDF readers, runtime/settings changes and strict TypeScript source are implemented. This is not a release announcement: BM-01 and the full-default BM-03 remain blockers. See [candidate status and format limits](release-0.4.0.md). +Separate Native/PTC projections, scoped discovery, bounded CSV/TSV, DOCX and restricted PDF readers, runtime/settings changes and strict TypeScript source are implemented. This is not a release announcement: final evidence must satisfy the approved OPS 0.3.x BM-01 threshold and file/discovery BM-03 budget, while separately reporting the official comparison and full Bash-inclusive startup total. See [candidate status and format limits](release-0.4.0.md). ## Later diff --git a/docs/usage.md b/docs/usage.md index f3d4a15b..b248d3c2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -4,7 +4,7 @@ The complete install, update and remove flow, plus every option of the command-l ## 0.4.0 candidate and component defaults -This checkout is an unreleased candidate; BM-01 and the full default BM-03 still block release. +This checkout is an unreleased candidate; acceptance uses the approved OPS 0.3.x BM-01 comparator and file/discovery BM-03 budget, with Bash still resident and the full total reported separately. See [candidate status](release-0.4.0.md). The seven rows enable file/PTC/discovery, enable shell only on Windows, and leave panel/TUI/replacements off in fresh profiles. Existing explicit switches and name-qualified settings survive upgrade. Only read_many/discover are initially resident in Native, diff --git "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" index fba35604..e2ec82f4 100644 --- "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -4,7 +4,7 @@ ## 0.4.0 候选与组件默认值 -本检出是未发布候选;BM-01 与完整默认 BM-03 仍阻止发布,见[候选状态](0.4.0发布候选.md)。新 profile 的七行默认开启 file/PTC/discovery,仅 Windows 开启 shell,panel/TUI/替换关闭;已有显式开关和带名称配置在升级后保留。Native 初始常驻 read_many/discover 及可用 Windows Bash,文档能力按需加载。PTC 有19个类型化 API,使用独立配置池与 SDK 加载器。[配置参考](配置参考.md)说明内置表单和可选浏览器承载行;升级按[手动验收](手动验收.md)复核。 +本检出是未发布候选;验收采用已批准的 OPS 0.3.x BM-01 基线与文件/发现 BM-03 预算,Bash 仍常驻且完整总数另列,见[候选状态](0.4.0发布候选.md)。新 profile 的七行默认开启 file/PTC/discovery,仅 Windows 开启 shell,panel/TUI/替换关闭;已有显式开关和带名称配置在升级后保留。Native 初始常驻 read_many/discover 及可用 Windows Bash,文档能力按需加载。PTC 有19个类型化 API,使用独立配置池与 SDK 加载器。[配置参考](配置参考.md)说明内置表单和可选浏览器承载行;升级按[手动验收](手动验收.md)复核。 --- diff --git "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" index 3d0fccbf..30209f3a 100644 --- "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" +++ "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" @@ -34,7 +34,7 @@ English: [Advanced tools on demand](discovery.md). 确定性的入口测量固定 `js-tiktoken@1.0.21` 和 `cl100k_base`,编码固定宿主 `ToolSchema` 字段(`name`、`description`、`parameters`)的紧凑 JSON。发现入口为 **94 tokens / 410 UTF-8 字节**。这是可复现的 schema 测量,不是供应商账单;不含消息封装、提示词指引和其它 OPS 组件。分词器是测试依赖,发布于 2025-08-09;锁定的 `base64-js` 也仅供测试使用。 -默认文件/发现工具面为 `ops_read_many` 加 `ops_discover`,**241 tokens / 1094 字节**。完整 Windows Bash/jobs 契约按供应商实际顺序为 **660 tokens / 3219 字节**,目录顺序为661 tokens,均超过完整 BM-03 的400预算。高级读取、搜索、修改、文档与结果分页按需发现;路由只列出已发布工具。数值边界、默认值、路径基准与权限契约保持不变;始终加载项会扩大初始面。见[基准证据](基准门禁.md)。 +默认文件/发现工具面为 `ops_read_many` 加 `ops_discover`,**241 tokens / 1094 字节**。完整 Windows Bash/jobs 契约按供应商实际顺序为 **660 tokens / 3219 字节**,目录顺序为661 tokens。已明确批准 BM-03 的 ≤400 token 门禁仅作用于文件/发现 schema;Bash 保持启动常驻,其成本及完整总数继续单列。高级读取、搜索、修改、文档与结果分页按需发现;路由只列出已发布工具。数值边界、默认值、路径基准与权限契约保持不变;始终加载项会扩大初始面。见[基准证据](基准门禁.md)。 BM-04 已通过真实 AgentLoop 验证已实现 PDF 读取器:初始 schema 不可见,当前轮保持等待,所属供应商下一请求可见,兄弟 agent 不可见,并成功读取 fixture 文本。这只验证已支持 PDF 子集,不代表通用 PDF/OCR 覆盖。 @@ -42,4 +42,4 @@ BM-04 已通过真实 AgentLoop 验证已实现 PDF 读取器:初始 schema 重叠的发现行由最近作用域拥有;同一作用域最后挂载的行生效,卸载后恢复前一行。文件行可在发现组件之前或之后挂载。仅让出或屏蔽确切属于 OPS 的 Native 注册,外部注册保持可见。PTC 的 Native 投影保留确切来源,发现后的激活项不能被其它 agent 继承。Native `result` 仅含 page/release,可在只读模式发现;worker 仍会拒绝不允许的 release。常驻 `read_many` 和按需 `edit_many` 保留原有逐文件完整性和 CAS 契约。 -BM-03 仍是完整默认 Native 工具面的门禁,整合 Windows Bash 后也必须计入。上述仅入口测量不代表该门禁通过。 +BM-03 现在对已批准的文件/发现小计执行门禁,同时单列 Bash 及完整默认 Native 总数。仍须核验确切默认工具清单,包括可用 Windows Bash 与宿主 jobs 契约;缺少工具应阻塞测量,不能换取更小分数。范围批准不改变默认值、参数或权限边界。 diff --git "a/docs/\346\211\213\345\212\250\351\252\214\346\224\266.md" "b/docs/\346\211\213\345\212\250\351\252\214\346\224\266.md" index 7aa78918..7bca8120 100644 --- "a/docs/\346\211\213\345\212\250\351\252\214\346\224\266.md" +++ "b/docs/\346\211\213\345\212\250\351\252\214\346\224\266.md" @@ -4,7 +4,7 @@ English: [manual acceptance](manual-validation.md). 以下是操作者检查清 ## 构建与发布证据 -按[贡献指南](贡献指南.md)构建 worker,并在 Windows 提供已校验 Bash fixture,运行完整发布门禁;所需运行时被跳过不能计为通过。单独运行[BM-01–05](基准门禁.md),保留逐项通过/失败/阻塞状态与原始结果。BM-01 和完整默认 BM-03 仍阻止发布;不能用入口 schema、小工作负载、缺少 Bash 或功能 CI 通过代替验收。 +按[贡献指南](贡献指南.md)构建 worker,并在 Windows 提供已校验 Bash fixture,运行完整发布门禁;所需运行时被跳过不能计为通过。单独运行[BM-01–05](基准门禁.md),保留逐项通过/失败/阻塞状态与原始结果。采用明确批准的标准:BM-01 在固定无损工作负载上相比 OPS 0.3.x 至少减少 40% 字节,官方读取另列对照;BM-03 对文件/发现 schema 执行 ≤400 token 上限,并保留 Bash 启动常驻。同时记录范围分数与完整供应商总数,核验含可用 Windows Bash 的确切默认工具清单。不能用更小工作负载、缺少 Bash 或功能 CI 通过代替实测。 ## Profile 与配置 diff --git "a/docs/\347\264\247\345\207\221\346\226\207\344\273\266\350\214\203\345\233\264.md" "b/docs/\347\264\247\345\207\221\346\226\207\344\273\266\350\214\203\345\233\264.md" index 2ccbfddc..8b3cc56f 100644 --- "a/docs/\347\264\247\345\207\221\346\226\207\344\273\266\350\214\203\345\233\264.md" +++ "b/docs/\347\264\247\345\207\221\346\226\207\344\273\266\350\214\203\345\233\264.md" @@ -9,11 +9,11 @@ English: [Compact Native file ranges](native-ranges.md). === FILE: "server.ts" (Lines 1-2 of 80) === 1|import { createServer } from 'http'; 2|const port = 8080; -[Notice: revision=cfd1bc81432fd6b88748b827f46bb4242db17cf8655cf4ea90f3ba0e62065355; more lines; nextOffset=3.] +[revision=cfd1bc81432fd6b88748b827f46bb4242db17cf8655cf4ea90f3ba0e62065355; more lines; nextOffset=3] ``` `N|` 后紧跟原始行内容,包含原有缩进。正文使用可逆 JSON 转义:反斜杠写成双反斜杠,控制字符及 Unicode 换行分隔符被转义,引号保持原样。例如真实制表符显示为 `\t`,而反斜杠加字母 t 的两个源字符显示为 `\\t`。紧凑范围和普通行号正文共用此编码,防止伪造额外行号或文件标题;LF/CRLF 物理行规则与 worker 保持一致。 -`more lines` 表示文件尚未读到末尾,按 `nextOffset` 继续;`end of file; complete=true` 表示本范围已经到达文件末尾。汇总的 `requested ranges returned` 仅证明所请求的范围正文全部返回,不代表读取了所有完整文件。完整版本散列、总行数和每个文件的续读位置继续保留;字节大小及重复计数仍在规范数据和 PTC 返回值中。 +`more lines` 表示文件尚未读到末尾,按 `nextOffset` 继续;`end of file; complete=true` 表示本范围已经到达文件末尾。汇总的 `requested ranges returned` 仅证明所请求的范围正文全部返回,不代表读取了所有完整文件。完整版本散列、总行数和每个文件的续读位置继续保留;字节大小及重复计数仍在规范数据和 PTC 返回值中。逐文件版本回执省去重复的 `Notice: ` 前缀与末尾句点;汇总 `Notice` 以及版本、续读和完整性事实均保留。 发生物理截断、缺失文件、覆盖事实异常、引用存储或其他不确定情况时,继续使用诊断输出并保留明确恢复提示。最终显示预算截断不会保留成功汇总,而会要求用更小上限重新读取原范围。上下文去重同时识别 `N|` 与现有单文件 `N | ` 格式;只有当前供应商上下文中存在完全相同的旧正文证据,才能返回未变化引用。 diff --git "a/docs/\350\267\257\347\272\277\345\233\276.md" "b/docs/\350\267\257\347\272\277\345\233\276.md" index 7237bac2..d613ad27 100644 --- "a/docs/\350\267\257\347\272\277\345\233\276.md" +++ "b/docs/\350\267\257\347\272\277\345\233\276.md" @@ -11,7 +11,7 @@ English: [Roadmap](roadmap.md). ## 0.4.0 候选 -Native/PTC 独立投影、作用域发现、有界 CSV/TSV、DOCX 与受限 PDF 读取、运行时/设置变更和严格 TypeScript 源码已实现。本页不是发布公告:BM-01 与完整默认 BM-03 仍阻止发布。范围和格式限制见[候选说明](0.4.0发布候选.md)。 +Native/PTC 独立投影、作用域发现、有界 CSV/TSV、DOCX 与受限 PDF 读取、运行时/设置变更和严格 TypeScript 源码已实现。本页不是发布公告:最终证据须满足已批准的 OPS 0.3.x BM-01 门槛与文件/发现 BM-03 预算,同时单列官方对照及含 Bash 的完整启动总数。范围和格式限制见[候选说明](0.4.0发布候选.md)。 ## 后续方向 diff --git "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" index 1ada0237..0e9f006c 100644 --- "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" +++ "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" @@ -198,7 +198,7 @@ Cordis 通过 `StandardSchemaV1` 校验配置;独立的公共设置描述在 ## 提示词注册 -提示以作用域运行时段落注册,不写入 AGENTS.md。关闭 shell 撤回其路由;关闭 file 撤回 Native 文件指引。新 Native 文件面仅常驻 ops_read_many,发现组件发布 ops_discover,符合条件的 Windows shell 另有 ops_bash。高级工具通过发现或显式始终加载启用。PTC 拥有独立类型化 SDK,并保持当前权限检查。固定 schema 成本单独计量;完整默认 BM-03 仍未解决。 +提示以作用域运行时段落注册,不写入 AGENTS.md。关闭 shell 撤回其路由;关闭 file 撤回 Native 文件指引。新 Native 文件面仅常驻 ops_read_many,发现组件发布 ops_discover,符合条件的 Windows shell 另有 ops_bash。高级工具通过发现或显式始终加载启用。PTC 拥有独立类型化 SDK,并保持当前权限检查。已批准的 BM-03 预算对文件/发现 schema 执行 ≤400 token 上限。Bash 启动常驻不变,其 schema 与完整启动总数另列;缺少 Bash 不能满足 Windows 就绪检查。 --- From 112e34e143a30281c238a85d4f4d6fe14289344e Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:52:00 +0800 Subject: [PATCH 117/118] test(bench): apply confirmed acceptance scopes --- bench/gates/integrity.mjs | 12 ++++++++++ bench/gates/report.mjs | 4 +++- bench/gates/tasks.mjs | 19 ++++++++------- docs/CHANGELOG.md | 3 +++ docs/benchmark-gates.md | 24 ++++++++++++++----- ...72\345\207\206\351\227\250\347\246\201.md" | 22 +++++++++++++---- test/bench-gates.test.mjs | 10 ++++++++ test/bench-integrity.test.mjs | 22 ++++++++++++++++- test/resident-tools.test.mjs | 10 ++++++-- 9 files changed, 102 insertions(+), 24 deletions(-) diff --git a/bench/gates/integrity.mjs b/bench/gates/integrity.mjs index 3ddfea80..c753016d 100644 --- a/bench/gates/integrity.mjs +++ b/bench/gates/integrity.mjs @@ -1,6 +1,7 @@ /** Assertions about the workload's availability and controls, independent of its byte/token score. */ import { NATIVE_RESIDENT_FILE_TOOLS } from '../../lib/capabilities/registry.js' import { publicToolName } from '../../lib/policy.js' +import { schemaMeasurement } from './metrics.mjs' export function startupReadiness(tools, platform = process.platform) { const expected = [...NATIVE_RESIDENT_FILE_TOOLS, 'discover', ...(platform === 'win32' ? ['bash'] : [])].map(publicToolName).sort() const names = tools.map(tool => tool.name), actual = new Set(names) @@ -16,6 +17,17 @@ export function startupReadiness(tools, platform = process.platform) { if (!shellReady) reasons.push('the full host-compatible Bash contract is unavailable') return { ready: !reasons.length, expected, missing, unexpected, duplicates, shellReady, reason: reasons.join('; ') || undefined } } +/** User-approved gates: OPS 0.3.x bytes; file/discovery schemas with Bash still resident. */ +export function readAcceptance(savings, toolBearingRounds) { + return toolBearingRounds === 1 && savings.baseline >= .4 ? 'passed' : 'failed' +} +export function startupAssessment(tools, platform = process.platform) { + const readiness = startupReadiness(tools, platform) + const gatedTools = tools.filter(tool => tool.name !== publicToolName('bash')) + const measurement = schemaMeasurement(tools), gatedMeasurement = schemaMeasurement(gatedTools) + return { status: !readiness.ready ? 'blocked' : gatedMeasurement.tokens <= 400 ? 'passed' : 'failed', + readiness, names: tools.map(tool => tool.name), measurement, gatedNames: gatedTools.map(tool => tool.name), gatedMeasurement } +} export function readControlPassed(value, file, expectedText) { if (value?.status !== 'completed' || value.failed !== 0 || value.pending !== 0 || value.truncated !== false || !Array.isArray(value.items) || value.items.length !== 1) return false diff --git a/bench/gates/report.mjs b/bench/gates/report.mjs index 7f5724e4..0518414d 100644 --- a/bench/gates/report.mjs +++ b/bench/gates/report.mjs @@ -5,6 +5,7 @@ export function formatReport(report) { `Selected gates passed: ${report.passed}. All five gates accepted: ${report.fullAcceptance}.`, '', '| Gate | Status |', '| --- | --- |', ...report.gates.map(gate => `| ${gate.id} | ${gate.status} |`)] const read = report.gates.find(gate => gate.id === 'BM-01') if (read?.cases) { + if (read.acceptanceBaseline) lines.push('', 'BM-01 acceptance baseline: OPS 0.3.1. Official-reader savings are a separate, non-gating comparison.') lines.push('', '| Predeclared case | Candidate bytes | 0.3.1 bytes | Official bytes | Saving vs 0.3.1 | Saving vs official |', '| --- | ---: | ---: | ---: | ---: | ---: |') for (const item of read.cases) lines.push(`| ${item.scenario.id}${item.scenario.primary ? ' (primary)' : ''} | ${item.rows.candidate.outputBytes} | ${item.rows.baseline.outputBytes} | ${item.rows.official.outputBytes} | ${pct(item.savings.baseline)} | ${pct(item.savings.official)} |`) const primary = read.cases.find(item => item.scenario.primary) @@ -14,7 +15,8 @@ export function formatReport(report) { ...Object.entries(primary.lowerBound.comparisons).map(([name, facts]) => `${name}: 40% target ${facts.targetBytes.toFixed(1)} bytes; source alone exceeds target: ${facts.sourceAloneExceedsTarget}.`)) } for (const gate of report.gates) { - if (gate.measurement) lines.push('', `${gate.id}: full default schema ${gate.measurement.tokens} tokens / ${gate.measurement.bytes} bytes; ${gate.names.join(', ')}.`, `Whole startup message JSON: ${gate.wholeStartupMessages.tokens} tokens / ${gate.wholeStartupMessages.bytes} bytes; this is separate from schema accounting.`) + if (gate.gatedMeasurement) lines.push('', `${gate.id}: gated file/discovery schema ${gate.gatedMeasurement.tokens} tokens / ${gate.gatedMeasurement.bytes} bytes; ${gate.gatedNames.join(', ')}. Bash remains resident; full-default readiness is required.`) + if (gate.measurement) lines.push('', `${gate.id}: full default schema ${gate.measurement.tokens} tokens / ${gate.measurement.bytes} bytes; ${gate.names.join(', ')}.${gate.gatedMeasurement ? ' This total is reported separately from the approved file/discovery budget.' : ''}`, `Whole startup message JSON: ${gate.wholeStartupMessages.tokens} tokens / ${gate.wholeStartupMessages.bytes} bytes; this is separate from schema accounting.`) if (gate.reason || gate.error) lines.push('', `${gate.id}: ${gate.reason ?? gate.error}`) if (gate.gates) lines.push('', `${gate.id}: ${JSON.stringify(gate.gates)}. ${gate.scopeNote ?? ''}`) } diff --git a/bench/gates/tasks.mjs b/bench/gates/tasks.mjs index ae62d24a..c69df1fb 100644 --- a/bench/gates/tasks.mjs +++ b/bench/gates/tasks.mjs @@ -3,9 +3,9 @@ import fs from 'node:fs' import path from 'node:path' import { getEncoding } from 'js-tiktoken' import { publicToolName } from '../../lib/policy.js' -import { schemaMeasurement, verifyReads, verifyEmittedReads, lowerBound } from './metrics.mjs' +import { verifyReads, verifyEmittedReads, lowerBound } from './metrics.mjs' import { boot } from './host.mjs' -import { startupReadiness, readControlPassed, rejectedWithoutEffects } from './integrity.mjs' +import { readAcceptance, startupAssessment, readControlPassed, rejectedWithoutEffects } from './integrity.mjs' import { READ_CASES, readFixture, fiveEdits, hash } from './fixtures.mjs' const calls = run => run.results.map(item => item.result) const raw = (name, args) => ({ name, args }) @@ -35,8 +35,9 @@ export async function bm01(options) { vs031: savings.baseline >= .4, vsOfficial: savings.official >= .4 } }) } const primary = cases.find(item => item.scenario.primary) - return { id: 'BM-01', status: Object.values(primary.passes).every(Boolean) ? 'passed' : 'failed', - criterion: 'One tool-bearing provider round; >=40% emitted text-byte reduction on predeclared 3×50 primary task against each separately reported comparator; neither comparator failure is hidden.', + return { id: 'BM-01', status: readAcceptance(primary.savings, primary.rows.candidate.toolBearingRounds), + criterion: 'One tool-bearing provider round; >=40% emitted text-byte reduction on predeclared 3×50 primary task against OPS 0.3.1. Official-reader comparison is reported separately and does not gate acceptance.', + acceptanceBaseline: 'ops-0.3.1', officialComparisonGating: false, scopeDecision: 'User confirmed OPS 0.3.x as the authoritative baseline; official reader remains a separate comparator.', universalSavingsClaim: false, providerModel: 'scripted; no paid calls', cases } } @@ -70,12 +71,12 @@ export async function bm03(options) { const host = await boot({ ...options, root, withShell: process.platform === 'win32' }) try { const run = await host.run(await host.agent(), [[]]), request = run.requests[0] - const measurement = schemaMeasurement(request.tools), names = request.tools.map(tool => tool.name) - const readiness = startupReadiness(request.tools) + const assessment = startupAssessment(request.tools) const messages = JSON.stringify(request.messages) - return { id: 'BM-03', status: !readiness.ready ? 'blocked' : measurement.tokens <= 400 ? 'passed' : 'failed', - criterion: 'Full default Native added schema <=400 tokens; Windows includes actual default Bash registration.', - pendingBudgetScopeDecision: true, readiness, reason: readiness.reason, shellReady: readiness.shellReady, shellFixture: host.shellFixture ?? null, platform: process.platform, names, measurement, + return { id: 'BM-03', ...assessment, + criterion: 'File/discovery resident schema <=400 tokens. Bash remains loaded at startup; exact full-default readiness is required and the full token total is reported separately.', + acceptanceScope: 'file-discovery', scopeDecision: 'User confirmed the 400-token budget excludes Bash while keeping Bash resident.', + reason: assessment.readiness.reason, shellReady: assessment.readiness.shellReady, shellFixture: host.shellFixture ?? null, platform: process.platform, wholeStartupMessages: { tokens: getEncoding('cl100k_base').encode(messages).length, bytes: Buffer.byteLength(messages), json: messages }, methodology: 'js-tiktoken@1.0.21 cl100k_base over compact host name/description/parameters JSON, actual provider order; not provider billing.' } } finally { await host.close() } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b3671c36..afe105a4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,9 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Apply the confirmed acceptance scopes: BM-01 gates against OPS 0.3.x with official-reader comparisons reported separately; BM-03 gates file/discovery schemas while retaining startup Bash, exact full-inventory readiness and separate full totals. Preserve historical failed evidence. +- 按已确认的验收范围判定:BM-01 以 OPS 0.3.x 为准,官方读取器比较单列;BM-03 仅约束文件/发现 schema,保留启动 Bash、完整目录就绪校验及单列完整总量。历史失败证据保持不变。 + - Compact each fully returned Native range's metadata receipt by removing its repeated notice label; keep exact source text, full revisions, continuation/EOF facts and the aggregate notice. - 删除 Native 完整返回范围的逐文件元数据回执中重复的提示标签;保留精确正文、完整版本标识、续读/文件结束事实及汇总提示。 diff --git a/docs/benchmark-gates.md b/docs/benchmark-gates.md index 0e3a1c10..708269d9 100644 --- a/docs/benchmark-gates.md +++ b/docs/benchmark-gates.md @@ -21,9 +21,9 @@ The manual `Opt-in OPS acceptance benchmarks` workflow runs the same command on | Gate | Evidence and criterion | | --- | --- | -| BM-01 | Three files with 70/80/90 TypeScript lines, each requesting its first 50 lines, as declared in the issue prose. Compare one `read_many` call with separately named OPS 0.3.1 and pinned official `read` results. Canonical and emitted text must retain exactly the requested lines before measuring bytes. Report sequential three-round and parallel one-round baseline controls separately; report total provider requests including the final answer. Both 40% comparisons remain individually visible. | +| BM-01 | Three files with 70/80/90 TypeScript lines, each requesting its first 50 lines, as declared in the issue prose. Compare one `read_many` call with separately named OPS 0.3.1 and pinned official `read` results. Canonical and emitted text must retain exactly the requested lines before measuring bytes. Report sequential three-round and parallel one-round baseline controls separately; report total provider requests including the final answer. The 40% acceptance gate uses OPS 0.3.x, pinned here to 0.3.1. Official-reader savings remain separately visible and do not gate acceptance. | | BM-02 | Five distinct edits in one file, one successful submission, then two externally changed stale-CAS cases. Verify exact resulting bytes, all conflicts rejected, and no full-file echo. | -| BM-03 | Measure actual first provider schemas for the full default Native surface. Windows includes the published Bash contract with the real jobs service; absence blocks the gate. Count compact `name/description/parameters` JSON with exact `js-tiktoken@1.0.21`, `cl100k_base`. Keep whole startup messages separately. The full ≤400 target remains pending; no narrower scope is silently substituted. | +| BM-03 | Measure actual first provider schemas for the full default Native surface. Windows includes the published Bash contract with the real jobs service; absence blocks the gate. Count compact `name/description/parameters` JSON with exact `js-tiktoken@1.0.21`, `cl100k_base`. Keep whole startup messages separately. The approved ≤400 gate counts only resident file/discovery schemas. Bash stays loaded at startup; its schema and the complete default total remain separately reported. | | BM-04 | A real one-page Helvetica/Flate PDF. No PDF schema initially; discovery request one must expose `pdf_read` in provider request two, without sibling visibility; then verify extracted text. Missing integrated PDF capability is blocked. | | BM-05 | Real credential-fenced outside reads and workspace-escaping writes must return rejected, preserve files, and reveal no denied parent path or secret body. An ordinary OS-readable outside read is a separately labelled allowed control: the pinned host does not implement a blanket workspace read jail. | @@ -33,15 +33,21 @@ The checkout must contain the pinned 0.3.1 Git history before running the functi The 0.3.1 baseline is commit `4b51b6333213c376c8b7c173c12f35b408fdec2d`. The preparation script materializes exact Git blobs without checkout newline conversion; provenance rechecks every `lib/` and `native/` blob and records the worker version and SHA-256. Binary reproducibility is not attested. The official comparator uses the installed, pinned host reader separately. The test-only jobs package `@deepseek-ai/dsh-jobs-local@0.2.0-rc.2` was published on 2026-09-29, more than seven days before addition; it is needed to measure the complete Bash schema. +## Confirmed acceptance scopes (2026-10-19) + +The user confirmed OPS 0.3.x as BM-01's authoritative comparison and retained the original DSH reader as a separate comparison. BM-03 keeps Bash loaded at startup and applies the unchanged 400-token threshold to file/discovery schemas only. The full default schema total and startup message JSON remain visible. Exact inventory checks still block a missing Bash on Windows, even when the file/discovery subset fits the budget. No tool contracts, defaults, source text, workload or numerical thresholds are removed or relaxed. + +The actual file/discovery definitions measure 241 tokens / 1,094 bytes. The previously captured complete Windows provider surface measures 660 tokens / 3,219 bytes; the different fixture order is 661 tokens with identical bytes. The complete surface does not fit 400 and is not described as doing so. A fresh integrated run is required for current acceptance; historical artifacts below keep their original verdicts. + ## Honest limits and retained results The earlier worker/renderer-only 3×8 probe recorded 2,256 baseline and 1,949 candidate bytes (13.608% reduction), with `modelRounds: null` and `hostAuthorityExercised: false`. This remains limited historical evidence. -The subsequent real AgentLoop primary case recorded 8,883 OPS 0.3.1 bytes, 5,457 official-reader bytes and 6,239 candidate bytes. Replaying those unchanged canonical receipts through the compact renderer gives 5,348 bytes: 39.795% below 0.3.1, still below 40%. The final integrated host rerun confirms those same byte counts and still fails the primary target. +The subsequent real AgentLoop primary case recorded 8,883 OPS 0.3.1 bytes, 5,457 official-reader bytes and 6,239 candidate bytes. Replaying those unchanged canonical receipts through the compact renderer gives 5,348 bytes: 39.795% below 0.3.1, still below 40%. The reviewed host rerun confirms those same byte counts and still fails the primary target. The primary source lines alone occupy 4,320 UTF-8 bytes. Forty percent below the 5,457-byte official result requires at most 3,274.2 bytes, so that comparison cannot pass without source omission or compression even before path/revision/coverage labels. The report includes this numerical lower bound and an illustrative absolute-path/range label size; shared-base layouts can reduce the latter. Long source lines likewise prevent universal percentage savings. Output paths, host contracts and formatter revisions affect measured bytes, so all exact receipts remain attached. -## Integrated Windows result (2026-10-19) +## Historical Windows result (2026-10-19) Candidate `02577ed140c16e1739bf2588172ee38d9370ec52` had a clean worktree, host `0.2.0-rc.2`, Node `25.2.1`, and the source-built worker SHA-256 `b29b8f130e109ec507d808b8a488d9461b08f28353222b41b40631cd062702b8`. The local worker used Cargo `1.99.0`; the manual CI workflow pins `1.97.1` and has not yet supplied cross-platform results. @@ -55,9 +61,15 @@ Candidate `02577ed140c16e1739bf2588172ee38d9370ec52` had a clean worktree, host The candidate uses one tool-bearing round plus the final response. Sequential comparators use three plus final; parallel comparator controls also use one plus final. The 8-line/1-line/32-line/long-line/Unicode sensitivities save 47.843%/55.768%/40.990%/7.560%/40.358% against 0.3.1; no universal percentage claim follows. A prior fresh-home run without optional Bash reported BM-03 blocked, never passed at 241 tokens. -The retained raw local artifact is `040-benchmark-final-pinned-bash.json` with SHA-256 `cc94fcb4f444a39858795ef9003553de54735eff128191f3037a21666db1de98`; its companion Markdown summarizes all gates. Full acceptance remains false; the baseline and budget-scope decisions remain unresolved. +The retained raw local artifact is `040-benchmark-final-pinned-bash.json` with SHA-256 `cc94fcb4f444a39858795ef9003553de54735eff128191f3037a21666db1de98`; its companion Markdown summarizes all gates. That historical run remains failed under its original criteria; the later scope decisions above do not rewrite its evidence. + + +## Reviewed complete rerun + +Clean candidate `2865936` includes reversible source escaping and the independent integrity controls. Its retained local evidence is `040-benchmark-reviewed-complete.json`, SHA-256 `380ac6fbd392dd0f7805c03c66aa6ba9d88f88bcf4eb5a60ba01868f152488bb`. BM-01 remains 5,348/8,883/5,457 bytes; BM-03 remains 660 tokens/3,219 bytes in actual provider order. BM-02, BM-04 and BM-05 pass; all-five acceptance remains false. +The corrected Unicode sensitivity is 2,616 candidate / 4,185 OPS 0.3.1 / 2,469 official bytes: 37.491% below OPS and 5.954% above official (a −5.954% reduction). The older 40.358% result above belongs to non-injective escaping and is historical only. Whole startup message JSON is separately 788 tokens/3,293 bytes; it is not the tool-schema gate. The final versioned release candidate still needs its own rebuilt-worker rerun. ## Integrity checks after review -BM-03 now requires the exact resident file catalogue plus discovery (and the full Bash contract on Windows) before a token score can pass. Missing, duplicate or unexpected tools block the gate with a named reason. BM-05 also requires its ordinary outside-read control to succeed with exact text, and checks `CREDENTIAL_DENIED` / `POLICY_DENIED` with `effectState: none` for the denied probes. Native line checks independently decode the documented escapes, including literal backslashes; they no longer mirror the renderer. These checks change no workload, default, budget or threshold. The result snapshot above remains historical evidence for its named commit; it is not reclassified as passing. +BM-03 now requires the exact resident file catalogue plus discovery (and the full Bash contract on Windows) before a token score can pass. Missing, duplicate or unexpected tools block the gate with a named reason. BM-05 also requires its ordinary outside-read control to succeed with exact text, and checks `CREDENTIAL_DENIED` / `POLICY_DENIED` with `effectState: none` for the denied probes. Native line checks independently decode the documented escapes, including literal backslashes; they no longer mirror the renderer. These integrity checks change no workload, default or numerical threshold; only the explicitly approved acceptance scopes above determine current verdicts. The result snapshot above remains historical evidence for its named commit; it is not reclassified as passing. diff --git "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" index 82c9f01c..b6926a63 100644 --- "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" +++ "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" @@ -21,9 +21,9 @@ Windows 的基线 worker 路径须加 `.exe`。每次运行使用新的输出文 | 门禁 | 证据与条件 | | --- | --- | -| BM-01 | 三个包含 70/80/90 行的 TypeScript 文件,各取前 50 行,来自议题正文的具体示例。一个 `read_many` 分别对比 OPS 0.3.1 与固定宿主的官方 `read`。先断言规范数据及实际输出都完整保留请求行,再测字节。分别报告基线串行三轮与并行一轮控制组,以及包含最后回答的供应商总请求数。两项 40% 比较各自明确保留。 | +| BM-01 | 三个包含 70/80/90 行的 TypeScript 文件,各取前 50 行,来自议题正文的具体示例。一个 `read_many` 分别对比 OPS 0.3.1 与固定宿主的官方 `read`。先断言规范数据及实际输出都完整保留请求行,再测字节。分别报告基线串行三轮与并行一轮控制组,以及包含最后回答的供应商总请求数。40% 验收门槛以 OPS 0.3.x 为准,此处固定为 0.3.1;官方读取器比较单独展示,不决定验收。 | | BM-02 | 单文件五处独立编辑,一次提交成功,然后制造两种外部修改导致的旧 CAS 冲突。验证准确字节、全部冲突被拒绝,以及不复读完整文件。 | -| BM-03 | 测量第一次真实供应商请求中的完整默认 Native Schema。Windows 包含真实 jobs 服务下发布的 Bash 契约,缺失时阻塞。使用固定 `js-tiktoken@1.0.21`、`cl100k_base` 对紧凑 `name/description/parameters` JSON 计数,启动消息总量另列。完整 ≤400 目标仍待解决,不暗中替换为更窄口径。 | +| BM-03 | 测量第一次真实供应商请求中的完整默认 Native Schema。Windows 包含真实 jobs 服务下发布的 Bash 契约,缺失时阻塞。使用固定 `js-tiktoken@1.0.21`、`cl100k_base` 对紧凑 `name/description/parameters` JSON 计数,启动消息总量另列。已确认的 ≤400 门槛仅计算常驻文件与发现工具;Bash 仍默认启动加载,其 schema 与完整默认总量单独报告。 | | BM-04 | 真实单页 Helvetica/Flate PDF。初始无 PDF Schema;第一请求发现后,第二请求必须显示 `pdf_read`,兄弟作用域仍不可见,并验证提取文本。尚未集成 PDF 时明确阻塞。 | | BM-05 | 工作区外凭据围栏读取与越界写入都须返回 rejected、保持文件不变,并且不披露被拒父路径或秘密正文。普通 OS 可读的工作区外文件作为允许读取的独立控制组:固定宿主不提供全面的工作区读取围栏。 | @@ -33,6 +33,12 @@ BM-01 始终保留偏移 13/31/51、每文件 8 行的案例,以及 1 行、32 0.3.1 基线固定为 `4b51b6333213c376c8b7c173c12f35b408fdec2d`。准备脚本直接还原 Git blob,避免 checkout 换行转换;运行前逐一校验 `lib/` 与 `native/`,并记录 worker 版本及 SHA-256,不宣称二进制可重现构建认证。官方比较组独立使用已安装的固定版本宿主读取器。测试专用 `@deepseek-ai/dsh-jobs-local@0.2.0-rc.2` 发布于 2026-09-29,添加时已超过七天,用于完整计量 Bash Schema。 +## 已确认的验收范围(2026-10-19) + +用户确认 BM-01 以 OPS 0.3.x 为权威比较,原始 DSH 读取器作为独立对照。BM-03 保持 Bash 默认启动加载,仅将不变的 400-token 阈值用于文件与发现工具。完整默认 schema 总量及启动消息 JSON 仍明确展示。Windows 缺少 Bash 时,即使文件/发现子集满足预算,精确目录校验仍会阻塞。没有删减或放宽工具契约、默认值、源文本、工作负载或数值阈值。 + +实际文件/发现定义计为 241 tokens / 1094 字节。此前真实 Windows 供应商完整表面为 660 tokens / 3219 字节;另一夹具顺序为 661 tokens、字节相同。完整表面不满足 400,不能如此宣称。当前验收仍须新的集成运行;下方历史产物保留原始判定。 + ## 保留的结果与限制 早期只运行 worker/渲染器的 3×8 探针得到基线 2,256、候选 1,949 字节(下降 13.608%),`modelRounds: null`、`hostAuthorityExercised: false`,只作为有限的历史证据保留。 @@ -43,7 +49,7 @@ BM-01 始终保留偏移 13/31/51、每文件 8 行的案例,以及 1 行、32 Windows 完整 Bash schema 测量需显式夹具:在独立临时 `DSH_HOME` 运行 `node bin/dsh-ops.mjs bash-runtime install`,将 `DSH_OPS_TEST_BASH` 指向经验证的绝对可执行路径。基准记录该选择,不修改 schema 选项;全新目录未安装 Bash 时,BM-03 明确阻塞,不以文件工具的较小计数冒充通过。手动工作流会显式获取该固定夹具。 -## Windows 集成结果(2026-10-19) +## 历史 Windows 集成结果(2026-10-19) 候选提交 `02577ed140c16e1739bf2588172ee38d9370ec52` 工作区干净;宿主 `0.2.0-rc.2`、Node `25.2.1`。本地 worker 使用 Cargo `1.99.0`,手动 CI 固定 `1.97.1`,尚无本次跨平台结果。 @@ -57,9 +63,15 @@ Windows 完整 Bash schema 测量需显式夹具:在独立临时 `DSH_HOME` 候选为一个工具轮次加最终答复;顺序基线为三个工具轮次加最终答复,并行对照同样为一个加最终答复。8/1/32 行、长行、Unicode 敏感性相对 0.3.1 节约 47.843%/55.768%/40.990%/7.560%/40.358%,不构成普遍百分比保证。此前未安装可选 Bash 的全新目录明确报告 BM-03 阻塞,不将 241 token 视为通过。 -本地保留原始 `040-benchmark-final-pinned-bash.json`,SHA-256 为 `cc94fcb4f444a39858795ef9003553de54735eff128191f3037a21666db1de98`,并有对应 Markdown。完整验收仍为 false;基线与预算范围决策尚未解决。 +本地保留原始 `040-benchmark-final-pinned-bash.json`,SHA-256 为 `cc94fcb4f444a39858795ef9003553de54735eff128191f3037a21666db1de98`,并有对应 Markdown。该历史运行保留当时标准下的失败判定;上方后续范围决策不改写原始证据。 + + +## 审查后的完整重跑 + +干净候选 `2865936` 包含可逆源文本转义和独立完整性断言。本地保留 `040-benchmark-reviewed-complete.json`,SHA-256 为 `380ac6fbd392dd0f7805c03c66aa6ba9d88f88bcf4eb5a60ba01868f152488bb`。BM-01 仍为 5348/8883/5457 字节;BM-03 实际供应商顺序仍为 660 tokens/3219 字节。BM-02、BM-04、BM-05 通过,五项完整验收仍为 false。 +修复后的 Unicode 敏感性为候选 2616、OPS 0.3.1 为 4185、官方 2469 字节:相对 OPS 减少 37.491%,相对官方增加 5.954%(即减少 −5.954%)。上方旧 40.358% 属于不可逆转义,只保留为历史记录。完整启动消息 JSON 另为 788 tokens/3293 字节,不作为工具 schema 门槛。最终版本候选仍需用重新构建的 worker 重跑。 ## 审查后的完整性断言 -BM-03 在评估 token 分数前,要求完整且精确的常驻文件能力目录与发现入口(Windows 还要求完整 Bash 契约)。缺失、重复或意外工具均会阻塞并指出原因。BM-05 同时要求普通外部读取对照成功且正文完全一致,并验证拒绝探测的 `CREDENTIAL_DENIED` / `POLICY_DENIED` 及 `effectState: none`。Native 行检查使用独立解码器验证反斜杠与控制字符,不再复制渲染器逻辑。这些断言不改变工作负载、默认值、预算或阈值;上方快照仍是所列提交的历史证据,不会被改判为通过。 +BM-03 在评估 token 分数前,要求完整且精确的常驻文件能力目录与发现入口(Windows 还要求完整 Bash 契约)。缺失、重复或意外工具均会阻塞并指出原因。BM-05 同时要求普通外部读取对照成功且正文完全一致,并验证拒绝探测的 `CREDENTIAL_DENIED` / `POLICY_DENIED` 及 `effectState: none`。Native 行检查使用独立解码器验证反斜杠与控制字符,不再复制渲染器逻辑。这些完整性断言不改变工作负载、默认值或数值阈值;当前判定采用上方明确确认的验收范围;上方快照仍是所列提交的历史证据,不会被改判为通过。 diff --git a/test/bench-gates.test.mjs b/test/bench-gates.test.mjs index c04103c0..fb0fe944 100644 --- a/test/bench-gates.test.mjs +++ b/test/bench-gates.test.mjs @@ -101,4 +101,14 @@ await test('human-readable reports retain failed and blocked gates and the limit assert.match(text, /BM-03 \| failed/); assert.match(text, /BM-04 \| blocked/) assert.match(text, /All five gates accepted: false/); assert.match(text, /hostAuthorityExercised=false/) }) +await test('reports distinguish the approved subset score from complete startup cost', () => { + const text=formatReport({platform:'win32',arch:'x64',hostVersion:'0.2.0-rc.2',node:'v24',passed:true,fullAcceptance:false,gates:[{ + id:'BM-03',status:'passed',names:['bash','discover','read'],measurement:{tokens:660,bytes:3219}, + gatedNames:['discover','read'],gatedMeasurement:{tokens:241,bytes:1094},wholeStartupMessages:{tokens:786,bytes:3293} + }]}) + assert.ok(text.includes('gated file/discovery schema 241 tokens / 1094 bytes')) + assert.ok(text.includes('full default schema 660 tokens / 3219 bytes')) + assert.match(text,/Bash remains resident; full-default readiness is required/) + assert.match(text,/Whole startup message JSON: 786 tokens/) +}) report('bench-gates') diff --git a/test/bench-integrity.test.mjs b/test/bench-integrity.test.mjs index 5d3ddd3d..f28e5589 100644 --- a/test/bench-integrity.test.mjs +++ b/test/bench-integrity.test.mjs @@ -1,6 +1,6 @@ /** Negative evidence must not pass a token or permission gate. */ import path from 'node:path' -import { startupReadiness, readControlPassed, rejectedWithoutEffects } from '../bench/gates/integrity.mjs' +import { readAcceptance, startupAssessment, startupReadiness, readControlPassed, rejectedWithoutEffects } from '../bench/gates/integrity.mjs' import { verifyEmittedReads } from '../bench/gates/metrics.mjs' import { NATIVE_RESIDENT_FILE_TOOLS } from '../lib/capabilities/registry.js' import { publicToolName } from '../lib/policy.js' @@ -24,6 +24,26 @@ await test('startup needs the exact default Native surface before any token scor assert.equal(startupReadiness(core,'win32').ready,false) assert.equal(startupReadiness([...core,{name:bash.name,parameters:{}}],'win32').ready,false) }) +await test('BM-01 gates the approved OPS baseline without hiding the official comparison', () => { + assert.equal(readAcceptance({baseline:.4,official:-.2},1),'passed') + assert.equal(readAcceptance({baseline:.39999,official:.9},1),'failed') + assert.equal(readAcceptance({baseline:.6,official:.6},2),'failed') + assert.equal(readAcceptance({official:.9},1),'failed') +}) +await test('the file/discovery budget still requires Bash and retains full startup cost', () => { + const bash={name:publicToolName('bash'),description:'shell contract '.repeat(600),parameters:{required:['description'],properties:{run_in_background:{type:'boolean'}}}} + const tools=[bash,...core], result=startupAssessment(tools,'win32') + assert.equal(result.status,'passed') + assert.ok(result.gatedMeasurement.tokens<=400) + assert.ok(result.measurement.tokens>400) + assert.deepEqual(result.names,tools.map(tool=>tool.name)) + assert.deepEqual(result.gatedNames,core.map(tool=>tool.name)) + assert.equal(startupAssessment(core,'win32').status,'blocked') + assert.equal(startupAssessment([...tools,core[0]],'win32').status,'blocked') + assert.equal(startupAssessment([...tools,{name:publicToolName('stat')}],'win32').status,'blocked') + const oversized=tools.map(tool=>tool===core[0]?{...tool,description:'file contract '.repeat(600)}:tool) + assert.equal(startupAssessment(oversized,'win32').status,'failed') +}) await test('authority gates require a successful allowed control and the exact effect-free denial codes', () => { const file=path.resolve('ordinary.txt'),item={status:'completed',path:file,complete:true,truncated:false,failed:0,items:[{path:file,line:1,text:'outside ordinary data'}]} const value={status:'completed',failed:0,pending:0,truncated:false,items:[item]} diff --git a/test/resident-tools.test.mjs b/test/resident-tools.test.mjs index 7a06fd77..421667ef 100644 --- a/test/resident-tools.test.mjs +++ b/test/resident-tools.test.mjs @@ -2,6 +2,7 @@ import fs from 'node:fs' import path from 'node:path' import { getEncoding } from 'js-tiktoken' +import { startupAssessment } from '../bench/gates/integrity.mjs' import * as file from '../lib/components/file/index.js' import { FILE_TOOLS, publicToolName } from '../lib/policy.js' import { NativeFileTools } from '../lib/file/runtime.js' @@ -38,7 +39,7 @@ await test('the resident file row publishes one bounded reader even with the dis assert.equal(absent.isError, true) } finally { await shutdown(ctx) } })) -await test('measure the full default Native surface including Bash and retain the unresolved BM-03 budget', () => withTempDir('resident', async dir => { +await test('gate the approved file/discovery budget and separately retain full startup Bash cost', () => withTempDir('resident', async dir => { const { ctx } = await mount() try { const agent = await mintDiscoveryAgent(ctx, 'resident-budget', dir) @@ -56,7 +57,12 @@ await test('measure the full default Native surface including Bash and retain th console.log(' cl100k_base/js-tiktoken1.0.21 compact host-schema JSON:', JSON.stringify({ core, fullFileCatalogue: full, hostBash: measure([shell]), corePlusBash: withShell })) assert.equal(core.tokens, 241, JSON.stringify(core)) assert.ok(full.tokens > 400 && full.tokens > core.tokens, 'retain honest full-surface measurement') - assert.ok(withShell.tokens > 400, 'BM-03 remains unresolved until the full default, including host-compatible Bash, fits400') + assert.ok(withShell.tokens > 400, 'the complete startup surface remains separately reported') + const assessed = startupAssessment([...assembly.tools, shell], 'win32') + assert.equal(assessed.status, 'passed') + assert.equal(assessed.gatedMeasurement.tokens, core.tokens) + assert.equal(assessed.measurement.tokens, withShell.tokens) + assert.equal(startupAssessment(assembly.tools, 'win32').status, 'blocked', 'missing Bash cannot pass a narrower budget') const text = assembly.sections.map(section => section.text).join('\n') for (const raw of FILE_TOOLS.filter(raw => raw !== 'read_many')) assert.equal(text.includes(publicToolName(raw)), false) assert.ok(text.includes(DISCOVER)) From cf4f30dca0a6ad41c91bf9e9d9b43e099384ab6c Mon Sep 17 00:00:00 2001 From: Shen Haoming <167516473+Haoming9527@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:53:50 +0800 Subject: [PATCH 118/118] docs(bench): record approved-scope acceptance results --- docs/benchmark-gates.md | 6 ++++++ "docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/docs/benchmark-gates.md b/docs/benchmark-gates.md index 708269d9..6d85614c 100644 --- a/docs/benchmark-gates.md +++ b/docs/benchmark-gates.md @@ -73,3 +73,9 @@ The corrected Unicode sensitivity is 2,616 candidate / 4,185 OPS 0.3.1 / 2,469 o ## Integrity checks after review BM-03 now requires the exact resident file catalogue plus discovery (and the full Bash contract on Windows) before a token score can pass. Missing, duplicate or unexpected tools block the gate with a named reason. BM-05 also requires its ordinary outside-read control to succeed with exact text, and checks `CREDENTIAL_DENIED` / `POLICY_DENIED` with `effectState: none` for the denied probes. Native line checks independently decode the documented escapes, including literal backslashes; they no longer mirror the renderer. These integrity checks change no workload, default or numerical threshold; only the explicitly approved acceptance scopes above determine current verdicts. The result snapshot above remains historical evidence for its named commit; it is not reclassified as passing. + +## Approved-scope validation + +The clean implementation at `112e34e` passed all five gates on Windows through the real pinned host. BM-01 returned 5,321 bytes versus 8,883 for OPS 0.3.1: **40.099% reduction**, with all requested source intact. The official reader returned 5,457 bytes (2.492% reduction), reported separately. BM-02 retained both stale-CAS rejections; BM-04 and BM-05 retained activation isolation and authority controls. + +BM-03 passed at **241 file/discovery tokens / 1,094 bytes**, while Bash remained resident. The full startup schema remained **660 tokens / 3,219 bytes**, reported separately. Removing only the redundant per-file notice label and terminal period saved 27 bytes across the fixed three-file task; revisions, continuation/EOF facts and the aggregate notice remain. Historical results above keep their original verdicts. Final release PR validation records the final candidate revision and evidence artifact. diff --git "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" index b6926a63..0b5a1abe 100644 --- "a/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" +++ "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" @@ -75,3 +75,9 @@ Windows 完整 Bash schema 测量需显式夹具:在独立临时 `DSH_HOME` ## 审查后的完整性断言 BM-03 在评估 token 分数前,要求完整且精确的常驻文件能力目录与发现入口(Windows 还要求完整 Bash 契约)。缺失、重复或意外工具均会阻塞并指出原因。BM-05 同时要求普通外部读取对照成功且正文完全一致,并验证拒绝探测的 `CREDENTIAL_DENIED` / `POLICY_DENIED` 及 `effectState: none`。Native 行检查使用独立解码器验证反斜杠与控制字符,不再复制渲染器逻辑。这些完整性断言不改变工作负载、默认值或数值阈值;当前判定采用上方明确确认的验收范围;上方快照仍是所列提交的历史证据,不会被改判为通过。 + +## 已批准范围的验证 + +干净实现 `112e34e` 在 Windows 上通过固定真实宿主运行的全部五项门禁。BM-01 返回5,321字节,OPS0.3.1为8,883字节,**减少40.099%**,请求正文完整保留。官方读取器为5,457字节(减少2.492%),单独报告。BM-02 保留两个过期 CAS 拒绝对照;BM-04、BM-05 保留激活隔离与权限对照。 + +BM-03 以**文件/发现241 tokens / 1,094字节**通过,Bash 仍然启动常驻;完整启动 schema **660 tokens / 3,219字节**另列。仅移除逐文件重复提示标签和末尾句点,在固定三文件任务上节省27字节,版本、续读/EOF事实及汇总提示不变。上文历史结果保留原判定,最终发布 PR 的验证部分记录最终候选版本与证据文件。