diff --git a/.github/workflows/benchmark-gates.yml b/.github/workflows/benchmark-gates.yml new file mode 100644 index 00000000..96eac063 --- /dev/null +++ b/.github/workflows/benchmark-gates.yml @@ -0,0 +1,62 @@ +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: 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 + 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 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 + 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index e36058e8..2ea7102c 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. @@ -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' @@ -79,18 +82,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 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 @@ -136,6 +132,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 @@ -169,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' @@ -186,6 +187,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 @@ -268,6 +271,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' @@ -276,11 +282,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..73b5e92e 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 @@ -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 }} @@ -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,15 +121,16 @@ 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 - # does not need to execute them. + # Windows validates the IPC payload while checking every target artifact. runs-on: windows-latest timeout-minutes: 45 env: 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: @@ -170,6 +175,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,8 +191,8 @@ jobs: run: npm run verify:release - name: Assemble and pack the publish directory - # Materializes the two Windows platform packages from their 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/.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..a979b20b --- /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 (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 | +| `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 --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 +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.ts`; never hand-write `ops_`. +- 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`. +- 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/NOTICE b/NOTICE index 15ed7492..bbf8db23 100644 --- a/NOTICE +++ b/NOTICE @@ -25,19 +25,16 @@ 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. - - * `@dsh-ops/bash--` — a PIN, not a redistribution. +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 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 @@ -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). @@ -94,3 +95,15 @@ 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 + +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/README.en.md b/README.en.md index d6dae176..fa8169a3 100644 --- a/README.en.md +++ b/README.en.md @@ -21,23 +21,38 @@ # 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. You can use the independent **bash 5.3.15** bundled with ops, or point the `ops_bash` tool at Git Bash. - -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. 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. + +- 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 @@ -84,7 +99,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. @@ -104,7 +119,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 ef363858..7888d92c 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 相比 OPS 0.3.x 输出减少至少 40%,官方读取另列对照;BM-03 对文件/发现 schema 执行 400 token 上限,同时保留 Bash 启动常驻并单列完整总数。最终实测证据与功能 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更流畅。可选择使用ops自带的独立 **bash 5.3.15** ,也可以将ops_bash工具指定为git bash +- 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 产品。 ## 安装、更新、卸载 @@ -89,7 +84,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`。 @@ -102,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)。 ## 致谢 diff --git a/README.zh.md b/README.zh.md index 19a140c7..7888d92c 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 相比 OPS 0.3.x 输出减少至少 40%,官方读取另列对照;BM-03 对文件/发现 schema 执行 400 token 上限,同时保留 Bash 启动常驻并单列完整总数。最终实测证据与功能 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更流畅。可选择使用ops自带的独立 **bash 5.3.15** ,也可以将ops_bash工具指定为git bash +- 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 产品。 ## 安装、更新、卸载 @@ -86,7 +84,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`。 @@ -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/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..e6c06059 --- /dev/null +++ b/bench/gates/host.mjs @@ -0,0 +1,89 @@ +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' +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, {}) + 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, shellFixture ? { bashPath: shellFixture } : {}) + 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, shellFixture, close: () => ctx.fiber.dispose() } +} diff --git a/bench/gates/index.mjs b/bench/gates/index.mjs new file mode 100644 index 00000000..62ce9b9e --- /dev/null +++ b/bench/gates/index.mjs @@ -0,0 +1,65 @@ +#!/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(), + 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 } + 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/integrity.mjs b/bench/gates/integrity.mjs new file mode 100644 index 00000000..c753016d --- /dev/null +++ b/bench/gates/integrity.mjs @@ -0,0 +1,41 @@ +/** 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) + 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 } +} +/** 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 + 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 new file mode 100644 index 00000000..a135a575 --- /dev/null +++ b/bench/gates/metrics.mjs @@ -0,0 +1,64 @@ +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 => 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) + 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 }])) } +} +// 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') { + 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:decodeLine(row[2])}) } + } + assert.equal(groups.size, files.length) + 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/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..0518414d --- /dev/null +++ b/bench/gates/report.mjs @@ -0,0 +1,25 @@ +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) { + 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) + 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.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 ?? ''}`) + } + 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..c69df1fb --- /dev/null +++ b/bench/gates/tasks.mjs @@ -0,0 +1,108 @@ +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 { verifyReads, verifyEmittedReads, lowerBound } from './metrics.mjs' +import { boot } from './host.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 }) +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: 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 } +} + +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 assessment = startupAssessment(request.tools) + const messages = JSON.stringify(request.messages) + 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() } +} + +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 = { 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.', + 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/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/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..183c8b47 100644 --- a/bin/check-release.mjs +++ b/bin/check-release.mjs @@ -24,7 +24,7 @@ for (const id of ['dsh-ops-file', 'dsh-ops-shell']) { } 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 e236671c..af94b363 100644 --- a/bin/dsh-ops.mjs +++ b/bin/dsh-ops.mjs @@ -20,7 +20,10 @@ 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' +import { commandSettingsProfile } from '../lib/cli/settings-profile.js' /** * Print one usage block. @@ -36,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, @@ -47,6 +52,12 @@ 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 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: @@ -85,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 }, @@ -98,7 +113,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 +122,12 @@ 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': + 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/cordis.patch.yml b/cordis.patch.yml index b67947a2..0581791a 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 strict official-tool hiding 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 @@ -24,11 +22,21 @@ name: dsh-ops/ptc config: required: false + 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 @@ -42,5 +50,4 @@ name: dsh-ops/hide-official disabled: true config: - strict: true - additionalOfficialTools: [] + replaceTools: [] 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..c832ad2b --- /dev/null +++ "b/docs/0.4.0\345\217\221\345\270\203\345\200\231\351\200\211.md" @@ -0,0 +1,82 @@ +# 0.4.0 发布候选 + +English: [0.4.0 release candidate](release-0.4.0.md). + +**尚未发布。** 版本字段仅标识候选,不代表允许打 tag、发布 npm 包或合并最终发布 PR。功能 CI 与五项验收基准分别计数。已批准 BM-01 相比 OPS 0.3.x 输出至少减少 40%,官方读取另列对照;BM-03 对文件/发现 schema 执行 ≤400 token 上限,Bash 保持启动常驻,其成本与完整启动总数另列。最终实测证据仍是必需条件。 + +## 已实现的能力 + +包中有七个稳定组件行: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;高级工具初始不可见。 | 精确供应商目录;已批准 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 与基准验收分别计数;最终评审前按明确批准的标准重跑全部五项门禁。 | + +## 堆叠评审与 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。操作者已明确批准 OPS 0.3.x 为 BM-01 权威基线、官方读取另列对照,以及文件/发现 ≤400 token 的 BM-03 范围并保留 Bash 启动常驻。固定无损工作负载、完整默认工具清单、schema 契约和权限检查保持不变。下文保留之前完整范围失败,不将它悄然重分类。版本提升、PR ready 或单独功能 CI 通过均不授权打 tag/发布。 + +## 验收证据 + +最终候选须按上文已批准标准保留全部五项结果。以下 Windows 快照早于范围批准与逐文件回执精简,是历史证据,不是更新候选的最终结论。它采用固定 issue 工作负载与固定宿主,不代表通用性能或供应商账单结论。[基准门禁](基准门禁.md)记录复现命令、来源与当前重跑状态。 + +| 门禁 | 批准调整前的历史结果 | +| --- | --- | +| BM-01 | 失败:候选5348字节,OPS0.3.1为8883字节,减少39.795%;官方读取5457字节,仅减少1.997%。请求源文本本身为4320字节。 | +| BM-02 | 通过:一个带工具轮次完成五处编辑,两个过期 CAS 对照均拒绝。 | +| BM-03 | 之前完整范围失败:含 Windows Bash 与真实 jobs 的供应商实际顺序为660 tokens/3219字节,超过当时完整范围≤400目标。已批准的文件/发现小计为241 tokens/1094字节;目录顺序661描述的是另一顺序的完整工具面。 | +| BM-04 | 通过:PDF 初始不可见,发现后所属供应商下一请求可见,兄弟 agent 不可见,并验证执行结果。 | +| BM-05 | 通过:凭据保护读取与工作区外写入被拒绝且不泄露被拒路径/内容;普通允许的外部读取是明确标注的允许对照。 | + +已批准的范围调整改变与400比较的 BM-03 小计,不从启动面或确切就绪清单删除 Bash;应同时记录文件/发现分数、Bash 成本与完整供应商总数。缺少 Bash 测试运行时仍阻塞 Windows 测量。紧凑回执仅移除逐文件重复的 `Notice: ` 前缀与末尾句点,保留完整版本、续读偏移、EOF/完整性及汇总提示;请求正文、工作负载、schema 与权限契约不变。调整后的新结果须明确记录;历史失败本身不能判定更新候选通过或失败。 + +按[手动验收清单](手动验收.md)复核。本候选不合并 PR、不发布二进制,也不启用被关闭的发布工作流。 + +## 已批准范围的结果 + +集成实现按已批准标准通过 Windows 全部五项基准:BM-01 对 OPS0.3.1 为 **5,321 / 8,883字节,减少40.099%**(官方读取器5,457字节、减少2.492%,单独报告);BM-03 **文件/发现241 tokens**,保留启动 Bash,**完整 schema 660 tokens**另列。BM-02、BM-04、BM-05 也通过。发布 PR 记录最终候选的精确来源与功能 CI,上方历史表保持不变。 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7d16e646..f4394c6e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,7 +7,385 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] -### File-tool discoverability, honest grep pages and compact inspect rendering +- 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。 +- 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 完整返回范围的逐文件元数据回执中重复的提示标签;保留精确正文、完整版本标识、续读/文件结束事实及汇总提示。 + +- 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 覆盖。 + +- 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 允许池遵循实现目录,保留已保存的显式选择。 + +- 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;保留产物路径、默认值、校验错误及生命周期行为。 + +### 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. +- 将配置档委托命令与会话临时目录辅助函数迁移为严格 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. +- 将凭据围栏、可信路径锚点和权限错误迁移为严格 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. +- 紧凑与通用 Native 正文共用可逆行编码,区分字面反斜杠序列、制表符、控制字符和 Unicode 分隔符;规范数据和普通 ASCII 行输出不变。 + +### Add opt-in real-host acceptance evidence (#261, 16/16) + +- 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) + +- 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 与工作区准备边界;代理收据格式错误时,在暂存运行时前明确拒绝。 + +- 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. +- 新增可选离线设置承载行迁移:精确验证宿主配置合成结果,备份原文件,原子写入并支持恢复。常规升级仍保留旧文件行名称与配置。 + +### 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 内置校验配置。 + +- 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. +- 通过宿主网关提供运行时清单、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. +- 为 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,避免诊断管道写满后验证死锁;保留两路完整输出及失败退出码。 + +- 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. +- 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 契约不同而不提供替换。内置行表单已提供严格校验;可选浏览器多选通过上文的显式客户端承载行启用。 + +### 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. 宿主接受任务启动回调后若准入失败,取消排队启动且不转为前台重试。 + +### 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. The remaining adapters are also strict TypeScript in the completed source migration. +- 将 shell 执行器、宿主契约、审批、jobs 和会话集成迁为严格 TypeScript,以仅类型宿主导入校验公开定义;保持发布路径,剩余适配器也已完成严格迁移。 + +### 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. +- 新增 Bash 状态/安装/删除及 Git/受管理副本选择;删除默认预览,仅移除有收据的非链接目录。移除自动 npm 载荷与发布组装,构建和门禁显式获取固定 fixture 或验证离线提供的副本。 + +### 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. +- 必填命令描述,复用宿主前台/后台/提升结果、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) + +- 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 + 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 + 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 + 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, + 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, + 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. +- 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 或阅读布局。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. 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) + +- 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) + +- 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. +- 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 按代理在下一步骤启用;解析缺口保留前缀且不伪造续取。 + +### 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) + +- 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 单文件和多文件重读仅在当前模型请求包含此前准确正文且每个文件重新验证后省略重复行;保留版本、覆盖范围、续页提示和规范数据,压缩、脱敏、权限变化或证据不足时恢复全文。 + +### 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 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) + +- 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 + 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 返回可收窄的规范结果信封;安全续取只描述只读操作,绝不重放修改。构建并发布同源类型声明, + 保留参数、覆盖范围、副作用和重试约束。 + +### 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 + 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, + 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 + 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. +- 新增多文件范围读取与单文件原子多段编辑;逐文件报告版本、错误及未读范围,保持权限边界与短回执。 + +### 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 + 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. +- 新增可信信封内的会话引用,单结果 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. 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 生命周期测试;当前发现入口为 94 cl100k_base tokens,不代表完整默认面;真实 PDF 下一请求断言由 BM-04 验证。 + +### 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, + 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. +- 统一能力名称、归属、呈现、并发及 schema 目录;策略和文件/PTC 契约迁为 TypeScript,加入 Outcome、搜索覆盖、结果引用及错误/重试类型,固定 Node 22 声明并验证正反类型用例。 + +### 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. +- 搜索区分扫描与分页,统计跳过原因,遇读取/编码/大小/预算缺口不提供全量总数;重扫续取校验查询、权限与文件状态,不完整遍历要求缩小新查询,保留物理 LF 行号和有界前缀。 + +### 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. +- 单次 worker 请求采用严格 v1、调用 ID、组件来源及执行/输出预算;副作用前拒绝错误信封,修改执行后中断/超限不假装无副作用,保留有界 v0 解码供基准兼容。 + +### 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. +- 拆分协议、文件、搜索、批处理和引用存储 crate,仍按调用启动并保留权限、元数据、nofollow 与审计;完整 Cargo workspace、发布源包及基准陈旧检查均包含路径依赖和平台元数据适配器。 + +### 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. +- 源码移至 src、构建到既有 lib 路径并复制资源,验证和打包前先构建;发布包保留 lib 不含 src。初期 JavaScript 迁移支持现已在第 16 步移除,全部插件源为严格 TypeScript/TSX。 + +### Earlier file-tool compatibility repairs retained in 0.4.0 + +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; @@ -36,6 +414,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. @@ -423,6 +803,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. @@ -692,7 +1075,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. @@ -709,7 +1092,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/CONTRIBUTING.md b/docs/CONTRIBUTING.md index d1ef7d75..7b2ee752 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,16 +50,47 @@ 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/` 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 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: + +```console +npm ci --ignore-scripts +npm run build +npm run build:ptc +npm pack +``` + +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 +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**: ```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 ``` @@ -77,7 +109,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 @@ -105,6 +138,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 @@ -112,11 +153,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.ts`; 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.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. @@ -150,3 +191,28 @@ 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. + +`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. + +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. + +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. + +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. + +`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/PROVENANCE.md b/docs/PROVENANCE.md index da090c6a..1883a9f9 100644 --- a/docs/PROVENANCE.md +++ b/docs/PROVENANCE.md @@ -3,6 +3,36 @@ 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 +`@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/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..bb18ae37 --- /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。当前工具轮次结束、所属会话提交 step/end 后,请求的 API 会在紧接着的供应商请求中发布;当前冻结的 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、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 配置行由最近作用域的行负责,同一作用域内后挂载行优先,卸载后恢复此前的行;不修改共享注册表。 + +先检查 Outcome.status 再使用 data。部分结果保留覆盖信息及 continuation;失败保留错误代码、影响状态和重试策略。加载器不执行文件操作,也不授予文件系统权限。本页不声称 token 基准或 IO 提速。 + +English: [Progressive PTC SDK loading](./ptc-sdk-loading.md). 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/benchmark-gates.md b/docs/benchmark-gates.md new file mode 100644 index 00000000..6d85614c --- /dev/null +++ b/docs/benchmark-gates.md @@ -0,0 +1,81 @@ +# 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. 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. + +## 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. 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 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. | + +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, 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. + +## 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 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. + +## 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. + +| 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. 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 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/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/component-reference.md b/docs/component-reference.md index 678f2f0a..decbd7da 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -1,17 +1,18 @@ # 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 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/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/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` | 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) | | `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. @@ -19,24 +20,33 @@ 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¹ | | `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¹ | | `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 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). +¹ **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). --- ## `dsh-ops/file` — file tools -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`. +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. 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. @@ -44,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 | | --- | --- | --- | @@ -102,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. --- @@ -131,12 +147,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 | @@ -148,19 +165,26 @@ Bounded session-owned result references: put object rows, page with top-level `e ## `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. + +`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. -"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. +`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**. 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 | | --- | --- | @@ -170,112 +194,37 @@ 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) | ``` --- -## `dsh-ops/hide-official` — takeover scope - -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**. +## `dsh-ops/hide-official` — per-tool replacements -`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 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 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). -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..a0f5d393 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -2,20 +2,80 @@ 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. + +## 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. + +### 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 -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` | 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. @@ -23,27 +83,31 @@ 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 | -| `capture` | boolean | `false` | panel | +| `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. + 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` — @@ -67,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: @@ -81,11 +145,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 ``` @@ -97,15 +161,18 @@ 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. -- `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: +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. + +- 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). @@ -115,58 +182,36 @@ 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 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. -```yaml -- name: dsh-ops/hide-official - config: - strict: true - scope: extended - additionalOfficialTools: [subagent] -``` +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. -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 -``` +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. -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). --- ## 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. 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. --- @@ -175,3 +220,19 @@ 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 + +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. + +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/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/discovery.md b/docs/discovery.md new file mode 100644 index 00000000..92f55f22 --- /dev/null +++ b/docs/discovery.md @@ -0,0 +1,45 @@ +# 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 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 seven-row bundle enables this component by default and preserves existing named row overrides. Its configuration is: + +```yaml +- name: dsh-ops/discovery + config: + enabled: true + maxResults: 3 + 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: [] +``` + +| 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. 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. + +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 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. 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. + +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. + +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/document-dependencies.md b/docs/document-dependencies.md new file mode 100644 index 00000000..65cc2bf5 --- /dev/null +++ b/docs/document-dependencies.md @@ -0,0 +1,69 @@ +# 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. + +## 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/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/manual-validation.md b/docs/manual-validation.md index c501b6c9..cdb81e14 100644 --- a/docs/manual-validation.md +++ b/docs/manual-validation.md @@ -1,77 +1,68 @@ -# 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. +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 + +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/native-kernel.md b/docs/native-kernel.md new file mode 100644 index 00000000..bc22b01b --- /dev/null +++ b/docs/native-kernel.md @@ -0,0 +1,336 @@ +# 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-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. +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. + +## 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. Ordinary success fields +retain their semantics; optional reference fields are described below. + +`budget.timeoutMs` accepts 1..2147483647 milliseconds for envelope compatibility. The host +uses a 30000ms default and caps supplied timeouts at 30000ms, with 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 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. Automatic output spill is described below. + +## 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 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, +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. 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. + +## CSV/TSV worker observations (15a/16) + +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. 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 +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 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 +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. + +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. + +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. + +## Bounded DOCX main-body observations (15b/16) + +`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, +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). + +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. + +## 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 +implemented together in the PDF extraction slice documented below. + +## 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). 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 +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. + +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. + +## 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. + +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. + +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. + +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. + +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/native-ptc.md b/docs/native-ptc.md index 84000730..83eae91e 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 @@ -51,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 @@ -63,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. -Standard/native and hybrid/both scopes expose neither tool nor extra prompt. +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. @@ -128,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): @@ -159,21 +162,27 @@ 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 }); } ``` 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. 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 +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 @@ -182,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; @@ -203,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})`. @@ -235,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, @@ -267,7 +283,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. diff --git a/docs/native-ranges.md b/docs/native-ranges.md new file mode 100644 index 00000000..2b3f39f6 --- /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; +[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. 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/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/docs/ptc-sdk-loading.md b/docs/ptc-sdk-loading.md new file mode 100644 index 00000000..787a0e3e --- /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 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: + +| 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, 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. + +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/docs/release-0.4.0.md b/docs/release-0.4.0.md new file mode 100644 index 00000000..7b1a4536 --- /dev/null +++ b/docs/release-0.4.0.md @@ -0,0 +1,137 @@ +# 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. 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 + +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; 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 remain separate; rerun all five gates under the explicitly approved criteria before final review. | + +## 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 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 evidence + +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 | 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 | 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 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. + +## Approved-scope result + +The integrated implementation passed all five Windows benchmarks under the approved criteria: BM-01 **5,321 / 8,883 bytes, 40.099% reduction** against OPS 0.3.1 (official reader: 5,457 bytes, 2.492% separately); BM-03 **241 file/discovery tokens**, with startup Bash retained and **660 full schema tokens** reported separately. BM-02, BM-04 and BM-05 also passed. The release PR records exact final-candidate provenance and functional CI; the historical table above remains unchanged. diff --git a/docs/release-process.md b/docs/release-process.md index 4a60dd36..f71c79b2 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -1,8 +1,11 @@ # 发布流程与如何启用 -对象是 `.github/workflows/release.yml`(workflow 名 `Release`)。发布目标只有一个:**GitHub Release**(tag + 自动 notes + 三个可安装 tarball + `SHA256SUMS`)。npm publish 不在这条流水线里,理由见下。 +> 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. -## 当前状态:`disabled_manually`,推 tag 不会发布 +对象是 `.github/workflows/release.yml`(workflow 名 `Release`)。发布目标只有一个:**GitHub Release**(tag + 自动 notes + 一个可安装主包 tarball + `SHA256SUMS`)。npm publish 不在这条流水线里,理由见下。 + +## 历史状态记录:`disabled_manually` ``` $ gh api repos/T-Auto/dsh-ops/actions/workflows --jq '.workflows[]|"\(.name) | \(.state)"' @@ -40,9 +43,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 +54,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 +74,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/result-budgets.md b/docs/result-budgets.md new file mode 100644 index 00000000..c55c9716 --- /dev/null +++ b/docs/result-budgets.md @@ -0,0 +1,58 @@ +# 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 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. + +## 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/roadmap.md b/docs/roadmap.md index d8afe23a..cce809aa 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: 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 ### 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/search-coverage.md b/docs/search-coverage.md new file mode 100644 index 00000000..94a11f01 --- /dev/null +++ b/docs/search-coverage.md @@ -0,0 +1,61 @@ +# 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. +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. + +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/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 d8704fda..b248d3c2 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; 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, +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 ``` @@ -46,7 +58,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. 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 + +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 @@ -91,6 +118,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 @@ -102,6 +143,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 ``` @@ -127,7 +169,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. --- @@ -154,3 +200,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`; 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 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/verify-locally.md b/docs/verify-locally.md index a974a8a4..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) | -| worker unit tests | `cargo test --locked --manifest-path native/Cargo.toml` | The Rust worker in isolation | +| 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/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\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/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..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" @@ -2,13 +2,18 @@ 安装、更新、卸载的完整流程,以及命令行接口的全部选项。 +## 0.4.0 候选与组件默认值 + +本检出是未发布候选;验收采用已批准的 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)复核。 + + --- ## 前置条件 | 项 | 要求 | | --- | --- | -| 操作系统 | 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 ``` @@ -43,7 +49,22 @@ PowerShell 7 在 macOS 上不可用。能力矩阵与已知限制见 [macOS arm6 ### 官方插件市场 -在目标 DSH 应用的插件市场中输入 npm 包名 **`dsh-ops`**,安装后选择启用。安装归属于当前运行的 profile,桌面端为 `desktop`。发布包依赖完整 bash 和 PowerShell 7 载荷;不使用安装脚本下载,不改系统 PATH。 +在目标 DSH 应用的插件市场中输入 npm 包名 **`dsh-ops`**,安装后选择启用。安装归属于当前运行的 profile,桌面端为 `desktop`。Bash 与 PowerShell 二进制都不再作为 npm 依赖安装。Bash 通过独立运行时管理器获取;PowerShell 继续由宿主管理。安装不运行 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 一键安装 @@ -88,6 +109,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 @@ -99,6 +134,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 ``` @@ -125,7 +161,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 兼容扩展,不改变宿主命令契约或超时上限。 --- @@ -152,3 +192,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`(完整多文件范围使用紧凑 `N|text`,同父目录可共用 JSON 引号路径,见[紧凑范围](紧凑文件范围.md)) +显示内容,并保留路径、已观察到的 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/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..3bbb00f7 --- /dev/null +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -0,0 +1,266 @@ +# 原生内核工作区 + +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-documents` | 有界 CSV/TSV 行、DOCX 正文段落及 PDF 文本观察 | 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 目录。 + +## 单次请求协议 + +新的宿主桥接发送 `{ version: 1, requestId, component, budget, request }`。`request` 保留严格的 +能力参数和宿主权限字段;`component` 为 `file` 或 `ptc`,不会授予权限。桥接生成新的 UUID, +填入真实会话、根路径和策略,并在启动进程前再次核实权限。普通成功字段保留原有语义;可选引用字段见下文。 + +`budget.timeoutMs` 为兼容信封仍接受 1..2147483647 毫秒;宿主默认值和硬上限均为 30000 毫秒,并保留宿主取消机制。 +进程内的看门狗也会约束直接调用;超时退出码为 124,桥接将副作用报告为 unknown。 +`budget.maxBytes` 范围为 1024..131072,默认 131072,计算序列化 JSON 与末尾换行的总字节数。 +下限保证结构化错误有足够空间。没有可信存储的读取输出超限返回 `OUTPUT_LIMIT` 且无副作用;修改回执丢失则 +报告 unknown,除非已有错误携带更精确的副作用状态。不会自动重试。 + +完整请求仍以 2 MiB 为上限。为兼容 0.3.x 基准对比,显式保留旧版无版本请求,采用相同权限检查和 +有界默认传输预算。只要出现任一信封字段,就严格按 v1 解析;缺字段、未知字段、非法 ID、 +不支持的组件或版本、无法使用的预算,均在文件系统副作用前被拒绝。请求 ID 不改变结果哈希。 +自动输出溢出见下文。 + +## 会话快照引用(6a/16) + +worker 接受可选的可信信封元数据 `storage: { root, authorityId, allowedTools, windowsWriteSid? }`。 +工具参数和内层 request 均不能注入这些字段。宿主提供已存在的绝对缓存根目录、64 位小写 SHA-256 +权限身份,以及当前规范化生产工具白名单。权限身份必须绑定本次会话随机标识、当前策略和凭据 +边界;知道引用不等于获得权限。每次访问仍核实会话、选定的规范化根路径及目录身份、生产工具和 +当前凭据边界。可信缓存根目录是独立的缓存专用授权,不随操作根目录选择而改变,也不受业务 write_roots 限制; +绝不将缓存位置加入业务修改白名单。 + +带此上下文的 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。宿主必须 +先在逻辑上使引用失效,再等待活动调用结束;清理被拒绝或中断时延后清理,绝不能重跑业务操作。 +宿主生命周期钩子由宿主集成切片接入。 + +## 过大输出的自动有界缓存(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 分开。只读观察不能创建引用。缓存拒绝时保留 +不完整的有界开头,并提示范围/十六进制恢复;已完成的修改不能重跑。发布前先验证引用元数据 +能否放进头部预算,避免因预算过小而留下无法取得的快照。没有可信存储时保留旧传输契约, +由宿主在存储不可用时补充恢复指导。 + +## CSV/TSV worker 观察(15a/16) + +新增 ops-documents crate,在同一个单次 worker 内处理 table_read。该能力不进入默认工具 schema: +Native 经 `ops_discover`、PTC 经 `ops_sdk` 选择 table_read 后,于代理下一步骤生效;兄弟及 +子代理必须独立选择。表格切片没有新增外部包:serde/serde_json 与 sha2 复用既有锁定依赖。 +下文已实现的 DOCX 与 PDF 切片,其依赖审查记录见[文档解析依赖.md](文档解析依赖.md)。 + +参数为 `{ 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,且不编造总数。元数据变化使 scanComplete 与 pageComplete 均失效。 +skipped 只统计请求范围之前实际观察到的行数;请求偏移超过 EOF 时也不虚增。 + +硬上限: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 也按只读操作分类。 + +Native 用带物理 LF 行范围的原位置 JSON 单元格展示,并明确提示扫描缺口不完整。PTC 的生成 +声明保留 `string[]` 单元格。只有稳定完整扫描且仍有后续行时提供只读前进续取;跨调用组合页面 +须比较 revision。非法输入、文件变化和物理扫描上限保留前缀数据与恢复提示,不提供会重复 +原缺口的续取。Native/PTC 引用页均区分原表格覆盖率与已存记录分页;既有排队、实时权限、 +凭据检查、取消与存储绑定继续适用。 + +新安装的 PTC bundle 行从已实现目录生成允许池;已保存的显式 allowedTools 数组在刷新和重启后 +仍然有效。文档 API 仍须由所属作用域请求后才加载。 + +## 有界 DOCX 正文观察(15b/16) + +`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、实体字符及原文本空白。明确排除格式与 +渲染布局、页眉页脚、脚注尾注、批注和包元数据;`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)。 + +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 提取切片中一并实现。 + +## PDF 文本 worker(15d/16) + +单次 worker 接受严格的 pdf_read { path, offset?, limit? }。宿主从当前目录 +解析路径。offset 为 1 起始页码(1–32,默认 1),limit 为 1–32 页(默认 10)。 +该能力默认不出现在 schema;经发现或 SDK 选择后,在所属代理下一步骤启用。 + +每项为 { 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 及无业务副作用沿用统一权限边界。 + +PDF 能力不进入默认 schema:Native 经 `ops_discover`、PTC 经 `ops_sdk` 选择,在所属代理下一 +步骤启用。Native 显示页码、转义文本与明确的文本层范围/排除内容;PTC 生成类型保留 page、text +和 string[] runs。不支持或非法页面在失败 Outcome 内保留此前规范数据,不生成循环页续取; +仅稳定完整扫描可前进分页,组合前须比较 revision。引用保留原页面覆盖率、解码/处理工作量、 +范围与捕获事实;每次读取复用文件工具的实时权限、凭据、nofollow、共享预算和取消路径。 + +## 类型化运行时归属(16/16) + +worker 选择与 Native 运行时生命周期改为严格 TypeScript。解析结果区分成功路径与失败原因, +配置及 Native 工具名在编译时检查。未初始化运行时在创建调用状态前返回不可用值。发布路径仍为 +.js,来源校验、作用域与取消行为保持一致。 + +Native worker 桥也使用严格 TypeScript,采用宿主权限、文件系统及执行类型,输入/输出先按未知值 +检查。非对象参数在启动前拒绝;进程返回非法 JSON 或非对象值时报告未知副作用,不记录成功读取。 +写入回执观察保留宿主版本事实,固定 Native 子进程路径、权限校验及独立 PTC 限制路径保持一致。 + +PTC 桥复用类型化输入/输出检查,并采用宿主限制执行与 runner 失败契约。非对象参数在批任务启动前 +拒绝;启动后的非法 JSON 回执保留未知副作用与生成的审计标识。只有已识别的 runner 失败证据才可 +声明 worker 未运行;每次权限复查与限制 argv 保持必需,不增加无限制回退或缓存/业务权限。 + +源码构建辅助模块也采用严格 TypeScript。Cargo 解析返回可执行文件、来源、版本与环境;构建必须 +提供已解析工具链及 crate 目录,流式输出文本诊断并返回数字退出码和收集输出。CLI 仍显式执行锁定 +依赖的 release 构建并暂存原有来源回执;此类型迁移不改变依赖、安装、发布路径或自动构建行为。 + +可选 TUI 的活动、提醒、渲染辅助模块及 HTML 模板读取器采用严格 TypeScript。只读快照仅保留 +有界计数和诊断元数据,运行中记录没有完成事实;注入 React/UI 的边界保留宿主节点类型,不加载 +TUI 或 React 依赖。提醒、会话隔离、分页及 HTML 资源行为保持一致;非法结果形状保持未确认, +不保留原始正文。 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..0b5a1abe --- /dev/null +++ "b/docs/\345\237\272\345\207\206\351\227\250\347\246\201.md" @@ -0,0 +1,83 @@ +# 基准门禁 + +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% 验收门槛以 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 门槛仅计算常驻文件与发现工具;Bash 仍默认启动加载,其 schema 与完整默认总量单独报告。 | +| 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 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。 + +## 已确认的验收范围(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`,只作为有限的历史证据保留。 + +随后真实 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 明确阻塞,不以文件工具的较小计数冒充通过。手动工作流会显式获取该固定夹具。 + +## 历史 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。该历史运行保留当时标准下的失败判定;上方后续范围决策不改写原始证据。 + + +## 审查后的完整重跑 + +干净候选 `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 行检查使用独立解码器验证反斜杠与控制字符,不再复制渲染器逻辑。这些完整性断言不改变工作负载、默认值或数值阈值;当前判定采用上方明确确认的验收范围;上方快照仍是所列提交的历史证据,不会被改判为通过。 + +## 已批准范围的验证 + +干净实现 `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 的验证部分记录最终候选版本与证据文件。 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..30209f3a --- /dev/null +++ "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" @@ -0,0 +1,45 @@ +# 按需高级工具 + +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 默认启用该组件,并保留已有带名称行覆盖。其配置如下: + +```yaml +- name: dsh-ops/discovery + config: + enabled: true + maxResults: 3 + 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: [] +``` + +| 设置 | 默认 | 契约 | +| --- | --- | --- | +| `enabled` | `true` | 发布发现入口。设为 `false` 仍保留 `alwaysLoaded` 工具;禁用整个 Loader 行会卸载该行拥有的全部工具。 | +| `maxResults` | `3` | 每次选择上限,整数 1-5。调用参数 `max_results` 也校验为 1-5,并受本设置限制。 | +| `allowedTools` | 上述全部已实现高级能力 | 使用原始能力名称的允许池。不支持的格式会被拒绝,不会假装可用。 | +| `alwaysLoaded` | `[]` | 允许池中跳过发现流程的选择,从首个符合条件的步骤开始可用。 | +| `binaryPath` / `required` | 自动 / `false` | 与原生文件工具相同的 worker 选择及激活失败行为。 | +| `denyCredentialReads` | `true` | 保留原生文件凭据保护。 | + +配置数组会复制并去重;未知配置键在错误中点名。搜索按经评审元数据确定性匹配关键词,同分时使用固定顺序,不调用模型。`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 适配器管理。 + +只使用本插件自己的作用域 `ctx.tools.register`、`restrict`、提示词段落和生命周期监听;不替换宿主方法或共享工具注册表。发布前比较目标工具名称,避免与 PTC 生命周期监听器互相触发重复注册。 + +## 测量边界 + +确定性的入口测量固定 `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 token 门禁仅作用于文件/发现 schema;Bash 保持启动常驻,其成本及完整总数继续单列。高级读取、搜索、修改、文档与结果分页按需发现;路由只列出已发布工具。数值边界、默认值、路径基准与权限契约保持不变;始终加载项会扩大初始面。见[基准证据](基准门禁.md)。 + +BM-04 已通过真实 AgentLoop 验证已实现 PDF 读取器:初始 schema 不可见,当前轮保持等待,所属供应商下一请求可见,兄弟 agent 不可见,并成功读取 fixture 文本。这只验证已支持 PDF 子集,不代表通用 PDF/OCR 覆盖。 + +内置设置可持久化严格校验的数组;可选浏览器多选使用固定宿主客户端 API,需要[配置参考](配置参考.md)中的显式离线承载行迁移,默认旧文件行不启用这些页面。 + +重叠的发现行由最近作用域拥有;同一作用域最后挂载的行生效,卸载后恢复前一行。文件行可在发现组件之前或之后挂载。仅让出或屏蔽确切属于 OPS 的 Native 注册,外部注册保持可见。PTC 的 Native 投影保留确切来源,发现后的激活项不能被其它 agent 继承。Native `result` 仅含 page/release,可在只读模式发现;worker 仍会拒绝不允许的 release。常驻 `read_many` 和按需 `edit_many` 保留原有逐文件完整性和 CAS 契约。 + +BM-03 现在对已批准的文件/发现小计执行门禁,同时单列 Bash 及完整默认 Native 总数。仍须核验确切默认工具清单,包括可用 Windows Bash 与宿主 jobs 契约;缺少工具应阻塞测量,不能换取更小分数。范围批准不改变默认值、参数或权限边界。 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/\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..7bca8120 --- /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 在固定无损工作负载上相比 OPS 0.3.x 至少减少 40% 字节,官方读取另列对照;BM-03 对文件/发现 schema 执行 ≤400 token 上限,并保留 Bash 启动常驻。同时记录范围分数与完整供应商总数,核验含可用 Windows Bash 的确切默认工具清单。不能用更小工作负载、缺少 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/\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..aa7f3482 --- /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,53 @@ +# 搜索覆盖范围与续页 + +Native 渲染会在匹配文本之前显示扫描覆盖范围与恢复建议;公开参数声明 `continuation`,结构化输出声明完整报告字段。 + +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/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/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..f0f73fd9 --- /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,55 @@ +# 文档解析依赖审查 + +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 基准。 + +## 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/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..8b3cc56f --- /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; +[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 返回值中。逐文件版本回执省去重复的 `Notice: ` 前缀与末尾句点;汇总 `Notice` 以及版本、续读和完整性事实均保留。 + +发生物理截断、缺失文件、覆盖事实异常、引用存储或其他不确定情况时,继续使用诊断输出并保留明确恢复提示。最终显示预算截断不会保留成功汇总,而会要求用更小上限重新读取原范围。上下文去重同时识别 `N|` 与现有单文件 `N | ` 格式;只有当前供应商上下文中存在完全相同的旧正文证据,才能返回未变化引用。 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..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" @@ -1,17 +1,18 @@ # 组件参考 -六个组件分别发布什么工具、参数是什么、需要什么权限。 +七个组件分别发布什么工具、参数是什么、需要什么权限。 --- ## 组件总览 | 配置行 `name` | 介绍 | 默认 | 发布的工具 | | --- | --- | --- | --- | -| `dsh-ops/shell` | 将默认终端改为 bash,以及 bash 覆盖不到时提供 PowerShell 7 | 开 | `ops_bash`;宿主 `pwsh` 使用随包 PowerShell 7 | -| `dsh-ops/file` | 更快、更高性能、输出更精简更省 token 的 Rust 文件检索 | 开 | 四个文件工具 | -| `dsh-ops/ptc` | 独立 Rust 安全批处理和有界结果引用,仅纯 PTC 会话加载 | 关 | `ops_batch`、`ops_result` | -| `dsh-ops/panel` | 数据采集,开关默认关:新对话写入 `/.dsh-ops/panel/.json` 与读取它的 `panel.html` | 行开,`capture: false` | 无;见[配置参考](配置参考.md) | +| `dsh-ops/shell` | 优先使用 bash,无法覆盖时使用宿主 PowerShell | Windows 开;Linux/macOS 关 | `ops_bash`;宿主 `pwsh` 保持其原执行器 | +| `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) | | `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 行会作为空操作加载:接受任意配置、不发布任何工具,只记录一条警告提示可删除该行。 @@ -19,24 +20,33 @@ ## 工具总表 +共享能力目录有 21 项,其中 PTC 生成 SDK 包含 19 个 API:16 个文件/文档工具,加 batch、result 和 sdk;bash 与 discover 属于其他入口。目录成员不表示常驻或已获权限。新 PTC 允许池跟随已实现目录,已有显式池保持不变。文档工具默认按需加载。 + | 工具 | 用途 | 完全权限要求 | | --- | --- | --- | +| `ops_read_many` | 常驻读取 1-32 个文件的有界 UTF-8 范围 | 无命令权限门¹ | | `ops_inspect_local_file` | 有界 UTF-8 文本行与 hex | 无命令权限门¹ | | `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 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`(组策略仍优先)。 +¹ **固定文件操作遵循 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-ops/file` —— 文件工具 -全部文件工具都以 DSH 会话 cwd 为基准解析相对路径,只接受一个 `path`,不提供 `root`、`workspace`、`cwd` 或 `file_path` 别名。全部为 UTF-8 优先且有界输出;glob 与 grep 不遍历符号链接、junction、重解析点、隐藏文件和 `.git`。 +仅有界多文件读取器 `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`。 宿主提供 `fs` 服务时,`ops_inspect_local_file` 的读取与 `ops_replace` 的实际写入会像宿主 `read`/`edit` 一样登记到宿主的「先读后改」观测表(`fs/observed`):用 OPS 读过的文件可直接用宿主 `edit`,OPS 写过之后宿主也不会把它误判为外部改动。`ops_grep`、`ops_glob` 与 `dryRun` 不登记。 @@ -44,7 +54,7 @@ 读取有界 UTF-8 文本行,或某个路径的 hex 视图。目录会被拒绝——请改用 `ops_glob` 或 `ops_grep`。 -模型展示把同路径页面的 `path` 提到顶层,只显示一次;原始执行/PTC 结果保留每条记录的路径。revision、分页、行内截断等其余字段均保留。worker 字节预算和超长单行续读行为不变;展示字节数不等于实测 token。 +Native 输出文件标题、带行号的可逆转义正文及 revision/续读提示;PTC 保留规范类型化记录。反斜杠、控制字符和 Unicode 行分隔符均转义,正文不能伪造额外行。worker 字节预算和长行续读仍明确保留,见[紧凑文件范围](紧凑文件范围.md)。展示字节不等于实测 token。 | 参数 | 类型 | 说明 | | --- | --- | --- | @@ -102,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`。 --- @@ -131,12 +147,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 | 每页条数 | @@ -148,19 +165,28 @@ ## `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 服务时不声明后台参数,前台超时会停止命令。未知参数在执行前被拒绝。 + +Bash 参数错误以失败的 foreground 值返回,不抛出异常。兼容的字符串 `error` 保留,`failure` 补充稳定错误码、副作用状态和重试指引。参数错误无命令副作用;执行后的超时、取消和非零退出效果未知,必须先只读检查,禁止自动重放。 宿主接受任务启动回调后准入失败时,取消排队启动,不会转为前台再次执行。 + +`sandbox_permissions` 和 `justification` 可通过宿主审批服务申请更宽的权限。一次批准只作用于当前命令;拒绝后不执行,也不修改会话模式。审批与沙箱准备后都会重新检查会话权限。Windows 如实报告宿主的实际约束级别(包括 `partial`);不兼容的 Bash 运行时直接拒绝执行。 -“默认终端改为 bash”指模型提示优先使用 `ops_bash`,**不修改系统默认终端、PATH 或宿主原有 shell 工具名称**。Shell 组件开时,通过官方配置生命周期临时指定 `pwsh-sandbox` 的 PowerShell 7 路径;关时按执行器最新原配置恢复,不写 profile 配置。执行器配置切换不代表取消已在运行的宿主命令。 +`script_path`(替代 `command`)、`graceMs` 和 `includeDiagnostics` 是保留的 OPS 兼容扩展,不改变宿主命令契约或超时上限。 + +“默认终端改为 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 | | --- | --- | @@ -170,104 +196,37 @@ | 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) | ``` --- -## `dsh-ops/hide-official` —— 接管范围 - -可选组件,**默认禁用**。`strict` 的含义是「在 dsh-ops 接管的范围内,只隐藏已有经评审替代品的工具」,而不是「不允许任何官方工具存活」。接管范围恰好是三面:**shell / file / ptc**。 +## `dsh-ops/hide-official` —— 逐项替换 -「面」一列中 `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 替代工具必须已注册、可见,并且是 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 验证器与公开 JSON schema 描述,供宿主内置行表单使用。可选浏览器多选页面需要显式客户端承载行迁移,见[配置参考](配置参考.md)。 -三种模式可直接复制的配置行见[配置参考](配置参考.md)。 --- 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..73c76fcd --- /dev/null +++ "b/docs/\347\273\223\346\236\234\351\242\204\347\256\227.md" @@ -0,0 +1,43 @@ +# 结果预算与会话生命周期 + +OPS 工作进程单次执行上限为 30 秒。同一宿主会话的 Native 与 PTC 共用先进先出的调用队列: +最多同时执行两次、等待八次。队列溢出返回 CALL_LIMIT,且无副作用。取消的等待调用不会启动; +获得执行资格后重新检查权限。Native 模型文本按转义后的字节计算,上限为 64 KiB;工作进程 +传输收集上限另设为 128 KiB。规范数据缓存限额为每结果 10 MiB、每会话 100 MiB。三者分别计量。 + +引用缓存位置来自可信宿主元数据。Windows 使用真实沙箱提供者的会话临时目录及其专用写入能力。 +PTC 仍通过宿主沙箱执行,Native 固定文件操作保持原有权限执行路径。完全访问模式准备缓存时 +主动收窄为工作区写入权限。无法准备缓存时,文件操作继续返回有界且明确标注不完整的结果; +创建引用则报告缓存不可用。PTC 不会回退为无沙箱执行。 + +引用绑定当前活动会话、权限、凭据保护规则、操作根目录和生产工具。只接受当前可见且可执行的 +生产工具及已获准执行的工具。权限模式变化会使原有绑定失效,包括收窄为只读。只读调用不会 +创建缓存或更新 LRU。已有旧式审计引用保持独立的约定。 + +会话销毁立即使引用过期,取消等待及执行中的调用,等待执行结束,再按当前宿主权限清理经过 +验证的快照和暂存文件。稳定锁及未知文件保留,审计数据不清理。只读模式或宿主服务不可用时, +物理清理可能无法完成并记录诊断;不安排自动重试,最多一个会话配额的残留文件需由宿主临时目录 +清理或操作者删除。内存中的新会话随机标识保证重启后不能重新使用磁盘残留引用。 +各组件共享生命周期;最后一个 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/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..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" @@ -7,6 +7,7 @@ - [dsh-ops 是什么(以及不是什么)](#dsh-ops-是什么以及不是什么) - [工作流程:先 issue,后 PR](#工作流程先-issue后-pr) - [开发环境](#开发环境) +- [构建插件与源码安装](#构建插件与源码安装) - [构建原生 worker](#构建原生-worker) - [验证门禁](#验证门禁) - [编写测试](#编写测试) @@ -39,16 +40,34 @@ ```console npm ci --ignore-scripts +npm run build # 将 src/ 编译到 lib/ 并复制组件资源 npm run build:ptc # 构建并暂存原生 worker(见下) npm run verify ``` +## 构建插件与源码安装 + +插件源码在 `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` 检查全部插件源码,不生成产物。构建拒绝 `src/` 中的 JavaScript 文件,包括 TypeScript 编译器原本会静默忽略的未导入文件。仓库脚本与测试仍可使用 JavaScript。 + +从本地目录或 Git 源码安装时,先在检出的仓库中构建并打包,再安装到 DSH: + +```console +npm ci --ignore-scripts +npm run build +npm run build:ptc +npm pack +``` + +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 大多数套件驱动的是真实的原生 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 代理 ``` @@ -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`。 @@ -76,13 +95,15 @@ 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 行以内。文件超过这个规模时,拆成模块,并把相关模块归到一个文件夹中,参照现有的 `lib/components/*` 与 `test/lib/` 布局。 -- **工具名**:一律经 `lib/policy.js` 的 `publicToolName()` 生成,不要手写 `ops_` 前缀。 -- **配置校验**:放在 `lib/config.js`,未知键必须报错并点名;`cordis.patch.yml` 里的默认值必须与代码默认值一致。 +- **文件规模**:新文件保持在约 500 行以内。文件超过这个规模时,拆成模块,并把相关模块归到一个文件夹中,参照现有的 `src/components/*` 与 `test/lib/` 布局。 +- **工具名**:一律经 `src/policy.ts` 的 `publicToolName()` 生成,不要手写 `ops_` 前缀。 +- **配置校验**:放在 `src/config.ts`,未知键必须报错并点名;`cordis.patch.yml` 里的默认值必须与代码默认值一致。 - **解析与探测**:同步放在 `apply` 里;`required: true` 必须在加载期拒绝激活。 ## 提交与 PR @@ -105,3 +126,26 @@ 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` 验证类型边界与既有权限行为。 + +`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` 验证类型边界、作用域启动/卸载及下一步发布。 + +设置集成测试使用固定 `0.2.0-rc.2` 版本的宿主设置、配置启动及配置编辑器开发依赖, +并固定兼容的 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` 套件覆盖 +表单到配置持久化以及先检查后执行的操作边界。 + +可选承载行回归测试还使用仅开发依赖 `@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/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..d613ad27 --- /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 源码已实现。本页不是发布公告:最终证据须满足已批准的 OPS 0.3.x BM-01 门槛与文件/发现 BM-03 预算,同时单列官方对照及含 Bash 的完整启动总数。范围和格式限制见[候选说明](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 bce76e2f..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" @@ -2,20 +2,74 @@ 按组件说明每个配置键,以及**哪个键属于哪个组件行**。 +## 内置行配置 + +活动行通过宿主 Settings/ConfigEditor 提供严格校验的设置。接受的更改通过 profile 写入器持久化并重载所属行;版本过期或无效更改不会修改已保存的值。关闭的行须先用宿主行开关重新启用,等待中的服务须先就绪,之后才能编辑。 + +默认 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` 导出保持兼容。关闭此承载行会卸载全部自定义设置页面,请使用宿主的 +**内置行开关**重新启用。没有自动表单回退。目标行被关闭时,也必须先重新启用才能编辑设置。 +发现组件行与可选客户端承载行都启用后,才可使用其自定义设置页面。 + +### 启用可选浏览器承载行 + +这是主动选择的离线 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。 + --- ## 配置的位置 在 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` | 严格隐藏官方工具,独立配置 | 关 | +| `dsh-ops/hide-official` | 逐项替换官方工具,独立配置 | 关 | 早期版本留下的 `dsh-ops/background` 行会作为空操作桩加载(FastCtx 后台组件已移除);它忽略自身配置,可直接删除。 @@ -23,27 +77,31 @@ Bundle 声明六个组件,分别启停;首次安装时 shell、file 与 pane ## 🔴 键与组件的归属关系 -`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 | -| `capture` | 布尔 | `false` | panel | +| `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 返回值不披露被拒路径、解析后的父目录或匹配规则。 + 键名是历史遗留;围栏本身拦的是**路径**,不是某一个方向。凭据位置在**读**(`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 会在下次启动时把它们从已保存的行中删除。 @@ -60,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` 行: @@ -74,11 +132,11 @@ config: # configUpdateVersion: 2 ``` -`dsh-ops/panel` 行 —— 设置项「**启动数据检测**」(`capture`) **默认关闭**: +`dsh-ops/panel` 行**默认关闭**。启用新行即开始采集;旧配置显式 `capture: false` 保持关闭,需操作者自行更改: ```yaml config: - capture: false # 「启动数据检测」:打开后只采集之后的新对话 + capture: true # 行开关控制采集;旧配置 false 继续不采集 textLimit: 2000 # 每个文本字段保留的字符数,被截断时会标记 maxSessionBytes: 8388608 ``` @@ -90,15 +148,18 @@ 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`,也不提供文件系统沙箱。 -- `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)仅在 `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`,**关就是什么都不做**——该行不注册监听、不建目录。打开后,每个**新对话**写入它自己的工作目录: +关闭组件行或保存 `capture: false` 会立即停止事件采集、取消尚未开始的文件操作,并在宿主完成变更前等待已开始的 I/O。停用不会触发最终归并;已有 NDJSON 可恢复,中断归并留下的临时文件可能保留。重新开启只采集后续事件,不补录关闭期间的事件。 + +- panel 行默认关闭,不注册监听、不写文件。启用后 `capture` 默认为 `true`;旧配置显式 `false` 仍不采集,不会被静默更改。宿主无法原子地清除旧字段并同时停用行,因此保留兼容设置,供操作者显式编辑。采集到的事件写入会话工作目录: - `/.dsh-ops/panel/.json` —— 唯一数据源:对话文本、tool_call/tool_result/里程碑轨迹、usage 分桶,以及截断与上限记录; - `/.dsh-ops/panel/panel.html` —— 静态查看器,在浏览器里读取该 JSON,**不内联任何数据**(把 JSON 拖到页面上,或经静态服务用 `?data=.json`)。 @@ -108,53 +169,36 @@ 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 替代工具必须是已注册且可见的确切 OPS Native 定义;同名外部工具或仅有 PTC 数据 API 均不符合条件。若其中任一工具缺失,该作用域会撤销隐藏,并以 `READINESS_BLOCKED` 拒绝提示词组装和执行;健康作用域仍可使用。恢复替代工具、移除相应选择或禁用该行即可解除。无需注册其它无关的 OPS 工具。 -```yaml -- name: dsh-ops/hide-official - config: - strict: true - scope: extended - additionalOfficialTools: [subagent] -``` +Native 默认只保留精简核心工具。选择上例前,应先为发现组件配置 `alwaysLoaded: [inspect_local_file, grep]`,或先激活这些 Native 能力。`read` 仍映射到 `inspect_local_file`,常驻 `read_many` 不能满足此选择。撤销或覆盖受拥有的替代定义会立即重新检查就绪状态,即使 schema 字节完全相同。 -保留配置行但让它中立(灰度发布、多机共用 profile): +**从 0.3 迁移:** 仅为兼容旧配置,接受并按旧类型和取值校验 `strict`、`scope`、`additionalOfficialTools`;它们均不授予隐藏权限,包括 `scope: extended` 和别名列表。只有旧键的既有行会变为无操作。即使保留旧键,`replaceTools` 也是唯一选择来源。本组件不改写已保存的 profile。 -```yaml -- name: dsh-ops/hide-official - config: - strict: true - scope: off -``` +Cordis 通过 `StandardSchemaV1` 校验配置;独立的公共设置描述在内置 Settings/ConfigEditor 中提供可编辑数组。成功保存后持久化并重载对应行。自定义浏览器多选界面仍需单独实现。 -哪些官方工具会被隐藏、哪些不会、为什么:见[组件参考](组件参考.md)的「`dsh-ops/hide-official` —— 接管范围」一节。 -启用该行不会因为范围外工具而阻塞会话:它们保留可见,并产生一条逐个点名的警告(#207/#238)。 --- ## 提示词注册 -提示词注册为独立运行时段,**不写 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,并保持当前权限检查。已批准的 BM-03 预算对文件/发现 schema 执行 ≤400 token 上限。Bash 启动常驻不变,其 schema 与完整启动总数另列;缺少 Bash 不能满足 Windows 就绪检查。 --- @@ -163,3 +207,17 @@ config: - [使用指南](使用指南.md) —— 安装、更新、卸载、命令行接口 - [组件参考](组件参考.md) —— 组件与工具列表 - [常见问题](常见问题.md) —— 报错与排查 + +## 宿主设置表单 + +已启用的 shell、discovery 和官方工具替换行向宿主设置服务提供经过校验的字段。 +保存使用表单读取时的配置版本;版本过期或选项无效时,在写入前拒绝更改。 +宿主持久化成功后重新加载对应行,使运行中的设置与已保存选择一致,同时保留其他行字段。 +禁用的行没有可编辑表单;请先使用宿主内置的行开关重新启用。 + +运行时操作使用宿主已认证的网关及受管理的子进程服务。安装、删除和升级必须先预览, +再执行仍然有效的预览;过期、内容变化或已使用的计划会被拒绝。同一时间只执行一个操作。 +卸载对应 shell 组件会取消尚未完成的操作并等待结束;启动后取消可能已造成更改,副作用标为未知。 +Bash 安装拒绝包含无归属版本目录的存储。PowerShell 操作更新标准 WinGet 安装,随后需要重启 +DSH;宿主显式设置的 `pwshPath` 仍然优先。安装器输出留在宿主日志中;如果 WinGet 需要交互输入, +请在终端运行预览中显示的命令。 diff --git a/dsh-plugin.json b/dsh-plugin.json index dd1c65a0..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": { @@ -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,12 +57,12 @@ { "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", "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": [ @@ -82,7 +82,13 @@ "stat", "copy", "move", - "remove" + "remove", + "read_many", + "edit_many", + "table_read", + "doc_read", + "pdf_read", + "result" ], "shellPermission": "Per-call DSH sandbox policy and native subprocess" }, @@ -105,15 +111,15 @@ "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_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" }, { "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"] }, @@ -124,11 +130,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": [] @@ -145,9 +160,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/lib/components/file/index.js b/lib/components/file/index.js deleted file mode 100644 index ef3a6443..00000000 --- a/lib/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/lib/components/file/locale/en.json b/lib/components/file/locale/en.json deleted file mode 100644 index bf67586b..00000000 --- a/lib/components/file/locale/en.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "dsh-ops-file", - "description": "High-performance Rust file search with faster execution and compact output for token efficiency." - } -} diff --git a/lib/components/file/locale/zh.json b/lib/components/file/locale/zh.json deleted file mode 100644 index 28222e82..00000000 --- a/lib/components/file/locale/zh.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "dsh-ops-file", - "description": "更快、更高性能、输出更精简更省token的rust文件检索" - } -} diff --git a/lib/components/file/package.json b/lib/components/file/package.json deleted file mode 100644 index 9f82edc9..00000000 --- a/lib/components/file/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "dsh-ops-file", - "description": "High-performance Rust file search with faster execution and compact output for token efficiency.", - "icon": "./icon.png", - "type": "module" -} diff --git a/lib/components/hide-official/index.js b/lib/components/hide-official/index.js deleted file mode 100644 index cec1fa9e..00000000 --- a/lib/components/hide-official/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { apply } from '../../official-tool-hiding.js' -export const name = 'dsh-ops-hide-official' -export { apply } diff --git a/lib/components/hide-official/locale/en.json b/lib/components/hide-official/locale/en.json deleted file mode 100644 index 6a939731..00000000 --- a/lib/components/hide-official/locale/en.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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." - } -} diff --git a/lib/components/hide-official/locale/zh.json b/lib/components/hide-official/locale/zh.json deleted file mode 100644 index e0e06a43..00000000 --- a/lib/components/hide-official/locale/zh.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "隐藏官方工具", - "description": "只隐藏 shell/file/ptc 三面中已有 OPS 等价物的官方工具;其余官方工具保留可见,并由一条警告逐个点名。仅当 additionalOfficialTools 显式要求隐藏却无等价物时才拒绝。" - } -} diff --git a/lib/components/hide-official/package.json b/lib/components/hide-official/package.json deleted file mode 100644 index 1c414944..00000000 --- a/lib/components/hide-official/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "dsh-ops-hide-official", - "description": "Strict scoped takeover of official tools, with replacement readiness and fail-closed surface verification.", - "icon": "./icon.png", - "type": "module" -} diff --git a/lib/components/panel/index.js b/lib/components/panel/index.js deleted file mode 100644 index 0b48e8bc..00000000 --- a/lib/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/lib/components/ptc/index.js b/lib/components/ptc/index.js deleted file mode 100644 index 85ea6fc8..00000000 --- a/lib/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/lib/components/ptc/locale/en.json b/lib/components/ptc/locale/en.json deleted file mode 100644 index 69e3df88..00000000 --- a/lib/components/ptc/locale/en.json +++ /dev/null @@ -1 +0,0 @@ -{"meta":{"title":"PTC enhancement","description":"Native Rust safe batch operations and bounded result references; only enabled in PTC-only sessions."}} diff --git a/lib/components/ptc/locale/zh.json b/lib/components/ptc/locale/zh.json deleted file mode 100644 index e7e34229..00000000 --- a/lib/components/ptc/locale/zh.json +++ /dev/null @@ -1 +0,0 @@ -{"meta":{"title":"PTC 增强","description":"原生 Rust 安全批处理与有界结果引用,仅在纯 PTC 会话启用。"}} diff --git a/lib/components/ptc/package.json b/lib/components/ptc/package.json deleted file mode 100644 index 5b786840..00000000 --- a/lib/components/ptc/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "dsh-ops-ptc", - "description": "Native Rust PTC batch operations and bounded result references for Windows, macOS and Linux.", - "icon": "./icon.png", - "type": "module" -} diff --git a/lib/components/shell/index.js b/lib/components/shell/index.js deleted file mode 100644 index 3e708b40..00000000 --- a/lib/components/shell/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { applyComponent } from "../../index.js" -export const name = "dsh-ops-bash & powershell 7" -export function apply(ctx, config) { return applyComponent(ctx, config, "shell") } diff --git a/lib/components/shell/locale/en.json b/lib/components/shell/locale/en.json deleted file mode 100644 index a2eaf627..00000000 --- a/lib/components/shell/locale/en.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "dsh-ops-bash & powershell 7", - "description": "Prefer bash for general commands and provide PowerShell 7 when Windows-native functionality is needed." - } -} diff --git a/lib/components/shell/locale/zh.json b/lib/components/shell/locale/zh.json deleted file mode 100644 index 78403aba..00000000 --- a/lib/components/shell/locale/zh.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "dsh-ops-bash & powershell 7", - "description": "将默认终端改为bash,以及bash覆盖不到时提供powershell7" - } -} diff --git a/lib/components/shell/package.json b/lib/components/shell/package.json deleted file mode 100644 index b41c8b25..00000000 --- a/lib/components/shell/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "dsh-ops-bash & powershell 7", - "description": "Prefer bash for general commands and provide PowerShell 7 when Windows-native functionality is needed.", - "icon": "./icon.png", - "type": "module" -} diff --git a/lib/file/contract.js b/lib/file/contract.js deleted file mode 100644 index e688d94d..00000000 --- a/lib/file/contract.js +++ /dev/null @@ -1,127 +0,0 @@ -import { publicToolName, FILE_TOOLS, FILE_MUTATIONS } from '../policy.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 }) - -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.' } - -export const PARAMETERS = Object.freeze({ - 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.' }, - limit: range(1, 10000, 'Default 200.'), - encoding: { type: 'string', enum: ['utf-8'] }, - view: { type: 'string', enum: ['text', 'hex'] }, - }), - glob: bounded({ - path: string, - pattern: { type: 'array', description: 'At least one positive glob is required.', items: string }, - outputMode: { type: 'string', enum: ['paths', 'details'] }, - sort: { type: 'string', enum: ['path', 'modified'] }, - offset: integer, - limit: range(1, 10000, 'Default 100.'), - }, ['pattern']), - grep: bounded({ - path: string, - pattern: string, - glob: { type: 'array', items: string }, - outputMode: { type: 'string', enum: ['files_with_matches', 'content', 'count', 'summary'] }, - caseInsensitive: { type: 'boolean' }, - lineNumbers: { type: 'boolean' }, - beforeContext: range(0, 32), - afterContext: range(0, 32), - multiline: { type: 'boolean' }, - offset: integer, - limit: range(1, 10000, 'Default 100.'), - }, ['pattern']), - 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']), - 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']), - replace: bounded({ - path: string, - pattern: string, - replacement: string, - glob: { type: 'array', items: string }, - maxReplacements: range(1, 100000, 'Default 100.'), - dryRun: { type: 'boolean' }, - }, ['pattern', 'replacement']), -}) - -export const OUTPUT = { - type: 'object', - properties: { - 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' }, - complete: { type: 'boolean' }, omitted: { type: 'integer' }, total: { type: 'integer' }, - nextOffset: { type: 'integer' }, truncated: { type: 'boolean' }, items: { type: 'array', items: {} }, - dryRun: { type: 'boolean' }, files: { type: 'integer' }, matches: { type: 'integer' }, - skippedFiles: { type: 'integer' }, - 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.' -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, - 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, - 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, - 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/lib/file/runtime.js b/lib/file/runtime.js deleted file mode 100644 index b261fa2e..00000000 --- a/lib/file/runtime.js +++ /dev/null @@ -1,55 +0,0 @@ -import { publicToolName } from '../policy.js' -import { definition, FILE_TOOLS } from './contract.js' -import { safeDisposeAll } from '../teardown.js' -import { invoke, failure } from './worker.js' - -export class NativeFileTools { - constructor({ ctx, config, report }) { - this.ctx = ctx - this.config = config - this.report = report ?? (() => {}) - this.file = undefined - this.active = new Set() - this.disposers = [] - this.stopped = false - } - - async start() { - this.file = this.config.workerPath - if (!this.file) { - 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)))) - } - return FILE_TOOLS.map(publicToolName) - } - - async call(tool, args, exec) { - if (this.stopped) return failure('POLICY_DENIED', 'Native file component is disposed.') - 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('Native file call timed out')), this.config.toolCallTimeoutMs ?? 300000) - timer.unref?.() - this.active.add(controller) - try { return await invoke(this.ctx, this.file, tool, args, exec, controller.signal, this.config) } - finally { - clearTimeout(timer) - this.active.delete(controller) - exec?.signal?.removeEventListener?.('abort', abort) - } - } - - async stop() { - this.stopped = true - for (const controller of this.active) controller.abort(new Error('Native file component unloaded')) - await Promise.allSettled([...this.active]) - // 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') - }, 'file runtime stop') - } -} diff --git a/lib/official-tool-catalog.js b/lib/official-tool-catalog.js deleted file mode 100644 index ea4d6984..00000000 --- a/lib/official-tool-catalog.js +++ /dev/null @@ -1,39 +0,0 @@ -/** 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. - */ -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. -export const OFFICIAL_REPLACEMENTS = Object.freeze({ - read: publicToolName('inspect_local_file'), - glob: publicToolName('glob'), - grep: publicToolName('grep'), -}) - -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/lib/official-tool-hiding.js b/lib/official-tool-hiding.js deleted file mode 100644 index 892dd287..00000000 --- a/lib/official-tool-hiding.js +++ /dev/null @@ -1,486 +0,0 @@ -/** 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). - */ -import { ConfigError, 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 { 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.` - -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) - 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 = [] - for (const lift of pending) { - if (!safeDispose(lift, error => fail(`dsh-ops: restriction cleanup failed: ${error.message}`), 'official-tool restriction')) failed.push(lift) - } - entry.lifts.push(...failed) - entry.cleanupError = failed.length > 0 ? 'host restriction cleanup failed; retrying before proceeding' : undefined - return failed.length === 0 - } - // 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 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 - // A failed plan hides nothing: no partial mask over an unready scope. - return { deny: failure ? [] : surface.hidden, warning: surfaceWarning(surface), failure } - } - // 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))]) - const reconcile = () => { - if (stopped || mutating) return - try { if (snapshot() === lastSurface) return } catch { /* reported by per-agent verification below */ } - mutating = true - try { - // 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 = [] - for (const [agent, entry] of entries) { - const plan = prepare(agent, entry) - entry.warning = plan.warning - plans.push([agent, entry, plan]) - } - // Each scope owns its readiness and mask. A failed child is blocked at - // assembly/dispatch; it does not make a healthy ancestor or root fail. - for (const [agent, entry, plan] of plans) { - if (plan.failure) { clear(entry); entry.error = plan.failure; continue } - 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 })) - 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) } - } - 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 - } - } - try { lastSurface = snapshot() } catch { lastSurface = undefined } - } finally { mutating = false } - } - const block = (agent, detail) => { - 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 - // fails; invalidate readiness so transient provider errors are retryable. - const entry = entries.get(agent) - if (entry) entry.error = message - 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') }) - reconcile() - } - const refusal = exec => { - if (stopped) return blocked('component disposed') - if (!exec?.agent?.ctx) return blocked('calling agent scope unavailable') - 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) - } - 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') - ctx.on('system-prompt/assemble', async (assembly, context, next) => { - // A global diagnostic assembly is not an agent AI surface. - if (!context.scope) return next() - attach(context.scope) - reconcile() - const entry = entries.get(context.scope) - const error = entry ? entry.error : blocked('agent scope cannot be verified') - 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}`) } - reconcile() - 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/disposed', ({ agent }) => { - const entry = entries.get(agent) - if (!entry) return - mutating = true - try { clear(entry); entries.delete(agent) } finally { mutating = false } - reconcile() - }) - ctx.on('tools/change', reconcile) - ctx.effect(() => () => { - stopped = true; mutating = true - for (const entry of entries.values()) clear(entry) - entries.clear() - }, 'dsh-ops: strict 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/lib/permissions.js b/lib/permissions.js deleted file mode 100644 index da15800d..00000000 --- a/lib/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/lib/ptc/index.js b/lib/ptc/index.js deleted file mode 100644 index fa1b49c8..00000000 --- a/lib/ptc/index.js +++ /dev/null @@ -1,164 +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 { 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' -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 => { - 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) 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')), 300000); 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) } - }))) - } - // 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)) - 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') { - for (const controller of active.keys()) controller.abort(new Error('Sandbox authority changed')) - 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/lib/session-pwsh.js b/lib/session-pwsh.js deleted file mode 100644 index f6a30f6d..00000000 --- a/lib/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/lib/tui/result.js b/lib/tui/result.js deleted file mode 100644 index 7a82e347..00000000 --- a/lib/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/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/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/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/native/Cargo.lock b/native/Cargo.lock index bb5ae2d5..c4235554 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" @@ -123,34 +138,38 @@ dependencies = [ [[package]] name = "dsh-ops-core" -version = "0.3.1" +version = "0.4.0" dependencies = [ "cap-fs-ext", "cap-std", - "dsh-ops-darwin-metadata", - "dsh-ops-windows-metadata", - "fs2", - "regex", - "rustix", - "serde", + "ops-batch", + "ops-documents", + "ops-files", + "ops-protocol", + "ops-results", + "ops-search", "serde_json", - "sha2", - "uuid", ] [[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", "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" @@ -161,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" @@ -227,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" @@ -290,12 +334,106 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "ops-batch" +version = "0.4.0" +dependencies = [ + "cap-std", + "ops-files", + "ops-protocol", + "ops-results", + "serde_json", + "uuid", +] + +[[package]] +name = "ops-documents" +version = "0.4.0" +dependencies = [ + "flate2", + "ops-files", + "ops-protocol", + "quick-xml", + "serde", + "serde_json", + "sha2", + "zip", +] + +[[package]] +name = "ops-files" +version = "0.4.0" +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.4.0" +dependencies = [ + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "ops-results" +version = "0.4.0" +dependencies = [ + "cap-fs-ext", + "cap-std", + "fs2", + "ops-files", + "ops-protocol", + "serde", + "serde_json", + "sha2", + "uuid", +] + +[[package]] +name = "ops-search" +version = "0.4.0" +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" @@ -311,6 +449,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" @@ -455,6 +602,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" @@ -659,6 +812,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/Cargo.toml b/native/Cargo.toml index 22768e08..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" @@ -15,23 +15,17 @@ 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-documents = { path = "crates/ops-documents" } +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..fc01ef64 --- /dev/null +++ b/native/crates/ops-batch/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ops-batch" +version = "0.4.0" +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-documents/Cargo.toml b/native/crates/ops-documents/Cargo.toml new file mode 100644 index 00000000..55dfec55 --- /dev/null +++ b/native/crates/ops-documents/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ops-documents" +version = "0.4.0" +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" +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/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/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/docx.rs b/native/crates/ops-documents/src/docx.rs new file mode 100644 index 00000000..0e17decf --- /dev/null +++ b/native/crates/ops-documents/src/docx.rs @@ -0,0 +1,402 @@ +//! 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 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", + 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).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); + 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) +} + +#[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/native/crates/ops-documents/src/lib.rs b/native/crates/ops-documents/src/lib.rs new file mode 100644 index 00000000..6d626916 --- /dev/null +++ b/native/crates/ops-documents/src/lib.rs @@ -0,0 +1,140 @@ +//! Bounded table observations; cells stay strings, and incomplete traversals never invent totals. +#![forbid(unsafe_code)] +mod archive; +mod pdf_syntax; +mod pdf_text; +mod pdf; +mod pdf_pages; +pub mod pdf_container; +mod xml; +mod docx; +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 == "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())?; + 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 = 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).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()); } + 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/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_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_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_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/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-documents/src/tests.rs b/native/crates/ops-documents/src/tests.rs new file mode 100644 index 00000000..cc811bd0 --- /dev/null +++ b/native/crates/ops-documents/src/tests.rs @@ -0,0 +1,64 @@ +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(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(); + 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, 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["pageComplete"], 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-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-files/Cargo.toml b/native/crates/ops-files/Cargo.toml new file mode 100644 index 00000000..894b0fd3 --- /dev/null +++ b/native/crates/ops-files/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "ops-files" +version = "0.4.0" +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/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/file.rs b/native/crates/ops-files/src/file.rs new file mode 100644 index 00000000..c030349a --- /dev/null +++ b/native/crates/ops-files/src/file.rs @@ -0,0 +1,381 @@ +//! 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), + "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 }) +} + +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) +} +#[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(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, request.output_sink.as_deref().map(|sink| (sink, false))) +} +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)?; + 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 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 + // 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("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 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) +} +/// 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/crates/ops-files/src/file_many.rs b/native/crates/ops-files/src/file_many.rs new file mode 100644 index 00000000..b2aa7044 --- /dev/null +++ b/native/crates/ops-files/src/file_many.rs @@ -0,0 +1,87 @@ +//! 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; + 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 { + // 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, sink.map(|sink| (sink, true))) + }); + let item = match observed { + Ok(item) => item, + 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 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" }); + 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..ee4d8e06 --- /dev/null +++ b/native/crates/ops-files/src/file_output.rs @@ -0,0 +1,77 @@ +//! 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 { + 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, 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; + 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)); + 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); + 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/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 98% rename from native/src/file_single.rs rename to native/crates/ops-files/src/file_single.rs index d3ff45c1..6632da97 100644 --- a/native/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( @@ -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(); @@ -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-files/src/lib.rs b/native/crates/ops-files/src/lib.rs new file mode 100644 index 00000000..15bf4ea8 --- /dev/null +++ b/native/crates/ops-files/src/lib.rs @@ -0,0 +1,52 @@ +#![forbid(unsafe_code)] +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)] +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..45f5fdf8 --- /dev/null +++ b/native/crates/ops-protocol/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ops-protocol" +version = "0.4.0" +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/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/envelope.rs b/native/crates/ops-protocol/src/envelope.rs new file mode 100644 index 00000000..6c7979d1 --- /dev/null +++ b/native/crates/ops-protocol/src/envelope.rs @@ -0,0 +1,101 @@ +//! 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, + storage: Option, +} + +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", "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")); + } + 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")); + } + 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 { + 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/src/contract.rs b/native/crates/ops-protocol/src/lib.rs similarity index 89% rename from native/src/contract.rs rename to native/crates/ops-protocol/src/lib.rs index 85d4fc33..33798a8a 100644 --- a/native/src/contract.rs +++ b/native/crates/ops-protocol/src/lib.rs @@ -1,4 +1,8 @@ -//! Typed operation contracts, independent of DSH transport and presentation. +#![forbid(unsafe_code)] +pub mod capture; +pub mod envelope; +pub mod search; +pub mod storage; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -72,6 +76,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.", @@ -148,6 +153,13 @@ 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, pub root: String, #[serde(default)] pub read_only: bool, @@ -189,6 +201,7 @@ pub struct ResultArgs { pub offset: usize, pub limit: Option, pub max_bytes: Option, + pub ttl_ms: Option, pub fields: Option>, pub equals: Option>, } @@ -215,3 +228,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-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-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/Cargo.toml b/native/crates/ops-results/Cargo.toml new file mode 100644 index 00000000..27134182 --- /dev/null +++ b/native/crates/ops-results/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ops-results" +version = "0.4.0" +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/cache.rs b/native/crates/ops-results/src/cache.rs new file mode 100644 index 00000000..00d9ce3f --- /dev/null +++ b/native/crates/ops-results/src/cache.rs @@ -0,0 +1,158 @@ +//! 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)?; + // 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(); + 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 new file mode 100644 index 00000000..4a3a48ae --- /dev/null +++ b/native/crates/ops-results/src/lib.rs @@ -0,0 +1,11 @@ +#![forbid(unsafe_code)] +use ops_protocol as contract; +use ops_files::paths; +#[cfg(unix)] +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 new file mode 100644 index 00000000..6f98c315 --- /dev/null +++ b/native/crates/ops-results/src/references.rs @@ -0,0 +1,162 @@ +//! 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, + #[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 { + 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, 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."})); + } + 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}); + 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(); + 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) +} + +/// 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/src/results.rs b/native/crates/ops-results/src/results.rs similarity index 96% rename from native/src/results.rs rename to native/crates/ops-results/src/results.rs index 5c66f93c..3a8adb26 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}, }; @@ -20,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( @@ -45,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() { @@ -92,7 +93,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/crates/ops-results/src/spill.rs b/native/crates/ops-results/src/spill.rs new file mode 100644 index 00000000..6f40a039 --- /dev/null +++ b/native/crates/ops-results/src/spill.rs @@ -0,0 +1,141 @@ +//! 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 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 }); + } + 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(); + // 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().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); + 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/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..e26a72f9 --- /dev/null +++ b/native/crates/ops-search/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ops-search" +version = "0.4.0" +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/crates/ops-search/src/lib.rs b/native/crates/ops-search/src/lib.rs new file mode 100644 index 00000000..395a9d9a --- /dev/null +++ b/native/crates/ops-search/src/lib.rs @@ -0,0 +1,308 @@ +//! 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; +use cap_fs_ext::DirExt; +use regex::RegexBuilder; +use serde::Deserialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +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; +fn one_hundred() -> usize { 100 } +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" => 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 ReplaceArgs { + path: String, + pattern: String, + replacement: String, + #[serde(default)] + glob: Vec, + #[serde(default = "one_hundred")] + max_replacements: usize, + #[serde(default)] + dry_run: bool, +} +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('.')) +} +fn host_link_or_reparse(path: &Path) -> bool { + let Ok(metadata) = std::fs::symlink_metadata(path) else { return true }; + if metadata.file_type().is_symlink() { return true; } + #[cfg(windows)] + if metadata.file_attributes() & 0x400 != 0 { return true; } + false +} +struct WalkResult { + files: Vec, + truncated: bool, +} + +fn walk(root: &Path, request: &Request) -> Result { + request.authorize_path(root)?; + let target = crate::file_path::checked(root.to_str().ok_or_else(|| Failure::new("INVALID_ARGUMENT", "Non-UTF8 path"))?)?; + let directory = target.parent.open_dir_nofollow(&target.name)?; + let mut files = Vec::new(); + let mut truncated = false; + visit_dir(root, root, directory, 0, request, &mut files, &mut truncated)?; + Ok(WalkResult { files, truncated }) +} +fn visit_dir(root: &Path, display: &Path, directory: cap_std::fs::Dir, depth: usize, request: &Request, output: &mut Vec, truncated: &mut bool) -> Result<()> { + if depth > MAX_DEPTH || output.len() >= MAX_ENTRIES { + *truncated = true; + return Ok(()); + } + let mut names = Vec::new(); + // A directory can change while it is being read. A broken child directory + // is skipped, while failure to enumerate the requested root remains visible. + let entries = match directory.entries() { + Ok(entries) => entries, + Err(error) if depth == 0 => return Err(error.into()), + Err(_) => return Ok(()), + }; + for entry in entries { + let Ok(entry) = entry else { continue }; + let Ok(name) = entry.file_name().into_string() else { continue }; + names.push(name); + } + names.sort(); + for name in names { + if crate::file_path::validate_name(&name).is_err() || name == ".git" { continue; } + let metadata = match directory.symlink_metadata(&name) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if crate::file_path::link(&metadata) { continue; } + let child = display.join(&name); + if host_link_or_reparse(&child) { continue; } + // A denied root is skipped by identity, not by name: the host names the + // credential locations and the worker stays out of them entirely, so a + // traversal cannot even report a private key below one. + if request.denies_path(&child) { continue; } + if hidden(&child, root) || matches!(name.as_str(), ".git" | ".dsh-ops" | "node_modules" | "target" | ".venv") { continue; } + if metadata.is_file() { + if output.len() >= MAX_ENTRIES { *truncated = true; return Ok(()); } + output.push(child); + continue; + } + if metadata.is_dir() { + let Ok(child_dir) = directory.open_dir_nofollow(&name) else { continue }; + visit_dir(root, &child, child_dir, depth + 1, request, output, truncated)?; + if *truncated && output.len() >= MAX_ENTRIES { return Ok(()); } + } + } + Ok(()) +} +fn positive_patterns(patterns: &[String]) -> impl Iterator { patterns.iter().filter(|p| !p.starts_with('!')) } +fn matches_any(root: &Path, file: &Path, patterns: &[String]) -> bool { + let name = relative_display(root, file); + let positives: Vec<_> = positive_patterns(patterns).collect(); + let included = positives.is_empty() || positives.iter().any(|p| glob_match(p, &name)); + let excluded = patterns.iter().filter_map(|p| p.strip_prefix('!')).any(|p| glob_match(p, &name)); + included && !excluded +} +fn files_for(path: &str, patterns: &[String], request: &Request) -> Result { + let root = target(path, false)?; + request.authorize_path(&root)?; + if root.is_file() { + return Ok(WalkResult { files: vec![root.clone()], truncated: false }); + } + let walked = walk(&root, request)?; + Ok(WalkResult { + files: walked.files.into_iter() + .filter(|f| matches_any(&root, f, patterns)) + .collect(), + truncated: walked.truncated, + }) +} +fn glob_match(pattern: &str, value: &str) -> bool { + let p: Vec<_> = pattern.trim_start_matches("./").split('/').collect(); + let v: Vec<_> = value.split('/').collect(); + glob_segments(&p, &v) +} +fn glob_segments(p: &[&str], v: &[&str]) -> bool { + if p.is_empty() { return v.is_empty(); } + if p[0] == "**" { return glob_segments(&p[1..], v) || (!v.is_empty() && glob_segments(p, &v[1..])); } + !v.is_empty() && segment_match(p[0], v[0]) && glob_segments(&p[1..], &v[1..]) +} +fn segment_match(pattern: &str, value: &str) -> bool { + let (mut pi, mut vi, mut star, mut mark) = (0usize, 0usize, None, 0usize); + let p = pattern.as_bytes(); let v = value.as_bytes(); + while vi < v.len() { + if pi < p.len() && (p[pi] == b'?' || p[pi].eq_ignore_ascii_case(&v[vi])) { pi += 1; vi += 1; } + else if pi < p.len() && p[pi] == b'*' { star = Some(pi); pi += 1; mark = vi; } + else if let Some(s) = star { pi = s + 1; mark += 1; vi = mark; } + else { return false; } + } + while pi < p.len() && p[pi] == b'*' { pi += 1; } + pi == p.len() +} +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())?; + if args.pattern.is_empty() || args.max_replacements == 0 || args.max_replacements > 100_000 { return Err(Failure::new("INVALID_ARGUMENT", "pattern and maxReplacements are required")); } + let walked = files_for(&args.path, &args.glob, request)?; let mut plans = Vec::new(); let mut total = 0usize; let mut binary: Vec = Vec::new(); + // A directory that holds one binary file (an icon, an archive) is a normal repository, not a + // malformed request: the binary is reported as skipped and the text files around it are still + // edited. Failing the whole call on the first non-UTF-8 byte made a routine directory edit + // impossible, and the error named no file, so the caller could not even narrow the request. + let mut update_guards = Vec::new(); + for file in walked.files { + // Serialize observation as well as publication. On APFS an open racing + // an unlink/rename can fail ENOENT on the superseded vnode even though + // the directory name always has a complete version. Do not retry or + // relabel ENOENT: avoid the race between cooperating writers instead. + let (guard, bytes) = if args.dry_run { (None, read_bytes(&file)?) } + else { let (guard, bytes) = lock_and_read_for_update(&file)?; (Some(guard), bytes) }; + match String::from_utf8(bytes.clone()) { + Ok(text) => { + let count = text.match_indices(&args.pattern).count(); + if count > 0 { total += count; plans.push((file, bytes, text, count)); update_guards.push(guard); } + } + Err(_) => binary.push(file), + } + } + let skipped = binary.len(); + let skipped_paths: Vec = binary.iter().take(8).map(|file| json!(relative_display(&request.root_path(), file))).collect(); + if total > args.max_replacements { return Err(Failure::new("OUTPUT_LIMIT", format!("{total} matches exceed maxReplacements ({}); no file was changed. maxReplacements is a ceiling, not a truncation: raise it to {total} or more and retry.", args.max_replacements))); } + let mut items=Vec::new(); for (file, original, text, count) in plans { let revision=format!("{:x}", Sha256::digest(&original)); let next=text.replace(&args.pattern,&args.replacement); items.push(json!({"path":file,"replacements":count,"revision":revision,"revisionAfter":format!("{:x}", Sha256::digest(next.as_bytes())),"bytesBefore":original.len(),"bytesAfter":next.len(),"lineCountBefore":text.lines().count(),"lineCount":next.lines().count()})); } + if serde_json::to_vec(&items)?.len() > MAX_OUTPUT_BYTES / 2 { return Err(Failure::new("OUTPUT_LIMIT", "Replacement receipts exceed the bounded output budget; narrow path/glob. No files changed.")); } + if args.dry_run { return Ok(json!({"status":"completed","phase":"observe","changed":0,"matched":total,"skipped":skipped,"failed":0,"complete":true,"omitted":0,"dryRun":true,"items":items,"skippedPaths":skipped_paths})); } + for item in &items { publication::authorize_write(request, item["path"].as_str().unwrap_or(""))?; } + let mut changed=0usize; for item in &mut items { + let applied = (|| -> Result<()> { let file=PathBuf::from(item["path"].as_str().unwrap_or("")); + publication::authorize_write(request, file.to_str().unwrap_or(""))?; + // Observation guards above remain owned until every publication settles. + let bytes = read_bytes(&file)?; + // 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("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); } } + Ok(()) })(); + if let Err(mut error) = applied { + if changed > 0 { error.effect_state = "applied".into(); error.next_action = "Earlier files were already replaced. Inspect postconditions; do not repeat the batch blindly.".into(); } + return Err(error); + } + changed += item["replacements"].as_u64().unwrap_or(0) as usize; } + let count = items.len(); + 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!([]) }; + Ok(output) +} +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, UNIX_EPOCH}; + + /// One private fixture directory under the canonical ambient temp root. + /// + /// The no-follow traversal refuses linked ancestry, and `TMPDIR` sits behind + /// one on Darwin, so the ambient root is resolved before anything is created — + /// the reason `single::tests::copy_changed_source_is_not_published` does the + /// same. + struct Fixture { root: PathBuf } + impl Fixture { + fn new(tag: &str) -> Self { + let root = std::fs::canonicalize(std::env::temp_dir()).unwrap().join(format!("dsh-ops-{tag}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&root).unwrap(); + Self { root } + } + /// Write one fixture file and stamp it with `modified_ms` since the epoch. + fn file(&self, name: &str, bytes: &[u8], modified_ms: u64) -> PathBuf { + let path = self.root.join(name); + std::fs::write(&path, bytes).unwrap(); + let stamp = UNIX_EPOCH + Duration::from_millis(modified_ms); + std::fs::OpenOptions::new().write(true).open(&path).unwrap() + .set_times(fs::FileTimes::new().set_accessed(stamp).set_modified(stamp)).unwrap(); + path + } + /// The sidecar `lock_and_read_for_update` creates for `name`. + fn sidecar(&self, name: &str) -> PathBuf { + self.root.join(format!(".dsh-ops-update-{:x}.lock", Sha256::digest(name.to_lowercase().as_bytes()))) + } + + } + impl Drop for Fixture { + fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.root); } + } + + /// #236: `modified` was `metadata.len()`, so every `details` item reported + /// `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"); + 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}"); + } + } + #[test] + fn equal_timestamps_keep_the_candidate_order() { + let fixture = Fixture::new("glob-modified-tie"); + 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 + /// sidecar before the target is read, so a refusal after the lock already + /// left it behind; it is one empty file per target name and is reused, never + /// accumulated, and a later successful write keeps using the same inode. + #[test] + fn the_update_lock_sidecar_is_created_once_and_reused() { + let fixture = Fixture::new("update-lock"); + let target = fixture.file("notes.txt", b"one", 1_600_000_000_000); + let sidecar = fixture.sidecar("notes.txt"); + assert!(!sidecar.exists(), "no sidecar before the first update attempt"); + drop(lock_and_read_for_update(&target).unwrap()); + assert!(sidecar.is_file(), "the lock is a persistent sibling, not the target"); + assert_eq!(fs::metadata(&sidecar).unwrap().len(), 0, "the sidecar carries no payload"); + drop(lock_and_read_for_update(&target).unwrap()); + // A refused read is the shape #240 reports: the sidecar is already there + // when the target itself cannot be read; a stale-revision write refuses the + // same way, after the same lock. + let missing = fixture.root.join("missing.txt"); + assert_eq!(lock_and_read_for_update(&missing).unwrap_err().code, "NOT_FOUND"); + assert!(fixture.sidecar("missing.txt").is_file(), "a refused operation still leaves the declared sidecar"); + assert_eq!(fs::read_dir(&fixture.root).unwrap().count(), 3, "one target and two sidecars, never more"); + } +} diff --git a/native/crates/ops-search/src/paging.rs b/native/crates/ops-search/src/paging.rs new file mode 100644 index 00000000..f0d243fe --- /dev/null +++ b/native/crates/ops-search/src/paging.rs @@ -0,0 +1,114 @@ +//! 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, capture: Option>, +} +impl Page { + 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 || 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 = 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); + 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 ops_protocol::capture::json_size(&item) + 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()); } + 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 new file mode 100644 index 00000000..87ee58bf --- /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, 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")); + 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..5792061d --- /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, 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)?; } + } + 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, 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| { + 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/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/src/file.rs b/native/src/file.rs deleted file mode 100644 index bcf5bae0..00000000 --- a/native/src/file.rs +++ /dev/null @@ -1,890 +0,0 @@ -//! 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; -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}; -#[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; - -#[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, -} -#[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 { - path: String, - pattern: String, - replacement: String, - #[serde(default)] - glob: Vec, - #[serde(default = "one_hundred")] - max_replacements: usize, - #[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('.')) -} -fn host_link_or_reparse(path: &Path) -> bool { - let Ok(metadata) = std::fs::symlink_metadata(path) else { return true }; - if metadata.file_type().is_symlink() { return true; } - #[cfg(windows)] - if metadata.file_attributes() & 0x400 != 0 { return true; } - false -} -struct WalkResult { - files: Vec, - truncated: bool, -} - -fn walk(root: &Path, request: &Request) -> Result { - request.authorize_path(root)?; - let target = crate::file_path::checked(root.to_str().ok_or_else(|| Failure::new("INVALID_ARGUMENT", "Non-UTF8 path"))?)?; - let directory = target.parent.open_dir_nofollow(&target.name)?; - let mut files = Vec::new(); - let mut truncated = false; - visit_dir(root, root, directory, 0, request, &mut files, &mut truncated)?; - Ok(WalkResult { files, truncated }) -} -fn visit_dir(root: &Path, display: &Path, directory: cap_std::fs::Dir, depth: usize, request: &Request, output: &mut Vec, truncated: &mut bool) -> Result<()> { - if depth > MAX_DEPTH || output.len() >= MAX_ENTRIES { - *truncated = true; - return Ok(()); - } - let mut names = Vec::new(); - // A directory can change while it is being read. A broken child directory - // is skipped, while failure to enumerate the requested root remains visible. - let entries = match directory.entries() { - Ok(entries) => entries, - Err(error) if depth == 0 => return Err(error.into()), - Err(_) => return Ok(()), - }; - for entry in entries { - let Ok(entry) = entry else { continue }; - let Ok(name) = entry.file_name().into_string() else { continue }; - names.push(name); - } - names.sort(); - for name in names { - if crate::file_path::validate_name(&name).is_err() || name == ".git" { continue; } - let metadata = match directory.symlink_metadata(&name) { - Ok(metadata) => metadata, - Err(_) => continue, - }; - if crate::file_path::link(&metadata) { continue; } - let child = display.join(&name); - if host_link_or_reparse(&child) { continue; } - // A denied root is skipped by identity, not by name: the host names the - // credential locations and the worker stays out of them entirely, so a - // traversal cannot even report a private key below one. - if request.denies_path(&child) { continue; } - if hidden(&child, root) || matches!(name.as_str(), ".git" | ".dsh-ops" | "node_modules" | "target" | ".venv") { continue; } - if metadata.is_file() { - if output.len() >= MAX_ENTRIES { *truncated = true; return Ok(()); } - output.push(child); - continue; - } - if metadata.is_dir() { - let Ok(child_dir) = directory.open_dir_nofollow(&name) else { continue }; - visit_dir(root, &child, child_dir, depth + 1, request, output, truncated)?; - if *truncated && output.len() >= MAX_ENTRIES { return Ok(()); } - } - } - Ok(()) -} -fn positive_patterns(patterns: &[String]) -> impl Iterator { patterns.iter().filter(|p| !p.starts_with('!')) } -fn matches_any(root: &Path, file: &Path, patterns: &[String]) -> bool { - let name = relative_display(root, file); - let positives: Vec<_> = positive_patterns(patterns).collect(); - let included = positives.is_empty() || positives.iter().any(|p| glob_match(p, &name)); - let excluded = patterns.iter().filter_map(|p| p.strip_prefix('!')).any(|p| glob_match(p, &name)); - included && !excluded -} -fn files_for(path: &str, patterns: &[String], request: &Request) -> Result { - let root = target(path, false)?; - request.authorize_path(&root)?; - if root.is_file() { - return Ok(WalkResult { files: vec![root.clone()], truncated: false }); - } - let walked = walk(&root, request)?; - Ok(WalkResult { - files: walked.files.into_iter() - .filter(|f| matches_any(&root, f, patterns)) - .collect(), - truncated: walked.truncated, - }) -} -fn glob_match(pattern: &str, value: &str) -> bool { - let p: Vec<_> = pattern.trim_start_matches("./").split('/').collect(); - let v: Vec<_> = value.split('/').collect(); - glob_segments(&p, &v) -} -fn glob_segments(p: &[&str], v: &[&str]) -> bool { - if p.is_empty() { return v.is_empty(); } - if p[0] == "**" { return glob_segments(&p[1..], v) || (!v.is_empty() && glob_segments(p, &v[1..])); } - !v.is_empty() && segment_match(p[0], v[0]) && glob_segments(&p[1..], &v[1..]) -} -fn segment_match(pattern: &str, value: &str) -> bool { - let (mut pi, mut vi, mut star, mut mark) = (0usize, 0usize, None, 0usize); - let p = pattern.as_bytes(); let v = value.as_bytes(); - while vi < v.len() { - if pi < p.len() && (p[pi] == b'?' || p[pi].eq_ignore_ascii_case(&v[vi])) { pi += 1; vi += 1; } - else if pi < p.len() && p[pi] == b'*' { star = Some(pi); pi += 1; mark = vi; } - else if let Some(s) = star { pi = s + 1; mark += 1; vi = mark; } - else { return false; } - } - 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. -/// -/// 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)}) -} -/// 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")); } - 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())?; - if args.pattern.is_empty() || args.max_replacements == 0 || args.max_replacements > 100_000 { return Err(Failure::new("INVALID_ARGUMENT", "pattern and maxReplacements are required")); } - let walked = files_for(&args.path, &args.glob, request)?; let mut plans = Vec::new(); let mut total = 0usize; let mut binary: Vec = Vec::new(); - // A directory that holds one binary file (an icon, an archive) is a normal repository, not a - // malformed request: the binary is reported as skipped and the text files around it are still - // edited. Failing the whole call on the first non-UTF-8 byte made a routine directory edit - // impossible, and the error named no file, so the caller could not even narrow the request. - let mut update_guards = Vec::new(); - for file in walked.files { - // Serialize observation as well as publication. On APFS an open racing - // an unlink/rename can fail ENOENT on the superseded vnode even though - // the directory name always has a complete version. Do not retry or - // relabel ENOENT: avoid the race between cooperating writers instead. - let (guard, bytes) = if args.dry_run { (None, read_bytes(&file)?) } - else { let (guard, bytes) = lock_and_read_for_update(&file)?; (Some(guard), bytes) }; - match String::from_utf8(bytes.clone()) { - Ok(text) => { - let count = text.match_indices(&args.pattern).count(); - if count > 0 { total += count; plans.push((file, bytes, text, count)); update_guards.push(guard); } - } - Err(_) => binary.push(file), - } - } - let skipped = binary.len(); - let skipped_paths: Vec = binary.iter().take(8).map(|file| json!(relative_display(&request.root_path(), file))).collect(); - if total > args.max_replacements { return Err(Failure::new("OUTPUT_LIMIT", format!("{total} matches exceed maxReplacements ({}); no file was changed. maxReplacements is a ceiling, not a truncation: raise it to {total} or more and retry.", args.max_replacements))); } - let mut items=Vec::new(); for (file, original, text, count) in plans { let revision=format!("{:x}", Sha256::digest(&original)); let next=text.replace(&args.pattern,&args.replacement); items.push(json!({"path":file,"replacements":count,"revision":revision,"revisionAfter":format!("{:x}", Sha256::digest(next.as_bytes())),"bytesBefore":original.len(),"bytesAfter":next.len(),"lineCountBefore":text.lines().count(),"lineCount":next.lines().count()})); } - if serde_json::to_vec(&items)?.len() > MAX_OUTPUT_BYTES / 2 { return Err(Failure::new("OUTPUT_LIMIT", "Replacement receipts exceed the bounded output budget; narrow path/glob. No files changed.")); } - if args.dry_run { return Ok(json!({"status":"completed","phase":"observe","changed":0,"matched":total,"skipped":skipped,"failed":0,"complete":true,"omitted":0,"dryRun":true,"items":items,"skippedPaths":skipped_paths})); } - for item in &items { publication::authorize_write(request, item["path"].as_str().unwrap_or(""))?; } - let mut changed=0usize; for item in &mut items { - let applied = (|| -> Result<()> { let file=PathBuf::from(item["path"].as_str().unwrap_or("")); - publication::authorize_write(request, file.to_str().unwrap_or(""))?; - // Observation guards above remain owned until every publication settles. - let bytes = read_bytes(&file)?; - // 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); - // 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); } } - Ok(()) })(); - if let Err(mut error) = applied { - if changed > 0 { error.effect_state = "applied".into(); error.next_action = "Earlier files were already replaced. Inspect postconditions; do not repeat the batch blindly.".into(); } - return Err(error); - } - 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); - 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!([]) }; - 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::*; - use std::time::{Duration, UNIX_EPOCH}; - - /// One private fixture directory under the canonical ambient temp root. - /// - /// The no-follow traversal refuses linked ancestry, and `TMPDIR` sits behind - /// one on Darwin, so the ambient root is resolved before anything is created — - /// the reason `single::tests::copy_changed_source_is_not_published` does the - /// same. - struct Fixture { root: PathBuf } - impl Fixture { - fn new(tag: &str) -> Self { - let root = std::fs::canonicalize(std::env::temp_dir()).unwrap().join(format!("dsh-ops-{tag}-{}", uuid::Uuid::new_v4())); - std::fs::create_dir(&root).unwrap(); - Self { root } - } - /// Write one fixture file and stamp it with `modified_ms` since the epoch. - fn file(&self, name: &str, bytes: &[u8], modified_ms: u64) -> PathBuf { - let path = self.root.join(name); - std::fs::write(&path, bytes).unwrap(); - let stamp = UNIX_EPOCH + Duration::from_millis(modified_ms); - std::fs::OpenOptions::new().write(true).open(&path).unwrap() - .set_times(fs::FileTimes::new().set_accessed(stamp).set_modified(stamp)).unwrap(); - path - } - /// The sidecar `lock_and_read_for_update` creates for `name`. - 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); } - } - - /// #236: `modified` was `metadata.len()`, so every `details` item reported - /// `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. - #[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()); - } - 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"); - } - - /// #240 P5, declared behavior: the update lock creates the persistent hidden - /// sidecar before the target is read, so a refusal after the lock already - /// left it behind; it is one empty file per target name and is reused, never - /// accumulated, and a later successful write keeps using the same inode. - #[test] - fn the_update_lock_sidecar_is_created_once_and_reused() { - let fixture = Fixture::new("update-lock"); - let target = fixture.file("notes.txt", b"one", 1_600_000_000_000); - let sidecar = fixture.sidecar("notes.txt"); - assert!(!sidecar.exists(), "no sidecar before the first update attempt"); - drop(lock_and_read_for_update(&target).unwrap()); - assert!(sidecar.is_file(), "the lock is a persistent sibling, not the target"); - assert_eq!(fs::metadata(&sidecar).unwrap().len(), 0, "the sidecar carries no payload"); - drop(lock_and_read_for_update(&target).unwrap()); - // A refused read is the shape #240 reports: the sidecar is already there - // when the target itself cannot be read; a stale-revision write refuses the - // same way, after the same lock. - let missing = fixture.root.join("missing.txt"); - assert_eq!(lock_and_read_for_update(&missing).unwrap_err().code, "NOT_FOUND"); - assert!(fixture.sidecar("missing.txt").is_file(), "a refused operation still leaves the declared sidecar"); - assert_eq!(fs::read_dir(&fixture.root).unwrap().count(), 3, "one target and two sidecars, never more"); - } -} diff --git a/native/src/lib.rs b/native/src/lib.rs index 78a23ba7..7cf1e3a2 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 @@ -67,17 +22,33 @@ 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 { + 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. - 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); + 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") { + return if matches!(request.tool.as_str(), "glob" | "grep" | "replace") { + ops_search::dispatch(&request) + } else { + ops_files::dispatch(&request) + }; + } + 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); } - 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.read_only && !(request.tool == "result" && request.args.get("action").and_then(Value::as_str) == Some("page")) @@ -87,10 +58,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/native/src/main.rs b/native/src/main.rs index 5f3f3e5d..20c0ffbd 100644 --- a/native/src/main.rs +++ b/native/src/main.rs @@ -1,35 +1,37 @@ #![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" | "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() { 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 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. + 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/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/native/windows-metadata/src/lib.rs b/native/windows-metadata/src/lib.rs index 1e75aeb2..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 { @@ -363,6 +391,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 +429,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(), diff --git a/package-lock.json b/package-lock.json index c4694cb9..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" @@ -22,27 +22,52 @@ }, "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-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-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", + "@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", + "@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", + "@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/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", + "@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" }, "engines": { "node": "^22.19 || >=24" }, - "optionalDependencies": { - "@dsh-ops/bash-win32-x64": "0.2.1", - "@dsh-ops/pwsh-win32-x64": "0.2.1" - }, "peerDependencies": { "@deepseek-ai/cordis": "^4.0.4", "@deepseek-ai/dsh": "^0.2.0-rc.2" @@ -56,6 +81,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", @@ -82,6 +238,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", @@ -114,91 +319,147 @@ } } }, - "node_modules/@deepseek-ai/dsh-attachment": { + "node_modules/@deepseek-ai/dsh-agent-loop": { "version": "0.2.0-rc.2", - "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-attachment/-/dsh-attachment-0.2.0-rc.2.tgz", - "integrity": "sha512-asUDLv+mfb30vjjlQtMI9aBnC+kmPoUxS1ooadhwAd8wssIlLetq97nzC5W1qd0GQGwZtPG/gpljVAMcSwPr1g==", + "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", - "peer": true, + "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-brand": "0.2.0-rc.2" + "@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-brand": { + "node_modules/@deepseek-ai/dsh-api-gateway": { "version": "0.2.0-rc.2", - "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-brand/-/dsh-brand-0.2.0-rc.2.tgz", - "integrity": "sha512-A5XlN4tIgP0qObkYFhcZ7Oz/tM2oESEZqLwS0Qq+sM1ivGx6o4AMPgJdfKQlwLzqk0cMLNtAaVBHfiYoElt4lw==", + "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/cordis": "~4.0.4", + "@deepseek-ai/dsh-client-connection": "0.2.0-rc.2" } }, - "node_modules/@deepseek-ai/dsh-fs": { + "node_modules/@deepseek-ai/dsh-api-remotes": { "version": "0.2.0-rc.2", - "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs/-/dsh-fs-0.2.0-rc.2.tgz", - "integrity": "sha512-PuVcI7drTwa19Key54DROoqnDFTTFkl8TPlyrTnS3BSnykm+jKbB13ES/PQEJoRunNdQkNZa12jtbUI4BP/EHw==", + "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", - "peer": true, + "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-brand": "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-sandbox": "0.2.0-rc.2" + "@deepseek-ai/dsh-scope": "0.2.0-rc.2" } }, - "node_modules/@deepseek-ai/dsh-fs-local": { + "node_modules/@deepseek-ai/dsh-app-boot": { "version": "0.2.0-rc.2", - "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs-local/-/dsh-fs-local-0.2.0-rc.2.tgz", - "integrity": "sha512-lDzh5VlQsXf3nI+E0+T0weYdH1VAoo+htj6Kwc62P7/Lx+xjnYSLQYTeGeaJR1d8H2iQv2FvlwaDKv60AorKdA==", + "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/schemastery": "~3.18.4", - "chokidar": "^4.0.3", - "koffi": "3.1.1" + "@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/dsh-fs": "0.2.0-rc.2" + "@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-fs-observation-policy": { + "node_modules/@deepseek-ai/dsh-atomic-write": { "version": "0.2.0-rc.2", - "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs-observation-policy/-/dsh-fs-observation-policy-0.2.0-rc.2.tgz", - "integrity": "sha512-YPqpJ+WPhYUHS6PtUN4abjUx/dK6bexUiQdFGeQ81F0CEWbIfxyT1QzadKPvU5PrjxpG5e+UtjoEmAQKSCrsiA==", + "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", + "integrity": "sha512-asUDLv+mfb30vjjlQtMI9aBnC+kmPoUxS1ooadhwAd8wssIlLetq97nzC5W1qd0GQGwZtPG/gpljVAMcSwPr1g==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.4", - "@deepseek-ai/dsh-fs": "0.2.0-rc.2" + "@deepseek-ai/dsh-brand": "0.2.0-rc.2" } }, - "node_modules/@deepseek-ai/dsh-http-proxy": { + "node_modules/@deepseek-ai/dsh-brand": { "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", - "integrity": "sha512-zIGscCHeW6MUIKrksxhW03McWtKKpaDVVMf877bt51ZPP8p4UG8KmBUqJmX2l3eVoHeuuEyqM5zJSCj5NtSBGg==", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-brand/-/dsh-brand-0.2.0-rc.2.tgz", + "integrity": "sha512-A5XlN4tIgP0qObkYFhcZ7Oz/tM2oESEZqLwS0Qq+sM1ivGx6o4AMPgJdfKQlwLzqk0cMLNtAaVBHfiYoElt4lw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@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": { - "undici": "^8.10.0" + "@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/cordis": "~4.0.4", + "@deepseek-ai/dsh-scope": "0.2.0-rc.2" } }, - "node_modules/@deepseek-ai/dsh-invariants": { + "node_modules/@deepseek-ai/dsh-client-locale": { "version": "0.2.0-rc.2", - "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-invariants/-/dsh-invariants-0.2.0-rc.2.tgz", - "integrity": "sha512-QOVc0HpRsS8ekvprb45FlxieDvgR/cAMaxW289HASjMoV/U/B54FpbkHuPBw4EnTw07/sPkjx2B/23+YqE0cbg==", + "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", - "peer": true, "dependencies": { "@deepseek-ai/schemastery": "~3.18.4" }, @@ -206,29 +467,35 @@ "@deepseek-ai/cordis": "~4.0.4" } }, - "node_modules/@deepseek-ai/dsh-lazy-require": { + "node_modules/@deepseek-ai/dsh-client-modules": { "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", - "integrity": "sha512-t6m+13fe+4veaepjGwNu0BLTtqdrSPq+A/zmOzC+7DXgWCBHJ3IxfW2vUlLviKfviwMI4BqQMPv4wHeuJgYFIw==", + "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-llm": { + "node_modules/@deepseek-ai/dsh-client-store": { "version": "0.2.0-rc.2", - "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm/-/dsh-llm-0.2.0-rc.2.tgz", - "integrity": "sha512-6QFrQn/h0iNqCfPpgQidnHtLSpA99A7ZND/uYKaGSd/4BGP8KBhuRd0w63e9s4u+hfW8jc3j29WKcNLIOeUfcA==", + "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", - "peer": true, "dependencies": { - "@deepseek-ai/dsh-brand": "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/dsh-util-crypto": "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" }, @@ -236,94 +503,307 @@ "@deepseek-ai/cordis": "~4.0.4" } }, - "node_modules/@deepseek-ai/dsh-output-retention": { + "node_modules/@deepseek-ai/dsh-client-ui-renderer": { "version": "0.2.0-rc.2", - "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-output-retention/-/dsh-output-retention-0.2.0-rc.2.tgz", - "integrity": "sha512-+yVL95Om473rZTHIyvjZ6JUyDl2orRpyjXeMSqOzJqh66/sERUfPgCtwMbnH9tzhxTF6nw6cC30I9dxgHEz13w==", + "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", - "peer": true, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.4" } }, - "node_modules/@deepseek-ai/dsh-ptc-runtime": { + "node_modules/@deepseek-ai/dsh-client-ui-settings": { "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", - "integrity": "sha512-HR/3h1VFRcNlAAjCtCAxZ4V3F5pViYicXcLBuYLGjzXWpOhr4zKzeFKh9+UMLjsc9XWIarJMoSvxl662nDV/xQ==", + "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", - "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "~3.18.4" + }, "peerDependencies": { - "@deepseek-ai/cordis": "~4.0.4", - "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2" + "@deepseek-ai/cordis": "~4.0.4" } }, - "node_modules/@deepseek-ai/dsh-ptc-runtime-node": { + "node_modules/@deepseek-ai/dsh-client-ui-slots": { "version": "0.2.0-rc.2", - "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-ptc-runtime-node/-/dsh-ptc-runtime-node-0.2.0-rc.2.tgz", - "integrity": "sha512-MYGTCOBFYuD8568j0hm4ChOTX/cBOUk0H1s+xuioRy9NAjjpTz+ybNZUJTYP3+noN5nzinDZfeiRdHqVoFh6YQ==", + "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", - "dependencies": { - "@deepseek-ai/dsh-util-values": "0.2.0-rc.2", - "@deepseek-ai/schemastery": "~3.18.4" - }, "peerDependencies": { - "@deepseek-ai/cordis": "~4.0.4", - "@deepseek-ai/dsh-fs": "0.2.0-rc.2", - "@deepseek-ai/dsh-ptc-runtime": "0.2.0-rc.2", - "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2", - "@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.2", - "@deepseek-ai/dsh-session": "0.2.0-rc.2", - "@deepseek-ai/dsh-subprocess": "0.2.0-rc.2", - "@deepseek-ai/dsh-timeout": "0.2.0-rc.2" + "@deepseek-ai/cordis": "~4.0.4" } }, - "node_modules/@deepseek-ai/dsh-sandbox": { + "node_modules/@deepseek-ai/dsh-config-editor": { "version": "0.2.0-rc.2", - "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox/-/dsh-sandbox-0.2.0-rc.2.tgz", - "integrity": "sha512-luKF7ey6HRZkGhImqLCB5Npt4f4omDRk1r+x5qQNqqdNOuN9LXciKDMNPfnSbHfKCcqSZbH3sK+APOmKzRAhVA==", + "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", - "peer": true, "dependencies": { - "@deepseek-ai/dsh-util-values": "0.2.0-rc.2" + "@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/dsh-llm": "0.2.0-rc.2", - "@deepseek-ai/dsh-session": "0.2.0-rc.2" + "@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-sandbox-local": { + "node_modules/@deepseek-ai/dsh-credentials": { "version": "0.2.0-rc.2", - "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox-local/-/dsh-sandbox-local-0.2.0-rc.2.tgz", - "integrity": "sha512-fM1QtkRxeQ498isBeotl0lW+oK/GS0G63cBNvwJJn6686Qzdl+gqwTDPS61L8pFzxRnqhqcXFxzu2tIV3o2zbg==", + "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-sandbox-windows-acl": "0.2.0-rc.2", - "@deepseek-ai/dsh-util-values": "0.2.0-rc.2", - "@deepseek-ai/node-addon-system": "~0.1.2", - "@deepseek-ai/schemastery": "~3.18.4" + "@deepseek-ai/dsh-brand": "0.2.0-rc.2" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.4", - "@deepseek-ai/dsh-llm": "0.2.0-rc.2", - "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2", - "@deepseek-ai/dsh-session": "0.2.0-rc.2" + "@deepseek-ai/dsh-invariants": "0.2.0-rc.2" } }, - "node_modules/@deepseek-ai/dsh-sandbox-policy": { + "node_modules/@deepseek-ai/dsh-deque": { "version": "0.2.0-rc.2", - "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox-policy/-/dsh-sandbox-policy-0.2.0-rc.2.tgz", - "integrity": "sha512-0xXVqWw908/V6scohMyyU9SZSiQJYji+S1hMPulcUNLb3iew89d+WPWMIkWQBJRvcJzdQ2ghHwCESRkONXTJnQ==", + "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", - "peer": true, - "dependencies": { - "@deepseek-ai/schemastery": "~3.18.4", + "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", + "integrity": "sha512-PuVcI7drTwa19Key54DROoqnDFTTFkl8TPlyrTnS3BSnykm+jKbB13ES/PQEJoRunNdQkNZa12jtbUI4BP/EHw==", + "dev": true, + "license": "MIT", + "peer": true, + "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-llm": "0.2.0-rc.2", + "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-fs-local": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs-local/-/dsh-fs-local-0.2.0-rc.2.tgz", + "integrity": "sha512-lDzh5VlQsXf3nI+E0+T0weYdH1VAoo+htj6Kwc62P7/Lx+xjnYSLQYTeGeaJR1d8H2iQv2FvlwaDKv60AorKdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "~3.18.4", + "chokidar": "^4.0.3", + "koffi": "3.1.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-fs": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-fs-observation-policy": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs-observation-policy/-/dsh-fs-observation-policy-0.2.0-rc.2.tgz", + "integrity": "sha512-YPqpJ+WPhYUHS6PtUN4abjUx/dK6bexUiQdFGeQ81F0CEWbIfxyT1QzadKPvU5PrjxpG5e+UtjoEmAQKSCrsiA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@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", + "integrity": "sha512-zIGscCHeW6MUIKrksxhW03McWtKKpaDVVMf877bt51ZPP8p4UG8KmBUqJmX2l3eVoHeuuEyqM5zJSCj5NtSBGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici": "^8.10.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-invariants": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-invariants/-/dsh-invariants-0.2.0-rc.2.tgz", + "integrity": "sha512-QOVc0HpRsS8ekvprb45FlxieDvgR/cAMaxW289HASjMoV/U/B54FpbkHuPBw4EnTw07/sPkjx2B/23+YqE0cbg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "~3.18.4" + }, + "peerDependencies": { + "@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", + "integrity": "sha512-t6m+13fe+4veaepjGwNu0BLTtqdrSPq+A/zmOzC+7DXgWCBHJ3IxfW2vUlLviKfviwMI4BqQMPv4wHeuJgYFIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-llm": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm/-/dsh-llm-0.2.0-rc.2.tgz", + "integrity": "sha512-6QFrQn/h0iNqCfPpgQidnHtLSpA99A7ZND/uYKaGSd/4BGP8KBhuRd0w63e9s4u+hfW8jc3j29WKcNLIOeUfcA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/dsh-brand": "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/dsh-util-crypto": "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" + } + }, + "node_modules/@deepseek-ai/dsh-output-retention": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-output-retention/-/dsh-output-retention-0.2.0-rc.2.tgz", + "integrity": "sha512-+yVL95Om473rZTHIyvjZ6JUyDl2orRpyjXeMSqOzJqh66/sERUfPgCtwMbnH9tzhxTF6nw6cC30I9dxgHEz13w==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@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", + "integrity": "sha512-HR/3h1VFRcNlAAjCtCAxZ4V3F5pViYicXcLBuYLGjzXWpOhr4zKzeFKh9+UMLjsc9XWIarJMoSvxl662nDV/xQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-ptc-runtime-node": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-ptc-runtime-node/-/dsh-ptc-runtime-node-0.2.0-rc.2.tgz", + "integrity": "sha512-MYGTCOBFYuD8568j0hm4ChOTX/cBOUk0H1s+xuioRy9NAjjpTz+ybNZUJTYP3+noN5nzinDZfeiRdHqVoFh6YQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-util-values": "0.2.0-rc.2", + "@deepseek-ai/schemastery": "~3.18.4" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-fs": "0.2.0-rc.2", + "@deepseek-ai/dsh-ptc-runtime": "0.2.0-rc.2", + "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2", + "@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2", + "@deepseek-ai/dsh-subprocess": "0.2.0-rc.2", + "@deepseek-ai/dsh-timeout": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-sandbox": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox/-/dsh-sandbox-0.2.0-rc.2.tgz", + "integrity": "sha512-luKF7ey6HRZkGhImqLCB5Npt4f4omDRk1r+x5qQNqqdNOuN9LXciKDMNPfnSbHfKCcqSZbH3sK+APOmKzRAhVA==", + "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-llm": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-sandbox-local": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox-local/-/dsh-sandbox-local-0.2.0-rc.2.tgz", + "integrity": "sha512-fM1QtkRxeQ498isBeotl0lW+oK/GS0G63cBNvwJJn6686Qzdl+gqwTDPS61L8pFzxRnqhqcXFxzu2tIV3o2zbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-sandbox-windows-acl": "0.2.0-rc.2", + "@deepseek-ai/dsh-util-values": "0.2.0-rc.2", + "@deepseek-ai/node-addon-system": "~0.1.2", + "@deepseek-ai/schemastery": "~3.18.4" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-llm": "0.2.0-rc.2", + "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-sandbox-policy": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox-policy/-/dsh-sandbox-policy-0.2.0-rc.2.tgz", + "integrity": "sha512-0xXVqWw908/V6scohMyyU9SZSiQJYji+S1hMPulcUNLb3iew89d+WPWMIkWQBJRvcJzdQ2ghHwCESRkONXTJnQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "~3.18.4", "zod": "^4.4.3" }, "peerDependencies": { @@ -382,6 +862,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", @@ -397,6 +894,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", @@ -570,6 +1088,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", @@ -731,32 +1262,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", - "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", @@ -780,6 +1285,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", @@ -1042,6 +1557,44 @@ "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/@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", @@ -1231,6 +1784,61 @@ "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", + "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", + "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", @@ -1247,6 +1855,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", @@ -1257,6 +1925,191 @@ "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", + "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/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", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "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", + "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/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", + "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", @@ -1285,6 +2138,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", @@ -1292,6 +2172,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", @@ -1303,6 +2397,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", @@ -1317,6 +2469,143 @@ "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/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", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "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", + "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", @@ -1328,6 +2617,113 @@ "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/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", + "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/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", @@ -1354,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 9a405086..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" }, @@ -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", @@ -59,6 +59,9 @@ "./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": { @@ -67,14 +70,24 @@ "./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/package.json": "./lib/components/discovery/package.json", + "./discovery/locale/*.json": "./lib/components/discovery/locale/*.json", "./hide-official": { "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" @@ -82,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": [ @@ -104,11 +126,17 @@ "native/Cargo.toml", "native/Cargo.lock", "native/src", + "native/crates", "native/windows-metadata", "native/darwin-metadata", "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 +146,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)\" --", @@ -138,24 +166,49 @@ "optional": true } }, - "optionalDependencies": { - "@dsh-ops/bash-win32-x64": "0.2.1", - "@dsh-ops/pwsh-win32-x64": "0.2.1" - }, "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-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-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", + "@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", + "@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", + "@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/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", + "@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" }, "engines": { 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 35cd41d1..253405ae 100644 --- a/scripts/build-platform-packages.mjs +++ b/scripts/build-platform-packages.mjs @@ -1,58 +1,13 @@ #!/usr/bin/env node /** - * Build and verify the two platform packages this plugin publishes. - * - * 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, - * 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 two package directories, 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 - * 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' @@ -65,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 two packages one release publishes, in publish order. */ -const KINDS = Object.freeze(['bash', 'pwsh']) // --------------------------------------------------------------------------- // Step: fetch and verify-upstream diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 00000000..7bad995f --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,72 @@ +#!/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' +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') +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 })) { + 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 }) + // 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, + }) + 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 { + bundleClient(root) + 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)`) + } + buildClientProfile(root) + 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/lib/build/assemble.js b/scripts/lib/build/assemble.js index 129bad75..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 two package directories are 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,30 +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: '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), - 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/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/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/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/scripts/lib/build/publish-dir.js b/scripts/lib/build/publish-dir.js index 30c66bee..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(' (both platform packages carry ready-to-run Windows binaries)\n') + console.log(' (shell binary payloads are not npm dependencies)\n') } /** 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/tools-trial.mjs b/scripts/tools-trial.mjs index 54098422..688ed7cd 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 = [] @@ -66,14 +66,21 @@ 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]}`) } - 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 +111,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') @@ -114,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/scripts/validate-manifest.mjs b/scripts/validate-manifest.mjs index c8096810..afcb2502 100644 --- a/scripts/validate-manifest.mjs +++ b/scripts/validate-manifest.mjs @@ -20,18 +20,19 @@ 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' -import { PTC_TOOLS, definition } from '../lib/ptc/contract.js' -import { resolveConfig } from '../lib/config.js' +import { PTC_API_TOOLS, definition } from '../lib/ptc/contract.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 @@ -256,10 +257,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( @@ -485,12 +486,12 @@ 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"]', - '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/scripts/verify-gate.mjs b/scripts/verify-gate.mjs index f2c4d3ec..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 } @@ -179,10 +179,23 @@ async function main() { process.stdout.write('\n=== install locked dependencies\n(skipped: node_modules is present; pass --skip-install to make this explicit)\n') } - await run('native worker unit tests', 'cargo', ['test', '--locked', '--manifest-path', 'native/Cargo.toml']) + // 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', '--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) @@ -194,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/lib/authority-paths.js b/src/authority-paths.ts similarity index 69% rename from lib/authority-paths.js rename to src/authority-paths.ts index 26c2ef38..2bea8c0f 100644 --- a/lib/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/lib/bash-ipc.js b/src/bash-ipc.ts similarity index 80% rename from lib/bash-ipc.js rename to src/bash-ipc.ts index 8988012f..315b591b 100644 --- a/lib/bash-ipc.js +++ b/src/bash-ipc.ts @@ -6,34 +6,39 @@ 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. ' +'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}={}) { +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,21 +113,23 @@ 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) return state } -export function confinedBash(file, {home=process.env.DSH_HOME ?? path.join(os.homedir(),'.dsh'), platform=process.platform, source}={}) { +/** @param {string} file + * @param {{home?: string, platform?: NodeJS.Platform, source?: string}} options */ +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}) @@ -131,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. * @@ -155,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 @@ -165,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 @@ -178,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 @@ -199,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/lib/bash-pe.js b/src/bash-pe.ts similarity index 76% rename from lib/bash-pe.js rename to src/bash-pe.ts index 66d34b37..4bbc99a9 100644 --- a/lib/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/capabilities/discovery.ts b/src/capabilities/discovery.ts new file mode 100644 index 00000000..ed4b8b24 --- /dev/null +++ b/src/capabilities/discovery.ts @@ -0,0 +1,37 @@ +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({ + 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', + 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', + 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: '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 = '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' } }, + alreadyActive: { type: 'array', items: { type: 'string' } }, message: { type: 'string' }, + error: { type: 'object' }, + }, +} diff --git a/src/capabilities/documents.ts b/src/capabilities/documents.ts new file mode 100644 index 00000000..0fe3706f --- /dev/null +++ b/src/capabilities/documents.ts @@ -0,0 +1,60 @@ +/** 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({ + 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.' }, + 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.' }, + 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({ + 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, 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, + 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 } } +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']) }, + error: { type: 'object' }, nextAction: string, reference: STORAGE_REFERENCE, referenceRoot: string, + 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']) }, +} } +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 === 'pdf_read' ? PDF_OUTPUT : tool === 'doc_read' ? DOCX_OUTPUT : tool === 'table_read' ? TABLE_OUTPUT : undefined } diff --git a/src/capabilities/file.ts b/src/capabilities/file.ts new file mode 100644 index 00000000..66520864 --- /dev/null +++ b/src/capabilities/file.ts @@ -0,0 +1,135 @@ +/** 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' + +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 }) + +// 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: 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({ + ...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']), + 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.' }, + limit: range(1, 10000, 'Default 200.'), + encoding: { type: 'string', enum: ['utf-8'] }, + view: { type: 'string', enum: ['text', 'hex'] }, + }), + glob: bounded({ + path: string, + pattern: { type: 'array', description: 'At least one positive glob is required.', items: string }, + outputMode: { type: 'string', enum: ['paths', 'details'] }, + 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, + pattern: string, + glob: { type: 'array', items: string }, + outputMode: { type: 'string', enum: ['files_with_matches', 'content', 'count', 'summary'] }, + caseInsensitive: { type: 'boolean' }, + lineNumbers: { type: 'boolean' }, + beforeContext: range(0, 32), + afterContext: range(0, 32), + 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']), + write_file: bounded({ path: string, content: string, expectedRevision: revision, dryRun: { type: 'boolean' } }, ['path', 'content', 'expectedRevision']), + 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']), + replace: bounded({ + path: string, + pattern: string, + replacement: string, + glob: { type: 'array', items: string }, + maxReplacements: range(1, 100000, 'Default 100.'), + dryRun: { type: 'boolean' }, + }, ['pattern', 'replacement']), +}) + +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' }, + complete: { type: 'boolean' }, omitted: { type: 'integer' }, total: { type: 'integer' }, + nextOffset: { type: 'integer' }, truncated: { type: 'boolean' }, items: { type: 'array', items: {} }, + dryRun: { type: 'boolean' }, files: { type: 'integer' }, matches: { type: 'integer' }, + skippedFiles: { type: 'integer' }, + error: { type: 'object' }, + }, +} + +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' }, + }, +} + +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, + }, +} + +/** 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 +} + +// 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({ + ...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, + 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, + 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, + 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, +}) diff --git a/lib/ptc/contract.js b/src/capabilities/ptc.ts similarity index 50% rename from lib/ptc/contract.js rename to src/capabilities/ptc.ts index b002ff1d..8dbfac9d 100644 --- a/lib/ptc/contract.js +++ b/src/capabilities/ptc.ts @@ -1,10 +1,11 @@ -/** 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 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' } +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, @@ -15,22 +16,19 @@ 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. -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, 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,12 +38,5 @@ export const OUTPUT = { } 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, - } + 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 new file mode 100644 index 00000000..38bb06d8 --- /dev/null +++ b/src/capabilities/registry.ts @@ -0,0 +1,53 @@ +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' +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' + +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', '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) + +export const FILE_CAPABILITIES = Object.freeze(fileOrder.map(rawName => Object.freeze({ + rawName, component: 'file' as const, presentations: allPresentations, + 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, 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))) +// 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 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])) + +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 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/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/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/capabilities/types.ts b/src/capabilities/types.ts new file mode 100644 index 00000000..6a466c41 --- /dev/null +++ b/src/capabilities/types.ts @@ -0,0 +1,25 @@ +import type { JsonSchemaNode, ObjectJsonSchema, ToolPresentationMode } from '@deepseek-ai/dsh-tools' + +export type Component = 'file' | 'ptc' | 'shell' | 'discovery' +export type ParameterSchema = ObjectJsonSchema & Record & { + properties: Record + required: string[] +} + +/** 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' | 'discoverable' + /** 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 + readonly nativeProjection?: { readonly component: 'file'; readonly availability?: 'resident' | 'discoverable'; readonly parameters: ParameterSchema; readonly description: string } +} 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/lib/cli/provision-shells.js b/src/cli/provision-shells.ts similarity index 89% rename from lib/cli/provision-shells.js rename to src/cli/provision-shells.ts index 28179556..6bce4244 100644 --- a/lib/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/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/lib/cli/report.js b/src/cli/report.ts similarity index 85% rename from lib/cli/report.js rename to src/cli/report.ts index 83e00dfa..170a169a 100644 --- a/lib/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'}`) @@ -197,7 +205,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;') @@ -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/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/lib/cli/uninstall.js b/src/cli/uninstall.ts similarity index 93% rename from lib/cli/uninstall.js rename to src/cli/uninstall.ts index 70b40a50..7b1f26c6 100644 --- a/lib/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/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/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.ts similarity index 84% rename from lib/components/background/index.js rename to src/components/background/index.ts index bf984f99..c8707d73 100644 --- a/lib/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/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/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/lib/components/file/icon.png b/src/components/discovery/icon.png similarity index 100% rename from lib/components/file/icon.png rename to src/components/discovery/icon.png diff --git a/src/components/discovery/index.ts b/src/components/discovery/index.ts new file mode 100644 index 00000000..03622670 --- /dev/null +++ b/src/components/discovery/index.ts @@ -0,0 +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/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/lib/components/hide-official/icon.png b/src/components/file/icon.png similarity index 100% rename from lib/components/hide-official/icon.png rename to src/components/file/icon.png 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/file/locale/en.json b/src/components/file/locale/en.json new file mode 100644 index 00000000..890cd27f --- /dev/null +++ b/src/components/file/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "dsh-ops-file", + "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 new file mode 100644 index 00000000..282b82ac --- /dev/null +++ b/src/components/file/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "dsh-ops-file", + "description": "有界原生多文件读取;单文件/hex 及高级操作通过发现按需加载。" + } +} diff --git a/src/components/file/package.json b/src/components/file/package.json new file mode 100644 index 00000000..26f1512d --- /dev/null +++ b/src/components/file/package.json @@ -0,0 +1,6 @@ +{ + "name": "dsh-ops-file", + "description": "Bounded Native multi-file reading; single-file/hex and advanced operations load through discovery.", + "icon": "./icon.png", + "type": "module" +} diff --git a/lib/components/panel/icon.png b/src/components/hide-official/icon.png similarity index 100% rename from lib/components/panel/icon.png rename to src/components/hide-official/icon.png diff --git a/src/components/hide-official/index.ts b/src/components/hide-official/index.ts new file mode 100644 index 00000000..8b0e1ac8 --- /dev/null +++ b/src/components/hide-official/index.ts @@ -0,0 +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 } +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/hide-official/locale/en.json b/src/components/hide-official/locale/en.json new file mode 100644 index 00000000..44722800 --- /dev/null +++ b/src/components/hide-official/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "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 new file mode 100644 index 00000000..8842cd82 --- /dev/null +++ b/src/components/hide-official/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "逐项替换官方工具", + "description": "逐项选择 read、glob 或 grep,改用经评审的 OPS 替代流程。默认不选;未选工具保持可见。所选替代工具不可用时,仅阻止受影响的作用域。" + } +} diff --git a/src/components/hide-official/package.json b/src/components/hide-official/package.json new file mode 100644 index 00000000..c802cfcf --- /dev/null +++ b/src/components/hide-official/package.json @@ -0,0 +1,6 @@ +{ + "name": "dsh-ops-hide-official", + "description": "Opt-in reviewed read/glob/grep alternatives with per-tool selection and scoped readiness checks.", + "icon": "./icon.png", + "type": "module" +} diff --git a/lib/components/ptc/icon.png b/src/components/panel/icon.png similarity index 100% rename from lib/components/ptc/icon.png rename to src/components/panel/icon.png diff --git a/src/components/panel/index.ts b/src/components/panel/index.ts new file mode 100644 index 00000000..6745a4bb --- /dev/null +++ b/src/components/panel/index.ts @@ -0,0 +1,35 @@ +/** 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 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).'), +}) +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) { + 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), + 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/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 91% rename from lib/components/panel/package.json rename to src/components/panel/package.json index 5715c9fd..d50fce0c 100644 --- a/lib/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/lib/components/shell/icon.png b/src/components/ptc/icon.png similarity index 100% rename from lib/components/shell/icon.png rename to src/components/ptc/icon.png 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/ptc/locale/en.json b/src/components/ptc/locale/en.json new file mode 100644 index 00000000..0db120a2 --- /dev/null +++ b/src/components/ptc/locale/en.json @@ -0,0 +1 @@ +{"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 new file mode 100644 index 00000000..29ad0180 --- /dev/null +++ b/src/components/ptc/locale/zh.json @@ -0,0 +1 @@ +{"meta":{"title":"PTC 增强","description":"PTC 会话独立提供类型化文件 API 和结果引用;高级 SDK 在下一个模型步骤加载。"}} diff --git a/src/components/ptc/package.json b/src/components/ptc/package.json new file mode 100644 index 00000000..9986d2b0 --- /dev/null +++ b/src/components/ptc/package.json @@ -0,0 +1,6 @@ +{ + "name": "dsh-ops-ptc", + "description": "Independent typed PTC file APIs, progressive SDK loading and bounded result references.", + "icon": "./icon.png", + "type": "module" +} diff --git a/lib/components/tui/icon.png b/src/components/shell/icon.png similarity index 100% rename from lib/components/tui/icon.png rename to src/components/shell/icon.png diff --git a/src/components/shell/index.ts b/src/components/shell/index.ts new file mode 100644 index 00000000..7d491752 --- /dev/null +++ b/src/components/shell/index.ts @@ -0,0 +1,18 @@ +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' +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') + mountRuntimeSettings(ctx) +} diff --git a/src/components/shell/locale/en.json b/src/components/shell/locale/en.json new file mode 100644 index 00000000..f560e1ae --- /dev/null +++ b/src/components/shell/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "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 new file mode 100644 index 00000000..0ac5da62 --- /dev/null +++ b/src/components/shell/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "dsh-ops-bash & host PowerShell", + "description": "常规命令优先使用 bash,Windows 原生操作使用宿主 PowerShell" + } +} diff --git a/src/components/shell/package.json b/src/components/shell/package.json new file mode 100644 index 00000000..a6c23ea6 --- /dev/null +++ b/src/components/shell/package.json @@ -0,0 +1,6 @@ +{ + "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/lib/components/tui/dsh-plugin.json b/src/components/tui/dsh-plugin.json similarity index 97% rename from lib/components/tui/dsh-plugin.json rename to src/components/tui/dsh-plugin.json index 10f385e9..83bdb115 100644 --- a/lib/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/icon.png b/src/components/tui/icon.png new file mode 100644 index 00000000..cb35b43e Binary files /dev/null and b/src/components/tui/icon.png differ diff --git a/lib/components/tui/index.js b/src/components/tui/index.ts similarity index 70% rename from lib/components/tui/index.js rename to src/components/tui/index.ts index 82687b8f..b33c9c70 100644 --- a/lib/components/tui/index.js +++ b/src/components/tui/index.ts @@ -1,15 +1,25 @@ /** 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' +export { Config } from '../../settings/tui-config.js' 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/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 86% rename from lib/components/tui/package.json rename to src/components/tui/package.json index 7642f6c1..1718e4ee 100644 --- a/lib/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/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/lib/config-updates.js b/src/config-updates.ts similarity index 81% rename from lib/config-updates.js rename to src/config-updates.ts index e0c6bc11..fceddbaf 100644 --- a/lib/config-updates.js +++ b/src/config-updates.ts @@ -1,15 +1,26 @@ /** 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 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 @@ -30,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 @@ -38,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. @@ -75,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/lib/config.js b/src/config.ts similarity index 67% rename from lib/config.js rename to src/config.ts index 9ea8175c..bbfab175 100644 --- a/lib/config.js +++ b/src/config.ts @@ -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,7 @@ * @module dsh-ops/config */ + /** * Keys only the removed FastCtx surface read. Existing rows still carry them * (the bundle patch wrote `enableShellTools` and `toolCallTimeoutMs` into every @@ -28,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. @@ -45,7 +38,7 @@ export class ConfigError extends Error { * @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( @@ -61,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() === '') { @@ -77,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') { @@ -93,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() === '')) { @@ -102,33 +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 {'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', @@ -147,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') } @@ -177,15 +169,14 @@ 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') ?? '', 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 @@ -194,14 +185,9 @@ export function resolveConfig(rawConfig) { } } -/** Independent strict takeover row; disabling the row is the only opt-out. */ -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 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)] } -} +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/lib/credential-policy.js b/src/credential-policy.ts similarity index 81% rename from lib/credential-policy.js rename to src/credential-policy.ts index da5b1b15..1b7e3e0a 100644 --- a/lib/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/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..a423a7fd --- /dev/null +++ b/src/discovery/index.ts @@ -0,0 +1,237 @@ +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 { 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 { nativeProviders, registerNativeProvider, claimNativeSelection } from '../file/result-provider.js' +import { ownScopes } from '../scoped-ownership.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' +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 + releaseSelection?: () => void +} +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'], async ctx => { + const tools = ctx.get('tools'), prompt = ctx.get('systemPrompt'), policyService = ctx.get('sandboxPolicy') + if (!tools || !prompt || !policyService) return + const entries = new Map() + 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)) { + 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 || 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 = (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) => { + 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) + } } + 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[] => 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 && ownership?.owns(agent) && permitted(agent) && await native(agent).catch(() => false)) ready.push([agent, entry]) + if (stopped || version !== epoch) return + 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)]) + 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 + clear(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 => { + 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) { + 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 && ownership?.owns(agent)) { + const deny = inheritedNative(agent, entry) + if (deny.length) entry.disposers.push(entry.ctx.tools.restrict({ deny })) + } + } finally { ownership?.end(); mutating = false } + ownership?.changedBelow() + }).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); 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 + } + 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 && 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() + } + return next() + }) + ctx.on('agent/disposed', async ({ agent }) => { + const entry = entries.get(agent) + if (!entry) return + 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', () => { 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. + 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 === '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 { + 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() + }) + 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: 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 }) : '' + }, + }), 'dsh-ops: discovered capability guidance') + ctx.effect(() => async () => { + stopped = true; ++epoch; mutating = true + 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(); ownership?.dispose(); await chain + }, 'dsh-ops: discovery lifecycle') + 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/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/contract.ts b/src/file/contract.ts new file mode 100644 index 00000000..0fd526b8 --- /dev/null +++ b/src/file/contract.ts @@ -0,0 +1,39 @@ +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, fileOutput } from '../capabilities/file.js' +export { FILE_TOOLS } from '../capabilities/registry.js' +export { PARAMETERS, DESCRIPTIONS, OUTPUT } +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 { + 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 +} + +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: fileOutput(rawName), 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/result-provider.ts b/src/file/result-provider.ts new file mode 100644 index 00000000..208dcf20 --- /dev/null +++ b/src/file/result-provider.ts @@ -0,0 +1,79 @@ +/** 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; emit?(name: 'tools/change'): unknown; get(name: 'tools'): { get(name: string, scope?: unknown): ToolDefinition | undefined } | undefined } +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(), shadows: new Set(), ptc: new Set(), selections: new Set() }; owners.set(key, entry) } + return entry +} +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, 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() } + } } + 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) } + } + 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 { 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); 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, 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/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.ts b/src/file/runtime.ts new file mode 100644 index 00000000..18581534 --- /dev/null +++ b/src/file/runtime.ts @@ -0,0 +1,91 @@ +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' +import { NATIVE_FILE_TOOLS } from '../capabilities/registry.js' +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' + +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 { + 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 ?? (() => {}) + this.file = undefined + this.active = new Map() + this.disposers = [] + this.stopped = false + this.readDedup = createNativeReadDedup(ctx) + } + + 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.') + return [] + } + 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: 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: 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() + 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 = 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 { + clearTimeout(timer) + this.active.delete(controller) + exec?.signal?.removeEventListener?.('abort', abort) + } + } + + cancel(reason: string): void { + for (const controller of this.active.keys()) controller.abort(new Error(reason)) + } + + async stop(): Promise { + this.stopped = true + this.readDedup.dispose() + 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. + safeDisposeAll(this.disposers.splice(0), (error, where) => { + this.report(`dsh-ops: ${where} teardown failed: ${error instanceof Error ? error.message : String(error)}`, 'warn') + }, 'file runtime stop') + } +} diff --git a/lib/file/worker.js b/src/file/worker.ts similarity index 60% rename from lib/file/worker.js rename to src/file/worker.ts index 5a7dcce0..b1715610 100644 --- a/lib/file/worker.js +++ b/src/file/worker.ts @@ -1,4 +1,14 @@ /** Native file worker bridge. The host owns path resolution and DSH authority. */ +import type { Context } from '@deepseek-ai/cordis' +import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +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' import { ensureSessionTemp, sessionTempDir } from '../session-temp.js' @@ -7,22 +17,28 @@ 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' +import { withCallBudget, CallBudgetError } from '../kernel/call-budget.js' +import { withResultSession } from '../kernel/result-lifecycle.js' +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 @@ -30,7 +46,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 }) @@ -39,53 +55,71 @@ 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) { - 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.') } +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.') + 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) { +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') + const policyService: SandboxPolicyService | undefined = ctx.get('sandboxPolicy') + const subprocess: SubprocessRuntime | undefined = 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' 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 // 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 @@ -103,10 +137,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): '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' @@ -127,11 +161,16 @@ 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() : [] - 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 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 try { @@ -143,6 +182,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) { @@ -151,22 +191,29 @@ 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 + if (!handle.collected.stdout || !handle.collected.stderr) return failure('RESULT_UNKNOWN', 'Worker output readers are unavailable; inspect postconditions.', 'verify', 'unknown') const stdout = handle.collected.stdout.readFrom(0) const stderr = handle.collected.stderr.readFrom(0) 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 (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/lib/index.js b/src/index.ts similarity index 64% rename from lib/index.js rename to src/index.ts index 7350661f..f7ff8f8d 100644 --- a/lib/index.js +++ b/src/index.ts @@ -1,24 +1,35 @@ /** 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' -import { FILE_TOOLS } from './file/contract.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' 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 { Config } from './settings/file-config.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,58 +37,62 @@ 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(() => () => { - 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: ${errorText(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') } /** 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) @@ -86,8 +101,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 @@ -100,22 +115,16 @@ 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 (shells) mountSessionBash(ctx, config, shells) if (config.shellPolicy === 'deny-host-shell') installHostShellDenial(ctx, config) }) } else { @@ -124,4 +133,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/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 new file mode 100644 index 00000000..f9c2d12c --- /dev/null +++ b/src/kernel/errors.ts @@ -0,0 +1,50 @@ +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, + SEARCH_STALE: invalid, + NOT_FOUND: invalid, + IS_DIRECTORY: invalid, + OUTPUT_LIMIT: invalid, + 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, + 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, + 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/kernel/result-lifecycle.ts b/src/kernel/result-lifecycle.ts new file mode 100644 index 00000000..325c531f --- /dev/null +++ b/src/kernel/result-lifecycle.ts @@ -0,0 +1,98 @@ +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 } +} +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') + // 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') + } + 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) + 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, disposed = true): Promise { + const state = owner(ctx), entry = state.sessions.get(session) + if (disposed) state.expired.add(session) + if (!entry) return Promise.resolve() + if (entry.closing) return entry.closing + entry.stopped = true + // 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 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, false))) + }, '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/modes/native/documents.ts b/src/modes/native/documents.ts new file mode 100644 index 00000000..aadf49be --- /dev/null +++ b/src/modes/native/documents.ts @@ -0,0 +1,23 @@ +/** 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', '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.]') + if (typeof value.nextAction === 'string') lines.push('Next: ' + show(value.nextAction)) + return lines +} 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 new file mode 100644 index 00000000..3342fee6 --- /dev/null +++ b/src/modes/native/ranges.ts @@ -0,0 +1,51 @@ +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')}`) +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}|${encodeSourceLine(line.text)}`) + if (!item.lines.length) output.push(item.total === 0 ? '(empty file)' : '(no lines in this range)') + 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/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..93664f78 --- /dev/null +++ b/src/modes/native/read-observation.ts @@ -0,0 +1,67 @@ +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;') + .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 new file mode 100644 index 00000000..20b5ef93 --- /dev/null +++ b/src/modes/native/render.ts @@ -0,0 +1,194 @@ +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 { renderResult } from './result.js' +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 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') + +/** 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', '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)) { + 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[] = [] + 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 } + 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 (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 = 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) + 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.' + : 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[] { + 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/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/modes/ptc/canonical-schema.ts b/src/modes/ptc/canonical-schema.ts new file mode 100644 index 00000000..8d90548c --- /dev/null +++ b/src/modes/ptc/canonical-schema.ts @@ -0,0 +1,43 @@ +/** 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' +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, + 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 (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 })] }) + 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: 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, ...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 new file mode 100644 index 00000000..de337159 --- /dev/null +++ b/src/modes/ptc/continuation.ts @@ -0,0 +1,72 @@ +/** 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', '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:' +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) + || 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]) + 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) ?? {} + // 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 === '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' || 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 + && 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 + } + 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..bec60334 --- /dev/null +++ b/src/modes/ptc/outcome.ts @@ -0,0 +1,79 @@ +/** 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) + 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') + } + 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..2767e645 --- /dev/null +++ b/src/modes/ptc/schema.ts @@ -0,0 +1,22 @@ +/** 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 { + 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: failureData }, ['status', 'error']), + ] } +} diff --git a/src/official-tool-catalog.ts b/src/official-tool-catalog.ts new file mode 100644 index 00000000..e5e05e1e --- /dev/null +++ b/src/official-tool-catalog.ts @@ -0,0 +1,24 @@ +/** 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' +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 +// cannot stand in for write/edit, and background jobs cannot control host jobs. +export const OFFICIAL_REPLACEMENTS = Object.freeze({ + read: publicToolName('inspect_local_file'), + glob: publicToolName('glob'), + grep: publicToolName('grep'), +}) + +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: componentEnabled('shell', platform) } +} 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.ts b/src/official-tool-hiding.ts new file mode 100644 index 00000000..9a3d4be1 --- /dev/null +++ b/src/official-tool-hiding.ts @@ -0,0 +1,178 @@ +/** 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 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: 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: 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: string): boolean => selected.has(tool) + root.inject(['tools', 'systemPrompt'], ctx => { + 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: ${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 + return failed.length === 0 + } + // Readiness examines the unmasked scope. It never accepts ops_ + // as an equivalence claim, nor uses run_code as a native replacement. + 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 ready = readiness(agent) + const missing = replaceTools.flatMap((tool, index) => { + const replacement = OFFICIAL_REPLACEMENTS[tool] + return names.includes(replacement) && ready[index] + ? [] : [`${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('; ')) : undefined + // A failed plan hides nothing: no partial mask over an unready scope. + return { deny: failure ? [] : hidden, failure } + } + // 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. + // 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 */ } + mutating = true + try { + // 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: [object, Entry, Plan][] = [] + for (const [agent, entry] of entries) { + let plan: Plan + try { plan = prepare(agent, entry) } + 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 + // assembly/dispatch; it does not make a healthy ancestor or root fail. + for (const [agent, entry, plan] of plans) { + if (plan.failure) { clear(entry); entry.error = plan.failure; continue } + try { + // One restriction per complete plan; never partially hide when a + // single required replacement is absent. + 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(message(error)) } + } + for (const entry of entries.values()) { + if (entry.error !== entry.reported) { + if (entry.error) fail(entry.error) + entry.reported = entry.error + } + } + try { lastSurface = snapshot() } catch { lastSurface = undefined } + } finally { mutating = false } + } + 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 + // fails; invalidate readiness so transient provider errors are retryable. + const entry = entries.get(agent) + if (entry) entry.error = message + lastSurface = undefined + return new Error(message) + } + 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: ToolGuard = exec => { + if (stopped) return blocked('component disposed') + if (!exec?.agent?.ctx) return blocked('calling agent scope unavailable') + 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 selected for replacement`) : undefined) + } + ctx.on('tools/pre-execute', async (exec, next) => { + const reason = refusal(exec) + 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) => { + // 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) + const error = entry ? entry.error : blocked('agent scope cannot be verified') + 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: ${message(error)}`) } + reconcile() + 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); return undefined }) + ctx.on('agent/disposed', ({ agent }) => { + const entry = entries.get(agent) + if (!entry) return + mutating = true + try { clear(entry); entries.delete(agent) } finally { mutating = false } + reconcile() + }) + ctx.on('tools/change', reconcile) + ctx.effect(() => () => { + stopped = true; mutating = true + for (const entry of entries.values()) clear(entry) + entries.clear() + }, '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/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/lib/panel/collect.js b/src/panel/collect.ts similarity index 71% rename from lib/panel/collect.js rename to src/panel/collect.ts index 84b4ddd0..18cf96cf 100644 --- a/lib/panel/collect.js +++ b/src/panel/collect.ts @@ -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,9 @@ 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' +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' @@ -37,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: false, 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 @@ -47,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) @@ -74,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 }) } /** @@ -90,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)) } @@ -104,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) @@ -119,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 } } @@ -162,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), @@ -189,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 @@ -211,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 } } @@ -226,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, @@ -239,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 } } @@ -249,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 @@ -318,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', { @@ -375,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, @@ -389,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, @@ -416,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 @@ -438,57 +447,72 @@ 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 = {}) { +export function createPanelCollector(options: CollectorOptions = {}) { 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 = { + 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: 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(), } @@ -511,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 } @@ -535,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 }) @@ -550,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) { @@ -590,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 @@ -602,43 +626,52 @@ 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 return merge(sessionId, true).then(() => { sessions.delete(sessionId) }) }, - /** Seal every session; called from the activation teardown. Never rejects. */ - dispose() { + /** 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(): Promise { + 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(): 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/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.ts similarity index 92% rename from lib/panel/render.js rename to src/panel/render.ts index 4d03e9e9..bf962e1c 100644 --- a/lib/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/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/lib/paths.js b/src/paths.ts similarity index 90% rename from lib/paths.js rename to src/paths.ts index b9dea814..0d1731fc 100644 --- a/lib/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/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/lib/policy.js b/src/policy.ts similarity index 53% rename from lib/policy.js rename to src/policy.ts index ea42e723..2f9b064f 100644 --- a/lib/policy.js +++ b/src/policy.ts @@ -1,42 +1,57 @@ +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. */ 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))), + files: NATIVE_FILE_TOOLS.some(name => names.has(publicToolName(name))), bash: names.has(BASH_TOOL), pwsh: pwsh === true && names.has('pwsh'), } } +interface ToolingPolicyOptions { + published?: Iterable + mounted?: boolean + levels?: { files?: boolean, bash?: boolean, pwsh?: 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, 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 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 '' - const heading = hasBash ? '# Shell routing: bash → PowerShell 7' - : hasFiles ? '# Repository file tools' : '# Windows-native shell' + if (!hasFiles && !hasBash && !hasPwsh && !hasDiscovery) return '' + const heading = hasBash ? '# Shell routing: bash → host PowerShell' + : hasFiles || hasDiscovery ? '# 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('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('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) |') row('grep', 'Search contents') @@ -49,15 +64,21 @@ 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'}) |`) + 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 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( - '- 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.', - '- 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))) 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 ') @@ -65,11 +86,11 @@ 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.', - '- 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. 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') } @@ -78,10 +99,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/presentation.ts b/src/presentation.ts new file mode 100644 index 00000000..4a3d60da --- /dev/null +++ b/src/presentation.ts @@ -0,0 +1,18 @@ +/** 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 inspectPresentation(prompt: Pick, scope: NonNullable, signal?: AbortSignal): Promise { + const context: AssembleContext = { scope, signal } + probes.add(context) + try { + 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/lib/profile-install.js b/src/profile-install.ts similarity index 68% rename from lib/profile-install.js rename to src/profile-install.ts index f6f55de1..20f68b9a 100644 --- a/lib/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) { @@ -94,7 +111,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/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/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/contract.ts b/src/ptc/contract.ts new file mode 100644 index 00000000..25917fab --- /dev/null +++ b/src/ptc/contract.ts @@ -0,0 +1,29 @@ +/** 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 { 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' +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: 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/src/ptc/index.ts b/src/ptc/index.ts new file mode 100644 index 00000000..542f662a --- /dev/null +++ b/src/ptc/index.ts @@ -0,0 +1,235 @@ +/** 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 { 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' +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, trackNativeShadow } 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) +const ptcDefinitions = new WeakSet() +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 = '' + 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) + 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 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)) + } + 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, true)])) + 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) { 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) => { + 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), 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 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() + } 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) => { + // 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 + 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 === '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) + }) + 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.' : '' + } }), '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') + 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..f46352ea --- /dev/null +++ b/src/ptc/ownership.ts @@ -0,0 +1,3 @@ +import type { Context } from '@deepseek-ai/cordis' +import { ownScopes } from '../scoped-ownership.js' +export const ownPtcScopes = (ctx: Context, changed: () => void) => ownScopes(ctx, 'ptc', changed) diff --git a/lib/ptc/runtime.js b/src/ptc/runtime.ts similarity index 79% rename from lib/ptc/runtime.js rename to src/ptc/runtime.ts index c7009b00..1b72395c 100644 --- a/lib/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,12 +55,12 @@ 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 } = {}) { - 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}.`) +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 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)) @@ -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/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/lib/ptc/worker.js b/src/ptc/worker.ts similarity index 58% rename from lib/ptc/worker.js rename to src/ptc/worker.ts index ab5aee59..4d064387 100644 --- a/lib/ptc/worker.js +++ b/src/ptc/worker.ts @@ -1,4 +1,16 @@ /** Native worker bridge. Every execution uses DSH policy, confinement and process governance. */ +import type { Context } from '@deepseek-ai/cordis' +import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +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' +import { resultStorage } from '../kernel/result-storage.js' import { randomUUID } from 'node:crypto' import path from 'node:path' import fs from 'node:fs' @@ -8,24 +20,39 @@ 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) { - 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.') } +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.') + 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) { +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') + const policyService: SandboxPolicyService | undefined = ctx.get('sandboxPolicy') + const subprocess: SubprocessRuntime | undefined = 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 @@ -33,6 +60,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 @@ -42,24 +70,32 @@ 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.') } - 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 } : {}) }) - 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') 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.') } 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 }) @@ -71,10 +107,11 @@ 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 + if (!handle.collected.stdout || !handle.collected.stderr) return errorResult('RESULT_UNKNOWN', 'Worker output readers are unavailable; inspect audit records.', 'unknown') const stdout = handle.collected.stdout.readFrom(0), stderr = handle.collected.stderr.readFrom(0) if (signal?.aborted || outcome.signal) return errorResult('RESULT_UNKNOWN', 'Worker interrupted; durable write-ahead records preserve possible effects.', 'unknown') if (confined && runnerFailure(outcome.exitCode, stderr.text, confined.runnerFailureRules ?? [])) { @@ -89,16 +126,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/lib/rust.js b/src/rust.ts similarity index 84% rename from lib/rust.js rename to src/rust.ts index 51c9aa2e..a139e80f 100644 --- a/lib/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/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/lib/session-shells.js b/src/session-shells.ts similarity index 53% rename from lib/session-shells.js rename to src/session-shells.ts index 07118433..e8103090 100644 --- a/lib/session-shells.js +++ b/src/session-shells.ts @@ -1,11 +1,17 @@ /** 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, 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. @@ -19,7 +25,7 @@ import { confinedBash } from './bash-ipc.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 = () => { @@ -146,88 +155,23 @@ 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 && 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 => { @@ -236,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) @@ -249,10 +193,11 @@ 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() }) + ctx.inject(['jobs'], jobsCtx => { reconcile(); jobsCtx.effect(() => () => { reconcile() }) }) ctx.inject(['sandboxPolicy', 'subprocess'], authorityCtx => { ready = true reconcile() diff --git a/lib/session-temp.js b/src/session-temp.ts similarity index 86% rename from lib/session-temp.js rename to src/session-temp.ts index 5b4fe529..0e3f66c7 100644 --- a/lib/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/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/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/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/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/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/lib/shells.js b/src/shells.ts similarity index 96% rename from lib/shells.js rename to src/shells.ts index 44b63d87..ab30731f 100644 --- a/lib/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/approval.ts b/src/shells/approval.ts new file mode 100644 index 00000000..5293c525 --- /dev/null +++ b/src/shells/approval.ts @@ -0,0 +1,40 @@ +// 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: 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: Record, standing: ShellPolicy): asserts args is EscalationArgs { + const target = args.sandbox_permissions + const reason = args.justification + 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') + 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: 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 }) + 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/lib/shells/argument-error.js b/src/shells/argument-error.ts similarity index 80% rename from lib/shells/argument-error.js rename to src/shells/argument-error.ts index e9045144..d93bfeb3 100644 --- a/lib/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/lib/shells/constants.js b/src/shells/constants.ts similarity index 95% rename from lib/shells/constants.js rename to src/shells/constants.ts index f9960b98..e98fbe04 100644 --- a/lib/shells/constants.js +++ b/src/shells/constants.ts @@ -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 @@ -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/execution.ts b/src/shells/execution.ts new file mode 100644 index 00000000..a2becca3 --- /dev/null +++ b/src/shells/execution.ts @@ -0,0 +1,128 @@ +/** 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 = (): SubprocessOutputRead => ({ text: '', nextOffset: 0, lossy: false }) + +/** Read the retained stream after settlement, preserving honest truncation facts. */ +export function settledStream(reader: SubprocessOutputReader | undefined, includeMetadata = false): ShellStream { + 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 }: ShellExecutorOptions, spec: ShellExecutionSpec, exec: ShellCall = {}): ShellExecution { + const controller = new AbortController() + const callerSignal = exec.signal + const startedAt = performance.now() + 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) + 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: '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: Promise.resolve(), + } + 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 (): Promise => { + let value: ShellResult + 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!: () => void + 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() + if (!subprocess) throw new Error('Host subprocess service is unavailable') + handle = subprocess.spawn({ + argv: preparedArgv, + // 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 } }, + 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/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 new file mode 100644 index 00000000..c7573bc8 --- /dev/null +++ b/src/shells/host-contract.ts @@ -0,0 +1,52 @@ +// 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 { bashFailure } from './failure.js' +import { BASH_OUTPUT_SCHEMA, renderBashResult, withBashStatus } from './tool.js' + +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' }, + } }, + { 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, + 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, 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, 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 { + 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.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}]`) + if (exitCode !== null) markers.push(`[exit code: ${exitCode}]`) + return [body, ...markers].join('\n') +} diff --git a/src/shells/host-tool.ts b/src/shells/host-tool.ts new file mode 100644 index 00000000..3034e109 --- /dev/null +++ b/src/shells/host-tool.ts @@ -0,0 +1,80 @@ +// 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, 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 { BashJobAdmissionError, startBashJob, waitOnBashJob } from './jobs.js' + +export const HOST_MAX_BASH_TIMEOUT_MS = 600_000 + +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: 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.' }, + ...(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: { ...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: 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 + 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 && (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) + // 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 (!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 approvedPolicy = policy + 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 || 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) + const execution = await start(exec, {}) + const result = await execution.result() + return hostForeground(result, execution.started ? 'unknown' : 'none') + } catch (error) { + 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 new file mode 100644 index 00000000..3ed93fb8 --- /dev/null +++ b/src/shells/jobs.ts @@ -0,0 +1,81 @@ +// 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' + +/** 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 }, + })) + let id: string + try { id = jobs.start({ kind: 'bash', label: args.command ?? args.script_path ?? 'Bash', + ...(exec.agent ? { owner: exec.agent.id } : {}), output, + run: () => { + 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() + 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: 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) } } + }, + }) } 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 { + const owner = exec.agent?.id + 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) + } + 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 } }, attached.effects()) + } + 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() } : {}) }, process.started ? 'unknown' : 'none') +} 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/lib/shells/paths.js b/src/shells/paths.ts similarity index 89% rename from lib/shells/paths.js rename to src/shells/paths.ts index 393909f4..f57499db 100644 --- a/lib/shells/paths.js +++ b/src/shells/paths.ts @@ -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. @@ -24,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, @@ -31,10 +28,14 @@ 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 } + /** - * 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 +47,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. */ @@ -60,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) @@ -77,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}` } @@ -106,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)) } @@ -138,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)` } @@ -172,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 } @@ -197,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. @@ -206,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 } } @@ -237,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 { @@ -268,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. @@ -293,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')) } @@ -315,11 +316,10 @@ 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: 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)) : [] } @@ -356,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] } @@ -425,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/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/lib/shells/pins.js b/src/shells/pins.ts similarity index 95% rename from lib/shells/pins.js rename to src/shells/pins.ts index 9edc5259..76a3d906 100644 --- a/lib/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/lib/shells/publish.js b/src/shells/publish.ts similarity index 79% rename from lib/shells/publish.js rename to src/shells/publish.ts index c8a841b3..2b598d2c 100644 --- a/lib/shells/publish.js +++ b/src/shells/publish.ts @@ -2,8 +2,8 @@ * Publishing the tools that run on the plugin's own shells, and the remedies a * spawn failure deserves. * - * Contract: {@link publishShellTools} is called inside one `ctx.effect` during - * mount and returns the disposer for whatever tool registrations it made + * Legacy helper for direct callers; component mounts use mountSessionBash and + * its scoped host-compatible definition instead. It returns the disposer for registrations * (today: the `ops_bash` tool backed by the plugin's own bash). Returning a * no-op disposer is valid. * @@ -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 @@ -29,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' @@ -38,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) @@ -55,8 +52,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. @@ -65,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') { @@ -103,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/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/lib/shells/resolve.js b/src/shells/resolve.ts similarity index 74% rename from lib/shells/resolve.js rename to src/shells/resolve.ts index fdcc810f..8d9898c2 100644 --- a/lib/shells/resolve.js +++ b/src/shells/resolve.ts @@ -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 @@ -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' } @@ -142,52 +152,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(): 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.' } } /** @@ -205,7 +173,7 @@ function resolvePwsh({ provisioned, bundled, bundledDetail, allowSystemFallback * @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 @@ -215,15 +183,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/src/shells/session-execution.ts b/src/shells/session-execution.ts new file mode 100644 index 00000000..3a28b88d --- /dev/null +++ b/src/shells/session-execution.ts @@ -0,0 +1,60 @@ +/** 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 }: { 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 }) + 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: { 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: ConfinedArgv | undefined + const definition = bashToolDefinition({ file: runtime.file, source: bash.source, + env: { ...runtime.env, ...dshEnv }, subprocess: ctx.get('subprocess'), + prepareArgv: async (argv: string[], signal: AbortSignal) => { + 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, 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: ShellResult) => { + const stderr = `${value.stderr?.text ?? ''}\n${value.error ?? ''}` + const runnerFailed = value.exitCode !== 0 && confinement?.runnerFailureRules?.some(rule => + (!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) + 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/lib/shells/spawn-error.js b/src/shells/spawn-error.ts similarity index 89% rename from lib/shells/spawn-error.js rename to src/shells/spawn-error.ts index 61e69d5a..3300be07 100644 --- a/lib/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/lib/shells/tool.js b/src/shells/tool.ts similarity index 69% rename from lib/shells/tool.js rename to src/shells/tool.ts index eeba5c64..42966882 100644 --- a/lib/shells/tool.js +++ b/src/shells/tool.ts @@ -14,19 +14,17 @@ */ import fs from 'node:fs' -import { Buffer } from 'node:buffer' +import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools' +import type { ShellStream, ShellResult, SettledShellResult, ShellExecutorOptions, LegacyShellDefinition } from './types.js' 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. @@ -35,8 +33,8 @@ import { formatBashSpawnError } from './spawn-error.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) } } @@ -62,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') @@ -89,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) @@ -112,33 +110,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. * @@ -154,7 +125,8 @@ function settledStream(reader, includeMetadata = false) { * value that does not satisfy it is what the host reports as "returned invalid * output". */ -export const BASH_OUTPUT_SCHEMA = Object.freeze({ +/** @type {import('@deepseek-ai/dsh-tools').JsonSchemaNode} */ +export const BASH_OUTPUT_SCHEMA: JsonSchemaNode = Object.freeze({ type: 'object', additionalProperties: false, required: ['status', 'timedOut', 'timeoutMs', 'stdout', 'stderr'], @@ -203,12 +175,12 @@ 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) { +export function withBashStatus(value: ShellResult): SettledShellResult { 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' } } @@ -241,14 +213,11 @@ 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 = {} }) { - return { +export function bashToolDefinition({ file, source, subprocess, prepareArgv, env = {}, projectResult = withBashStatus }: ShellExecutorOptions): LegacyShellDefinition { + const definition: LegacyShellDefinition = { name: BASH_TOOL_NAME, description: bashToolDescription(), parameters: { @@ -289,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. @@ -301,15 +270,14 @@ 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) { + 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 - // field reaches the caller as itself instead of as "returned invalid output" (#235). + // This internal validator throws typed argument errors. The session host projection returns + // them as schema-valid failures, preserving the caller's diagnosis and no-effect facts. 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) } @@ -355,96 +323,20 @@ 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') } } - // 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 { 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() }, } + return definition } diff --git a/src/shells/types.ts b/src/shells/types.ts new file mode 100644 index 00000000..e241dfe3 --- /dev/null +++ b/src/shells/types.ts @@ -0,0 +1,104 @@ +/** Typed OPS shell boundary; host classes are borrowed through services, never imported as values. */ +import type { OpsErrorPayload } from '../kernel/outcome.js' +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 + failure?: OpsErrorPayload + 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 & 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 +} +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/lib/teardown.js b/src/teardown.ts similarity index 89% rename from lib/teardown.js rename to src/teardown.ts index 30e92b7e..6fc345f2 100644 --- a/lib/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 diff --git a/lib/tui/activity.js b/src/tui/activity.ts similarity index 52% rename from lib/tui/activity.js rename to src/tui/activity.ts index 4ece2a85..3505df08 100644 --- a/lib/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/lib/tui/attention.js b/src/tui/attention.ts similarity index 54% rename from lib/tui/attention.js rename to src/tui/attention.ts index c480c0b8..46b95e07 100644 --- a/lib/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/lib/tui/panel.js b/src/tui/panel.ts similarity index 70% rename from lib/tui/panel.js rename to src/tui/panel.ts index 798cbd89..9e9aaf5d 100644 --- a/lib/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.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/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/src/worker-protocol.ts b/src/worker-protocol.ts new file mode 100644 index 00000000..ddc59d63 --- /dev/null +++ b/src/worker-protocol.ts @@ -0,0 +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 = 30000 +export const WORKER_OUTPUT_BYTES = 128 * 1024 + +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: Math.min(timeout, WORKER_TIMEOUT_MS), maxBytes: WORKER_OUTPUT_BYTES }, request, ...(storage ? { storage } : {}) } +} diff --git a/lib/workspace-preparation.js b/src/workspace-preparation.ts similarity index 84% rename from lib/workspace-preparation.js rename to src/workspace-preparation.ts index fd8097ba..dfe3f5ff 100644 --- a/lib/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/authority.test.mjs b/test/authority.test.mjs index 979d4953..ad4599a7 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', () => { @@ -42,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()) @@ -134,7 +157,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) } }) @@ -201,10 +224,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..9184d9fe 100644 --- a/test/bash-ipc.test.mjs +++ b/test/bash-ipc.test.mjs @@ -1,6 +1,8 @@ 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' import { confinedBash, restrictedCompatibility } from '../lib/bash-ipc.js' import { assert, test, report, withTempDir } from './lib/harness.mjs' @@ -18,7 +20,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) @@ -38,7 +40,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) @@ -52,7 +54,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 +68,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=>{ @@ -110,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') diff --git a/test/bench-gates.test.mjs b/test/bench-gates.test.mjs new file mode 100644 index 00000000..fb0fe944 --- /dev/null +++ b/test/bench-gates.test.mjs @@ -0,0 +1,114 @@ +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) + 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 () => { + 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/) +}) +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 new file mode 100644 index 00000000..f28e5589 --- /dev/null +++ b/test/bench-integrity.test.mjs @@ -0,0 +1,65 @@ +/** Negative evidence must not pass a token or permission gate. */ +import path from 'node:path' +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' +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('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]} + 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') 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/bin.test.mjs b/test/bin.test.mjs index 10814780..f456141b 100644 --- a/test/bin.test.mjs +++ b/test/bin.test.mjs @@ -23,12 +23,13 @@ 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 { 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' /** 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. @@ -272,7 +273,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 ? FILE_TOOLS.map(publicToolName) : []), ...(levels.bash ? ['ops_bash'] : []), ...(levels.pwsh ? ['pwsh'] : []), ] @@ -555,7 +556,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), []) @@ -654,8 +655,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 +671,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) }) }) @@ -754,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}`) }) }) diff --git a/test/build.test.mjs b/test/build.test.mjs new file mode 100644 index 00000000..cd18984e --- /dev/null +++ b/test/build.test.mjs @@ -0,0 +1,120 @@ +/** 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')) + 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', 'yaml']) { + 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' })) +} + +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 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.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') + 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.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'), + '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.ts')) + 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.ts'), '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) + })) + +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/test/bundle-startup.test.mjs b/test/bundle-startup.test.mjs index c2a52a1d..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,10 +32,12 @@ 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`) - 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 } } } @@ -45,20 +49,46 @@ 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') && - names().includes('ops_bash'), { what: `${mode}/${presentation} default publication` }) + 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') === (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) assert.ok(names().includes('write'), 'defaults must not mask standard-preset mutation tools') - 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 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) } }) }) } +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(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) + } + 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_read_many'), { what: 'file publication without optional Bash' }) + assert.equal(names().includes('ops_bash'), 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) } + }) +}) report('bundle-startup') 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/capabilities.test.mjs b/test/capabilities.test.mjs new file mode 100644 index 00000000..7d7996bf --- /dev/null +++ b/test/capabilities.test.mjs @@ -0,0 +1,93 @@ +/** Registry migration preserves the complete previously published host definitions. */ +import { createHash } from 'node:crypto' +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' + +await test('the catalogue covers every owned file, PTC and shell tool exactly once', () => { + 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', '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']) + 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', 'discovery']) 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 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.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') + assert.equal(entry.concurrencySafe, entry.component === 'file' && !entry.mutates) + } +}) + +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) 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('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', '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)) +}) + +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(), 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) { + const tool = definition(name, async () => 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') + } + } + 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/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/context-read-dedup.test.mjs b/test/context-read-dedup.test.mjs new file mode 100644 index 00000000..0fdd5ae6 --- /dev/null +++ b/test/context-read-dedup.test.mjs @@ -0,0 +1,245 @@ +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 }) + assert.equal(ctx.get('tools').get(READ, agent), undefined) + full(await read({ owner: { id: agent.id, session: agent.session } })) +})) + +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..4abfe69d --- /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), /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 => { + 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/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/discovery-composition.test.mjs b/test/discovery-composition.test.mjs new file mode 100644 index 00000000..0a41984b --- /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_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)) + 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_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_read_many'), 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_read_many')) + 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_read_many'), 'unload preserves the resident 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 new file mode 100644 index 00000000..19c860b0 --- /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: ['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', () => { + 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), ['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 }]) + 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, 94) +}) +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/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 new file mode 100644 index 00000000..c26f55b0 --- /dev/null +++ b/test/docx-worker.test.mjs @@ -0,0 +1,153 @@ +/** 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 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') + }) +}) +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/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') diff --git a/test/file-contract.test.mjs b/test/file-contract.test.mjs index 00234f9f..7e37498c 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) }) @@ -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/) 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..69c2203c 100644 --- a/test/file-sdk-render.test.mjs +++ b/test/file-sdk-render.test.mjs @@ -2,11 +2,12 @@ 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' -await test('all eleven file argument schemas remain discoverable without unknown types', () => { - assert.equal(FILE_TOOLS.length, 11) +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) @@ -23,32 +24,144 @@ 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) ===`)) + 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/) + 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', () => { @@ -63,4 +176,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/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') 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/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/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/kernel-errors.test.mjs b/test/kernel-errors.test.mjs new file mode 100644 index 00000000..eafe7033 --- /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', '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', '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', () => { + 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/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/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/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/lib/context-read.mjs b/test/lib/context-read.mjs new file mode 100644 index 00000000..3c83aa9f --- /dev/null +++ b/test/lib/context-read.mjs @@ -0,0 +1,94 @@ +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 * 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' + +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: 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 + 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 } = {}) => { + if (!admitted.has(owner)) { + owner.ctx ??= createScope(ctx, owner).ctx + await ctx.serial('agent/created', { agent: owner }) + admitted.add(owner) + } + await ctx.waterfall('agent/pre-step', { agent: owner, signal: new AbortController().signal }, async () => {}) + 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) } + }) +} diff --git a/test/lib/discovery.mjs b/test/lib/discovery.mjs new file mode 100644 index 00000000..4b67e0b6 --- /dev/null +++ b/test/lib/discovery.mjs @@ -0,0 +1,210 @@ +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 { 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' + +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('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('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) + assert.equal(scopedToolNames(ctx, code).includes(publicToolName('stat')), false) + 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 { + 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 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']) + 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) } + })) +} + +export { boot as bootDiscoveryHost, mint as mintDiscoveryAgent, nextStep as discoveryNextStep } 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/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/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/lib/official-tool-hiding.mjs b/test/lib/official-tool-hiding.mjs index 4bad0af2..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' @@ -36,8 +37,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 @@ -51,15 +52,16 @@ 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 }) 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) + 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') @@ -90,82 +92,56 @@ 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) + 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) + 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 { + 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) + 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')) + 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') + 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 () => { 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') @@ -173,10 +149,10 @@ 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) - // 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')) @@ -188,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')) @@ -197,10 +173,10 @@ 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) + await replacements(ctx) ctx.provide('ptcRuntime', { language: 'typescript' }) const warnings = warningsFrom(ctx) await mount(ctx) @@ -208,32 +184,33 @@ 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() 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) @@ -252,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') @@ -275,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) @@ -302,10 +279,73 @@ 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 { + // 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 { - 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) => { @@ -321,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) @@ -343,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) @@ -357,13 +397,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 +422,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) + 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') + 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/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/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/linux-shell.test.mjs b/test/linux-shell.test.mjs index 68a6253e..5a661346 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], @@ -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) diff --git a/test/manifest.test.mjs b/test/manifest.test.mjs index cebb1f30..2dc0a4f4 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) { @@ -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/mount.test.mjs b/test/mount.test.mjs index 80242f9a..ed5b5624 100644 --- a/test/mount.test.mjs +++ b/test/mount.test.mjs @@ -1,8 +1,11 @@ +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' import { pathToFileURL } from 'node:url' 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' @@ -16,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 = 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` @@ -28,6 +31,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 }, @@ -49,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) } }) } @@ -116,7 +124,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 @@ -128,6 +136,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() @@ -139,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) @@ -158,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') @@ -169,7 +180,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/) @@ -228,12 +240,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/) } @@ -330,33 +342,20 @@ 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('selected replacements fail 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: 'selected-file-takeover', apply: hideOfficial }, { replaceTools: ['read', 'glob', 'grep'] }) + const current = await agent(ctx, 'selected-file-takeover') + await assert.rejects(() => ctx.get('systemPrompt').assemble({ scope: current }), /READINESS_BLOCKED/) + for (const name of ['read', 'glob', 'grep']) assert.ok(ctx.tools.schemas(current).some(tool => tool.name === name)) 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-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') 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') diff --git a/test/native-ranges.test.mjs b/test/native-ranges.test.mjs new file mode 100644 index 00000000..eddf2e53 --- /dev/null +++ b/test/native-ranges.test.mjs @@ -0,0 +1,68 @@ +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)) + 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) + 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(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', () => { + 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') diff --git a/test/native-result.test.mjs b/test/native-result.test.mjs new file mode 100644 index 00000000..7f3dc891 --- /dev/null +++ b/test/native-result.test.mjs @@ -0,0 +1,235 @@ +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 * 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' +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 = { fiber: await mountNativeCatalogue(ctx, { binaryPath: WORKER, required: true }) } + 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) } }) + 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 +} +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('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) + 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('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' + 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) + 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') + } finally { await shutdown(ctx) } + }) +}) + +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 { + 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 => 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') + 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) { 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)) + 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() + 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) + 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/official-tool-hiding.test.mjs b/test/official-tool-hiding.test.mjs index e646088a..ad7e42f9 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 { 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 { Context } from '@deepseek-ai/cordis' +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' -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']) -}) -await test('strict hiding is disabled opt-in and the pinned platform surface is exact', () => { - 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. - assert.equal(hiding.disabled, true) - assert.deepEqual(resolveHidingConfig(hiding.config), { strict: true, additionalOfficialTools: [] }) - for (const platform of ['win32', 'linux', 'darwin']) { - assert.deepEqual(platformSurface(platform), { retainedCommand: platform === 'win32' ? 'pwsh' : 'bash', shellDefaultEnabled: true }) - } - assert.throws(() => platformSurface('freebsd'), /Unsupported/) + raw.replaceTools.push('glob') + assert.deepEqual(result, { replaceTools: ['read', 'grep'] }) }) -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) +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 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) + 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/) } - // 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) + assert.throws(() => resolveHidingConfig({ replacementTools: ['read'] }), error => error instanceof ConfigError && /unknown hiding key: replacementTools/.test(error.message)) }) -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 }) +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'] }) } - for (const bad of ['strict', '', 'TAKEOVER', 'extend', 'on', true, 3, [], {}]) { - assert.throws(() => resolveTakeoverConfig({ scope: bad }), /scope must be one of/) + 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/) } - // 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`) +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('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 hiding = document.flatMap(item => item.insert).find(row => row.name === 'dsh-ops/hide-official') + assert.equal(hiding.disabled, true) + 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: platform === 'win32' }) } - assert.equal(surfaceWarning(off), undefined) }) -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('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('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') 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/package-source.test.mjs b/test/package-source.test.mjs index fc35f69e..39e3bba2 100644 --- a/test/package-source.test.mjs +++ b/test/package-source.test.mjs @@ -1,25 +1,50 @@ -/** 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 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 = ['native/Cargo.toml', 'native/Cargo.lock', 'native/src/lib.rs', + 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', - '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', '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}`) + 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') 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/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') 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/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') diff --git a/test/permissions.test.mjs b/test/permissions.test.mjs index ef17c043..5e11f2a7 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) @@ -347,28 +349,29 @@ 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}) 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,{}) @@ -376,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) @@ -396,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/platform-defaults.test.mjs b/test/platform-defaults.test.mjs index 67e401a5..d9eb1039 100644 --- a/test/platform-defaults.test.mjs +++ b/test/platform-defaults.test.mjs @@ -1,24 +1,56 @@ -// #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' +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')) +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) + 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/policy.test.mjs b/test/policy.test.mjs index 27c7ebec..7addbfe3 100644 --- a/test/policy.test.mjs +++ b/test/policy.test.mjs @@ -15,12 +15,22 @@ 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/) 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\/\*\*"\]`/) @@ -30,9 +40,11 @@ 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.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) }) @@ -41,10 +53,19 @@ 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('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('resident-only routing omits unavailable operation hints and includes discovery only when published', () => { + 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'))) + assert.match(discovery, /next model step/) + for (const name of files) assert.equal(discovery.includes(name), false) +}) +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/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') 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/progressive-loop.test.mjs b/test/progressive-loop.test.mjs new file mode 100644 index 00000000..596fa8ad --- /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, JSON.stringify(stat.value)) + } 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, JSON.stringify(calls[1].result.value)) + } finally { await host.close() } +})) +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: ['stat'] } }) + 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_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_stat')) + return [] + }]) + assert.equal(run.requests.length, 2) + assert.equal(fs.readFileSync(path.join(root, 'note'), 'utf8'), 'unchanged') + } finally { await host.close() } +})) +report('progressive-loop') diff --git a/test/ptc-cost.test.mjs b/test/ptc-cost.test.mjs index 039ee4d3..fbed30db 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_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 > 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-outcome.test.mjs b/test/ptc-outcome.test.mjs new file mode 100644 index 00000000..6b99c167 --- /dev/null +++ b/test/ptc-outcome.test.mjs @@ -0,0 +1,114 @@ +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 { 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', () => { + 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, 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) + 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') + 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) + 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) + } +}) +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') diff --git a/test/ptc-projection-worker.test.mjs b/test/ptc-projection-worker.test.mjs new file mode 100644 index 00000000..68ded73c --- /dev/null +++ b/test/ptc-projection-worker.test.mjs @@ -0,0 +1,114 @@ +/** 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' +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('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('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('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') + 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/ptc-sdk.test.mjs b/test/ptc-sdk.test.mjs new file mode 100644 index 00000000..80bf4565 --- /dev/null +++ b/test/ptc-sdk.test.mjs @@ -0,0 +1,185 @@ +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 { mountNativeCatalogue } from './lib/native-catalogue.mjs' +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() + 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, {}) + 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) { + 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) } }) + agents.push(value) + 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]) { 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) + 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(); 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) } + }) +}) +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_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_read_many'), { 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 78a54c69..9251099a 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)) @@ -223,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 @@ -247,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) { @@ -268,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) @@ -315,19 +316,24 @@ 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() - 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-/) @@ -343,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() @@ -351,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)) @@ -406,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 }))) @@ -418,13 +425,15 @@ 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 selected = []; let offset = 0; let stable = true; let bounded = true; + 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:512}); - if (page.error) return page; - stable = stable && page.contentHash === stored.contentHash && page.sourceTotal === 120 && page.total === 60; - bounded = bounded && JSON.stringify(page).length <= 512; + 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; selected.push(...page.items.map(row=>row.n)); if (page.complete) break; if (page.nextOffset <= offset) return {error:'pagination did not advance'}; @@ -432,15 +441,16 @@ 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); 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') @@ -454,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) 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/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') 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/resident-tools.test.mjs b/test/resident-tools.test.mjs new file mode 100644 index 00000000..421667ef --- /dev/null +++ b/test/resident-tools.test.mjs @@ -0,0 +1,88 @@ +/** 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 { 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' +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' + +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) } +} +async function mount(config) { + const host = await bootDiscoveryHost(config) + const fileFiber = await host.ctx.plugin(file, { required: true }) + 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), [READ]) + assert.deepEqual(scopedToolNames(ctx, agent), [READ]) + fs.writeFileSync(path.join(dir, 'read.txt'), 'bounded reader') + 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/) + 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('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) + const assembly = await ctx.get('systemPrompt').assemble({ scope: agent }) + 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 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, 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, '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)) + 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(), [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()) + } finally { await shutdown(ctx) } +})) +report('resident-tools') diff --git a/test/result-lifecycle.test.mjs b/test/result-lifecycle.test.mjs new file mode 100644 index 00000000..74139421 --- /dev/null +++ b/test/result-lifecycle.test.mjs @@ -0,0 +1,112 @@ +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 } } + +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) + 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) } +}) + +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) } +}) +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') diff --git a/test/result-references.test.mjs b/test/result-references.test.mjs new file mode 100644 index 00000000..3c012cdf --- /dev/null +++ b/test/result-references.test.mjs @@ -0,0 +1,285 @@ +/** 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('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 }] }) + 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') 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") diff --git a/test/result-storage-host.test.mjs b/test/result-storage-host.test.mjs new file mode 100644 index 00000000..16170fd5 --- /dev/null +++ b/test/result-storage-host.test.mjs @@ -0,0 +1,83 @@ +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 { definition } from '../lib/file/contract.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.inject(['tools'], scoped => { scoped.tools.register(definition('grep', async () => ({ status: 'completed' }))) }) + }) + 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/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/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') 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') 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-host.test.mjs b/test/settings-host.test.mjs new file mode 100644 index 00000000..326b0739 --- /dev/null +++ b/test/settings-host.test.mjs @@ -0,0 +1,161 @@ +/** 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 { bootHost, 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) + } + } +}) +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/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') 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/test/shells-execution.test.mjs b/test/shells-execution.test.mjs new file mode 100644 index 00000000..03e05fd4 --- /dev/null +++ b/test/shells-execution.test.mjs @@ -0,0 +1,95 @@ +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) +}) + +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 new file mode 100644 index 00000000..2432a024 --- /dev/null +++ b/test/shells-host.test.mjs @@ -0,0 +1,292 @@ +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() + 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) +}) + +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) + 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) + 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' }]) { + 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) +}) + +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) + 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 }) + 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) + 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) + 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) } +}) + +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') + 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 () => { + 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) + 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 () => { + 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) +}) + +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/) +}) + +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-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-patch.test.mjs b/test/shells-patch.test.mjs index 2c285d19..0217a56b 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')) @@ -112,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', () => { @@ -131,195 +47,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 +89,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) }) }) diff --git a/test/shells-session.test.mjs b/test/shells-session.test.mjs index acf89726..60d7ff27 100644 --- a/test/shells-session.test.mjs +++ b/test/shells-session.test.mjs @@ -17,11 +17,12 @@ 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' -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) } @@ -44,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. */ @@ -78,7 +81,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)) } @@ -112,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() } }) @@ -140,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) } @@ -165,12 +168,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 +181,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') 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/table-worker.test.mjs b/test/table-worker.test.mjs new file mode 100644 index 00000000..12f28499 --- /dev/null +++ b/test/table-worker.test.mjs @@ -0,0 +1,186 @@ +/** 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) + 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 () => { + 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') 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/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/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/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/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/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/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/ptc-sdk.ts b/test/types/ptc-sdk.ts new file mode 100644 index 00000000..2251249b --- /dev/null +++ b/test/types/ptc-sdk.ts @@ -0,0 +1,88 @@ +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] + +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 + +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 + +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 diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json new file mode 100644 index 00000000..9301ec8b --- /dev/null +++ b/test/types/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "../..", + "noEmit": true + }, + "include": [ + "kernel.ts", + "ptc-sdk.ts", + "worker-runtime.ts", + "tui.ts", + "authority.ts", + "profile-temp.ts", + "host-entry.ts", + "panel-collector.ts", + "config.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] diff --git a/test/types/worker-runtime.ts b/test/types/worker-runtime.ts new file mode 100644 index 00000000..5633e636 --- /dev/null +++ b/test/types/worker-runtime.ts @@ -0,0 +1,64 @@ +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 +} + +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') + +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') + +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] diff --git a/test/worker-boundary.test.mjs b/test/worker-boundary.test.mjs new file mode 100644 index 00000000..b2457e54 --- /dev/null +++ b/test/worker-boundary.test.mjs @@ -0,0 +1,117 @@ +/** 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' +import { invoke as ptcCall } from '../lib/ptc/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, missing: new Set() } + 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: state.missing.has('stdout') ? undefined : { readFrom: () => ({ text: state.text, lossy: false }) }, + stderr: state.missing.has('stderr') ? undefined : { 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') + }) +}) +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) + }) +}) +await test('Missing Native collected readers never confirm successful reads or writes', async () => { + await fixture(async ({ ctx, exec, state, signal }) => { + for (const missing of [['stdout'], ['stderr'], ['stdout', 'stderr']]) { + state.missing = new Set(missing) + for (const [tool, args] of [['stat', { path: 'safe.txt' }], ['write_file', { path: 'new.txt', content: 'new', expectedRevision: 'absent' }]]) { + const value = await fileCall(ctx, 'unused', tool, args, 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.message, /output readers are unavailable/) + assert.match(value.error.nextAction, /do not blindly retry/) + } + } + assert.equal(state.spawns, 6); assert.equal(state.observed, 0) + }) +}) +await test('Missing PTC collected readers preserve unknown effects and audit identity', async () => { + await fixture(async ({ ctx, exec, state, signal }) => { + for (const missing of [['stdout'], ['stderr'], ['stdout', 'stderr']]) { + state.missing = new Set(missing) + 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.message, /output readers are unavailable/) + assert.match(value.error.nextAction, /never blindly rerun/); assert.match(value.detailsRef, /^j-/) + } + assert.equal(state.spawns, 3) + }) +}) +report('worker-boundary') diff --git a/test/worker-protocol.test.mjs b/test/worker-protocol.test.mjs new file mode 100644 index 00000000..42ea9903 --- /dev/null +++ b/test/worker-protocol.test.mjs @@ -0,0 +1,136 @@ +/** 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) + assert.equal(workerEnvelope('file', {}, 60000).budget.timeoutMs, 30000) + 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 }) }, + } } + } })) + const exec = execution(root) + for (const [call, tool, args, component, timeout] of [ + [fileCall, 'grep', { path: root, pattern: 'x', requestId: 'model-value' }, 'file', 1234], + [ptcCall, 'batch', { action: 'plan', operations: [] }, 'ptc', WORKER_TIMEOUT_MS], + ]) { + 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) + 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') diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..95244604 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2023", + "lib": [ + "ES2023", + "DOM" + ], + "types": [ + "node" + ], + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "removeComments": false, + "declaration": false, + "sourceMap": false, + "newLine": "lf", + "noEmitOnError": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx" + }, + "include": [ + "src/**/*" + ] +}