diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8b104ab..36a41754 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,8 @@ jobs: - run: npm ci - name: Run native DSH Skill discovery smoke run: npm run test:dsh-native + - name: Run native DSH configured-assets smoke + run: npm run test:dsh-configured-assets-native - name: Run Vitest run: npm run test:ci - name: Verify Harness DSL generated sources diff --git a/docs/adapters/README.md b/docs/adapters/README.md index 5b2257d1..d0204aec 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -2,8 +2,8 @@ This is the single entry point for Claude Code, Codex, Qoder, Cursor, Qwen, GitHub Copilot, Pi, Kimi Code, WorkBuddy, and Grok host boundaries, plus the -DeepSeek Harness (DSH) verified install/discovery and developer-preview session -slices. Do not +DeepSeek Harness (DSH) verified install/discovery, developer-preview +configured-assets, and developer-preview session slices. Do not create `docs/adapters/claude-code.md`, `docs/adapters/codex.md`, `docs/adapters/qoder.md`, `docs/adapters/cursor.md`, `docs/adapters/qwen.md`, `docs/adapters/copilot.md`, `docs/adapters/pi.md`, @@ -45,7 +45,7 @@ project `.kimi-code/skills/`), then runs `/skill:better-harness`. | Kimi Code | Analysis-capable source-local host | `.kimi-plugin/plugin.json` | `scripts/agent-customize/providers/kimi.mjs` | `scripts/session-analysis/platforms/kimi.mjs` | self-contained HTML + Markdown | `AGENTS.md` + `~/.kimi-code/skills` + project `.kimi-code/skills`/`.kimi/skills` + `~/.kimi-code/mcp.json` | `harness evidence-bundle --platform kimi` -> validated `html` render | | WorkBuddy | Analysis-capable source-local host | none (skills install into `~/.workbuddy/skills`) | `scripts/agent-customize/providers/workbuddy.mjs` | `scripts/session-analysis/platforms/workbuddy.mjs` | self-contained HTML + Markdown | `~/.workbuddy` `AGENTS.md` + identity files + `.agents` + `AGENTS.md` | `session-analysis --platform workbuddy sources` -> validated `html` render | | Grok | Analysis-capable source-local host | none (skills install into `~/.grok/skills`) | `scripts/agent-customize/providers/grok.mjs` | `scripts/session-analysis/platforms/grok.mjs` | self-contained HTML + Markdown | `~/.grok` + `.grok` + `.agents` + `AGENTS.md` | `session-analysis --platform grok sources` -> skill symlink -> validated `html` render | -| DeepSeek Harness (DSH) | Verified install/discovery for headless/base and Web `standard`/`code`/`cordis`; partial session evidence (developer preview) | local DSH Cordis policy at `scripts/dsh-skill-discovery/index.mjs`; no lifecycle shell | unavailable | `scripts/session-analysis/platforms/dsh.mjs`; `dsh-v1` for the audited format-0 session-evidence slice from DSH `dsh-v0.1.0-rc.7` and `dsh-v0.1.0-rc.8`, raw `.jsonl` and feature-detected `.jsonl.zstd` | unavailable; no report route | canonical Skill from the complete root; model Skill calls rejected | `npm run test:dsh-native`; read-only session `sources`/`facts` commands remain separate | +| DeepSeek Harness (DSH) | Verified install/discovery for headless/base and Web `standard`/`code`/`cordis`; partial configured assets and session evidence (developer preview) | local DSH Cordis policy at `scripts/dsh-skill-discovery/index.mjs`; no lifecycle shell | `scripts/agent-customize/providers/dsh.mjs`; filesystem Skills and cwd-sensitive Instructions, configured-not-observed | `scripts/session-analysis/platforms/dsh.mjs`; `dsh-v1` for the audited format-0 session-evidence slice from DSH `dsh-v0.1.0-rc.7` and `dsh-v0.1.0-rc.8`, raw `.jsonl` and feature-detected `.jsonl.zstd` | unavailable; no report route | canonical Skill from the complete root; model Skill calls rejected | `npm run test:dsh-native`; `npm run test:dsh-configured-assets-native`; read-only session `sources`/`facts` commands remain separate | ## Read-only Plugin Lifecycle @@ -86,8 +86,8 @@ Kimi Code, Grok, and DSH are absent from this table on purpose: none has a validated native lifecycle contract yet, so lifecycle targets reject them with `UNKNOWN_HOST` instead of borrowing another host's install route. Kimi Code and Grok retain their configured-asset and session evidence. DSH retains its -bounded verified discovery and partial session-evidence slices, but has no -lifecycle profile or native lifecycle claim. +bounded verified discovery, configured-assets, and partial session-evidence +slices, but has no lifecycle profile or native lifecycle claim. The lifecycle commands do not read raw session transcripts, contact a registry, edit host settings, or register an `apply` path. @@ -193,7 +193,7 @@ edit host settings, or register an `apply` path. `signals.json`). The adapter honors `GROK_HOME`. Grok has no install shell in this repository; skills install manually into `~/.grok/skills` (symlink is enough for `/better-harness`). -- DeepSeek Harness has two independent bounded slices. Verified +- DeepSeek Harness has independent bounded capabilities. Verified install/discovery uses DSH `0.1.1-rc.2` at audited source `b150a551b8d465e31e418e1b2eaf5e79bbb7d28e`. The sole supported discovery route points the active DSH `skill-filesystem.customSkillDirs` at the @@ -213,6 +213,18 @@ edit host settings, or register an `apply` path. values are not canonical routes. Moving the Better Harness root requires updating every configured absolute path. The credential-free native owner smoke is `npm run test:dsh-native`. +- Its developer-preview configured-assets provider at + `scripts/agent-customize/providers/dsh.mjs` reports native filesystem Skill + winners and byte-budget-represented Instruction sources for an authorized + workspace and cwd. User-home sources are excluded unless + `--include-user-home` is supplied. The evidence is + `configured-not-observed`: runtime/in-process Skills and active Cordis, + Profile, and Preset composition remain unresolved. DSH advertises exactly + `sessionAnalysis` and `agentCustomize`; it does not gain asset-practices, + checkup, evidence-bundle, report, rendering, or output support. See + [DeepSeek Harness Configured Assets](../../references/agent-customize/platforms/dsh.md) + and run the credential-free owner smoke with + `npm run test:dsh-configured-assets-native`. - Separately, DeepSeek Harness has a developer-preview, JSONL-only session adapter at `scripts/session-analysis/platforms/dsh.mjs`. Home resolution is strictly `--dsh-home` over `DSH_HOME` over `~/.dsh`, and the only source root is @@ -222,9 +234,8 @@ edit host settings, or register an `apply` path. workspace evidence. Better Harness reports adapter metadata `dsh-v1`; its format-0 session-evidence slice is validated against `dsh-v0.1.0-rc.7` and `dsh-v0.1.0-rc.8`, including RC8 interrupted assistant messages and the - required team-event vocabulary. The host is registered only for the - `sessionAnalysis` capability; team events are validated and accounted, not - projected as team analytics. + required team-event vocabulary. Team events are validated and accounted, + not projected as team analytics. Known-but-unsupported events and unknown ignorable events are explicitly accounted for. Unknown required events, malformed records, identity drift, committed corruption, and unsupported versions fail closed; an uncommitted @@ -238,8 +249,9 @@ edit host settings, or register an `apply` path. compressed evidence is unavailable while independent raw JSONL evidence remains readable. There is no fallback dependency or shell. The combined DSH boundary does not provide live PTY or process state, - configured assets, plugin lifecycle, a managed shell, manifest or package - integration, report/output routing, README Quickstart, SQLite or custom + complete runtime configured-asset resolution, plugin lifecycle, a managed + shell, manifest or package integration, report/output routing, README + Quickstart, SQLite or custom persistence, automatic optimization, plugin fault or causality attribution, or artifact repair or writes. See [Story #93](https://github.com/QoderAI/better-harness/issues/93) diff --git a/docs/docs/hosts/adapter-matrix.md b/docs/docs/hosts/adapter-matrix.md index 739d5f6d..f8529545 100644 --- a/docs/docs/hosts/adapter-matrix.md +++ b/docs/docs/hosts/adapter-matrix.md @@ -14,12 +14,14 @@ host-neutral. ## Support levels Better Harness currently declares ten more complete capability-level host -adapters plus bounded DSH discovery and session slices. Six have verified public +adapters plus bounded DSH discovery, configured-assets, and session slices. Six +have verified public Quickstart paths. Pi, Kimi Code, WorkBuddy, and Grok are visible as adapter support because their installation and end-to-end evidence boundaries differ from that six-host set. DSH has Verified install/discovery for a qualified -runtime/preset boundary plus a developer-preview session-evidence contract; it -is not a runnable report adapter. The [canonical adapter matrix](https://github.com/QoderAI/better-harness/blob/main/docs/adapters/README.md) +runtime/preset boundary plus developer-preview configured-assets and +session-evidence contracts; it is not a runnable report adapter. The +[canonical adapter matrix](https://github.com/QoderAI/better-harness/blob/main/docs/adapters/README.md) remains the complete capability-level source of truth. ## Supported host adapters @@ -36,7 +38,7 @@ remains the complete capability-level source of truth. | Kimi Code | Adapter support | Analysis-capable source-local host | `.kimi-plugin/plugin.json` | Workspace-matching Kimi wire transcripts | Self-contained HTML + Markdown | | WorkBuddy | Adapter support | Analysis-capable source-local host | None; skills use WorkBuddy-owned paths | Workspace-matching WorkBuddy JSONL transcripts | Self-contained HTML + Markdown | | Grok | Adapter support | Analysis-capable source-local host | None; skills use Grok-owned paths | Workspace-matching Grok session dirs (`updates.jsonl`) | Self-contained HTML + Markdown | -| DeepSeek Harness (DSH) | Verified install/discovery | Qualified headless/base and Web `standard`/`code`/`cordis`; partial session evidence | Local DSH Cordis policy; no lifecycle shell | DSH JSONL backend session format `0`: raw `.jsonl` and feature-detected `.jsonl.zstd` | Unavailable | +| DeepSeek Harness (DSH) | Verified install/discovery | Qualified headless/base and Web `standard`/`code`/`cordis`; partial configured-assets and session evidence | Local DSH Cordis policy; no lifecycle shell | DSH JSONL backend session format `0`: raw `.jsonl` and feature-detected `.jsonl.zstd` | Unavailable | The `@qoder-ai/better-harness` npm package includes all seven plugin metadata roots. Pi reuses install metadata in the existing `package.json`, so it does @@ -145,6 +147,22 @@ Harness root requires reconfiguring every absolute path. The Installation page documents the configuration boundary; run the pinned, credential-free owner smoke with `npm run test:dsh-native`. +DSH also has a developer-preview configured-assets provider. It reports native +filesystem Skill winners and cwd-sensitive Instruction sources as +configured-not-observed evidence: + +```bash +better-harness agent-customize inventory --provider dsh --workspace [--cwd ] [--dsh-home ] [--include-user-home[=true]] +``` + +User-home Skills and Instructions are not read by default. Runtime/in-process +Skills and active Cordis, Profile, and Preset composition remain unresolved. +The host advertises exactly `sessionAnalysis` and `agentCustomize`; this does +not add asset-practices, evidence-bundle, report, rendering, or output support. +Repository contributors can run the pinned credential-free comparison with +`npm run test:dsh-configured-assets-native`. See +[DeepSeek Harness Configured Assets](https://github.com/QoderAI/better-harness/blob/main/references/agent-customize/platforms/dsh.md). + Separately, DSH has a developer-preview JSONL session slice with Better Harness adapter metadata `dsh-v1`. Its format-0 session-evidence slice is validated against DSH `dsh-v0.1.0-rc.7` and `dsh-v0.1.0-rc.8`, including RC8 @@ -153,8 +171,7 @@ are validated and accounted, not projected as team analytics. Home resolution is strictly `--dsh-home` over `DSH_HOME` over `~/.dsh`; the only source root is `/sessions`. The adapter reads the fixed nested `session.jsonl` or `session.jsonl.zstd` layout without writing or repairing artifacts, and it -qualifies a workspace only from the header's absolute `cwd`. DSH is registered -only for the `sessionAnalysis` capability. +qualifies a workspace only from the header's absolute `cwd`. Compressed artifacts are concatenated independently checksummed Zstandard frames and are validated and decompressed one complete frame at a time. The @@ -176,9 +193,9 @@ node scripts/session-analysis.mjs sources --platform dsh --workspace [--d ``` Verified discovery does not imply a complete report loop. DSH has no live -PTY/process integration, configured-assets support, plugin lifecycle, managed -shell, manifest, package integration, report/output route, public Quickstart, -SQLite or custom persistence support, automatic +PTY/process integration, complete runtime configured-asset resolution, plugin +lifecycle, managed shell, manifest, package integration, report/output route, +public Quickstart, SQLite or custom persistence support, automatic optimization, plugin-fault attribution, or artifact mutation/recovery. See the [canonical source matrix](https://github.com/QoderAI/better-harness/blob/main/docs/adapters/README.md) and [Story #93](https://github.com/QoderAI/better-harness/issues/93). diff --git a/docs/docs/installation.mdx b/docs/docs/installation.mdx index a65fc4cd..ef4ead88 100644 --- a/docs/docs/installation.mdx +++ b/docs/docs/installation.mdx @@ -134,16 +134,36 @@ Better Harness root moves, update both the `customSkillDirs` value and policy plugin path/configuration. Repository contributors can repeat the pinned, credential-free native proof with `npm run test:dsh-native`. -This maturity level does not provide configured assets, evidence-bundle or -report registration, output routing, rendering, lifecycle management, -MCP/profile product support, Web `minimal`, or a full report workflow. The +### Inventory configured assets + +The independent developer-preview configured-assets provider reports native +filesystem Skill winners and cwd-sensitive Instruction sources as +configured-not-observed evidence: + +```bash +better-harness agent-customize inventory --provider dsh --workspace [--cwd ] [--dsh-home ] [--include-user-home[=true]] +``` + +The default excludes user-home DSH and Agents roots. Add +`--include-user-home` only when those sources are intentionally in scope. +Runtime/in-process Skills and active Cordis, Profile, and Preset composition +remain unresolved, so the inventory does not prove that any asset was used. +Repository contributors can repeat the pinned, credential-free native +comparison with `npm run test:dsh-configured-assets-native`. The complete +configured-assets boundary is documented in the +[adapter matrix](./hosts/adapter-matrix#deepseek-harness-dsh). + +This maturity level does not provide complete runtime configured-asset +resolution, evidence-bundle or report registration, output routing, rendering, +lifecycle management, MCP/profile product support, Web `minimal`, or a full +report workflow. The [adapter matrix](./hosts/adapter-matrix#deepseek-harness-dsh) tracks the exact boundary. :::tip Don't see your Coding Agent? The six tabs below are the verified Quickstart paths, while the project tracks -ten fuller host adapters plus bounded DSH slices. [Compare all adapter support boundaries](./hosts/adapter-matrix), +ten fuller host adapters plus bounded DSH capabilities. [Compare all adapter support boundaries](./hosts/adapter-matrix), then [follow the new-host contribution workflow and worked pull requests](./hosts/contributing-new-coding-agent) if you want to add or complete an integration. You can also [browse current repository pull requests](https://github.com/QoderAI/better-harness/pulls) diff --git a/docs/specs/2026-08-19-67-antigravity-plugin-artifact.md b/docs/specs/2026-08-19-67-antigravity-plugin-artifact.md index 67f654ac..274e71a4 100644 --- a/docs/specs/2026-08-19-67-antigravity-plugin-artifact.md +++ b/docs/specs/2026-08-19-67-antigravity-plugin-artifact.md @@ -108,8 +108,8 @@ The generated root basename is `better-harness`. Its positive allowlist is: - `scripts/**` except `scripts/packaging/**`; - `references/**`, `templates/**`, `models/**`, `hooks/**`, `docs/**`, and `case-studies/**`; -- `node_modules/@vscode/tree-sitter-wasm/**` and - `node_modules/esbuild-wasm/**`, including package metadata and licenses. +- `node_modules/@vscode/tree-sitter-wasm/**`, `node_modules/esbuild-wasm/**`, + and `node_modules/yaml/**`, including package metadata and licenses. The builder copies only regular files and directories reached without following symbolic links. The verifier rejects unknown roots, other Skills and host @@ -125,8 +125,8 @@ and `dependencies`. It binds: `module`; - `bin` to the singleton `{ "better-harness": "scripts/better-harness.mjs" }`; - `engines` to exact nonblank `node` and `npm` entries projected from source; -- `dependencies` to exact version-bound `@vscode/tree-sitter-wasm` and - `esbuild-wasm` entries. +- `dependencies` to exact version-bound `@vscode/tree-sitter-wasm`, + `esbuild-wasm`, and `yaml` entries. The builder projects fresh nested objects and rejects missing, extra, blank, non-string, or wrong source runtime metadata rather than aliasing source @@ -145,10 +145,10 @@ Repository-only navigation uses absolute upstream HTTPS URLs and is not copied or traversed. `pathname:` is treated only as a non-local Docusaurus route; `file:` and unknown schemes fail closed. -The verified pinned artifact contains a Markdown closure of exactly 93 nodes, -290 edges, and 96 files. Runtime analysis starts at +The verified pinned artifact contains a Markdown closure of exactly 106 nodes, +305 edges, and 109 files. Runtime analysis starts at `scripts/better-harness.mjs` and proves a syntax-aware ESM closure of 19 modules -and 39 edges, plus the exact two packaged dependencies and their licenses. +and 39 edges, plus the exact three packaged dependencies and their licenses. Limits bound files, bytes, depth, nodes, and edges. Three target-only source-integrity repair groups make that shipped closure diff --git a/docs/specs/2026-08-23-101-deepseek-harness-configured-assets.md b/docs/specs/2026-08-23-101-deepseek-harness-configured-assets.md new file mode 100644 index 00000000..a040f7e3 --- /dev/null +++ b/docs/specs/2026-08-23-101-deepseek-harness-configured-assets.md @@ -0,0 +1,383 @@ +# DeepSeek Harness Configured Assets + +## Traceability + +- Spec ID: deepseek-harness-configured-assets +- Story: #101 +- Status: Implemented +- Approved scope: [Issue #101](https://github.com/QoderAI/better-harness/issues/101) +- Qualified DSH release: `0.1.1-rc.2` +- Qualified DSH source: `b150a551b8d465e31e418e1b2eaf5e79bbb7d28e` + +## Intent + +Better Harness already has independent DeepSeek Harness (DSH) session-evidence +and verified Skill-discovery slices, but `agent-customize` cannot report which +filesystem Skills and cwd-sensitive Instructions a DSH environment is +configured to use. This Story adds one bounded DSH configured-assets provider. + +The provider reports effective filesystem Skill winners in `manage.skills` and +applicable, byte-budget-represented Instruction sources in `manage.rules`. +Configured or applicable state is not proof that a Skill was invoked or an +Instruction influenced a session. Runtime/in-process Skill providers and the +active Cordis, Profile, and Preset composition remain unresolved. + +The implementation is one PR and one provider at +`scripts/agent-customize/providers/dsh.mjs`. DSH gains only +`AGENT_CUSTOMIZE`; it does not gain `ASSET_PRACTICES`, report, output, +evidence-bundle, lifecycle, or Quickstart support. + +### Native authority + +The implementation is pinned to these DSH owners: + +1. [Filesystem Skill provider](https://github.com/deepseek-ai/deepseek-harness/blob/b150a551b8d465e31e418e1b2eaf5e79bbb7d28e/packages/skill/skill-filesystem/src/index.ts) +2. [Skill registry](https://github.com/deepseek-ai/deepseek-harness/blob/b150a551b8d465e31e418e1b2eaf5e79bbb7d28e/packages/skill/skill/src/index.ts) +3. [Instruction configuration](https://github.com/deepseek-ai/deepseek-harness/blob/b150a551b8d465e31e418e1b2eaf5e79bbb7d28e/packages/context/agent-instructions/src/config.ts) +4. [Instruction discovery and loading](https://github.com/deepseek-ai/deepseek-harness/blob/b150a551b8d465e31e418e1b2eaf5e79bbb7d28e/packages/context/agent-instructions/src/files.ts) +5. [Instruction rendering and budgeting](https://github.com/deepseek-ai/deepseek-harness/blob/b150a551b8d465e31e418e1b2eaf5e79bbb7d28e/packages/context/agent-instructions/src/render.ts) + +Later DSH versions are not implicitly qualified. + +## Acceptance Scenarios + +### AC-1: Provider and capability ownership + +DSH is registered in the existing `agent-customize` provider map and advertises +exactly `SESSION_ANALYSIS` plus `AGENT_CUSTOMIZE`. It remains absent from +`ASSET_PRACTICES` and every report, rendering, evidence-bundle, and checkup +capability projection. + +### AC-2: Standard inventory envelope + +The provider returns the existing inventory envelope with `provider: "dsh"`, +`workspace`, `cwd`, `projectRoot`, standard tabs, `manage.skills`, +`manage.rules`, and empty Plugin, MCP, Subagent, Command, and Hook collections. +No new shared item schema is introduced. + +### AC-3: Native filesystem Skill discovery + +The provider discovers only native one-level Skill layouts: + +- `//SKILL.md` +- `/.md` + +Entries in each root use native `localeCompare` order. Arbitrary nested Skill +files and unsupported entries are excluded. `.system` is skipped only in the +user DSH root. The validated frontmatter `name`, not the filename, is Skill +identity. Active results are sorted by DSH's code-point name ordering. + +### AC-4: DSH-compatible Skill validation + +Frontmatter begins with an exact `---` line, ends at an exact `---` line, and +parses to a YAML object. `name` and `description` must be non-empty strings; +names match `^[a-z0-9]+(?:-[a-z0-9]+)*$`. Unknown fields and object-valued +`metadata` are accepted but not emitted. + +Canonical `disable-model-invocation` and `user-invocable` values accept native +booleans, `1`/`0`, and case-insensitive `true`, `false`, `yes`, `no`, `on`, and +`off`. Invalid canonical values and legacy `disableModelInvocation`, +`modelInvocable`, and `userInvocable` fields invalidate the candidate. + +Implementation uses a direct production dependency on exact `yaml@2.9.0`. +The existing lossy Better Harness frontmatter helper is not DSH-compatible. +Skill bodies may be read only as needed to locate frontmatter boundaries and +are never retained in or serialized by the inventory. + +### AC-5: Filesystem precedence and runtime qualification + +Lower ranks win duplicate declared names within the qualified filesystem view: + +| Rank | Source | +| ---: | --- | +| 100 | `/.dsh/skills` | +| 200 | `/.agents/skills` | +| 250 | runtime/in-process registry, unresolved and not inventoried | +| 300 | `customSkillDirs`, in declaration order | +| 400 | `/skills` | +| 500 | `/skills` | +| 600 | bundled Skill root | + +Ties use provider registration order and then provider-local candidate order. +A malformed higher-priority candidate is absent and permits a valid lower +candidate to win. Only the filesystem winner appears in `manage.skills`; +shadowed candidates are diagnostics, never active Skills. The provider never +claims a complete runtime winner because rank-250 and scoped providers are +unresolved. + +### AC-6: Workspace, cwd, and project authorization + +`workspace` defaults to `process.cwd()`. `cwd` defaults to workspace and must +exist as a directory equal to or lexically inside workspace. Missing or +non-directory inputs and an outside cwd fail before asset scanning. No cwd is +derived from session evidence. + +Workspace is the analysis-selection boundary and cwd-containment boundary, not +a universal filesystem sandbox. Starting at cwd, DSH walks upward to the +nearest existing `.git` file or directory. That directory is `projectRoot`; if +no marker exists, cwd is `projectRoot`. Selecting workspace/cwd authorizes the +fixed native DSH project sources at that root and the root-to-cwd Instruction +chain, including when `projectRoot` is above workspace. Discovery stops at the +nearest project root and does not crawl arbitrary siblings or ancestors above +it. The resolved `projectRoot` is returned at the inventory top level. + +### AC-7: User-home and explicit-root authorization + +`includeUserHome` defaults to `false`. Without opt-in, the provider performs no +`stat`, `readdir`, `readFile`, `createReadStream`, or `realpath` against: + +- `/skills` +- `/skills` +- `/AGENTS.md` +- ambient `DSH_BUNDLED_SKILL_DIR` + +Opt-in authorizes those ambient sources. Supplying `dshHome` or +`dshAgentsHome` alone does not authorize them. Programmatic `customSkillDirs` +and explicit `bundledSkillDir` authorize exactly their named lexical roots, +including off-tree roots, without authorizing siblings and without requiring +user-home opt-in. + +Explicit-root scope is `project` inside workspace, `user` inside the operating +system home, and `other` otherwise. Relative custom/bundled/agents-home values +resolve from `process.cwd()` and do not expand literal `~`. DSH home alone uses +native `resolveDshHome` semantics: an explicit `dshHome`/`dsh-home`/`home` +value has precedence (including an explicit blank value); a blank or +whitespace-only ambient `DSH_HOME` is treated as unset and falls back to +`/.dsh`; and `~`, `~/`, and `~\` prefixes expand against +the operating-system home. + +### AC-8: Native Instruction discovery and order + +When Instructions are enabled, discovery order is: + +1. authorized `/AGENTS.md`; +2. each directory from `projectRoot` through cwd; +3. within each directory, base candidates in configured order, then local + candidates in configured order. + +Defaults are `.git`, base `AGENTS.md`/`CLAUDE.md`, local +`AGENTS.local.md`/`CLAUDE.local.md`, `maxSourceBytes` 1,048,576, and +`maxBytes` 65,536. Candidate values `""`, `.`, `..`, absolute paths, or values +containing `/` or `\` are filtered out. Exact absolute paths are deduplicated +globally with the earliest candidate retained. `manage.rules` preserves native +retained order and bypasses generic rule sorting. + +### AC-9: Instruction content deduplication and source limits + +Instruction candidates must resolve through `stat` to regular files; a final +file symlink is followed. Stat size and streaming UTF-8 byte count enforce +`maxSourceBytes`. Oversized, disappearing, unreadable, broken-link, directory, +and non-file candidates are excluded independently without collapsing other +Instruction sources. + +Loaded raw content is preserved for native render-byte budgeting. A SHA-1 +digest of the trimmed content is used only for deduplication within the same +`dirname(displayPath)`. The earliest same-directory duplicate wins; identical +content in different directories remains. Raw content and digests are never +serialized. + +### AC-10: Aggregate Instruction budget + +Native UTF-8 render accounting includes system-reminder framing, intro text, +source headings/content, and omission/truncation markers even though Better +Harness emits none of that prose. The algorithm: + +1. tries the complete deduplicated list; +2. drops the broadest prefix one source at a time until a suffix fits; +3. when no suffix fits, keeps only the most-specific source and binary-searches + a UTF-8-safe content prefix using full and then compact intro text; +4. represents a genuinely empty file if its heading survives; +5. does not represent a non-empty file truncated to zero; +6. represents no source for a notice-only fallback. + +Only represented sources enter `manage.rules`. Non-positive or non-finite +`maxBytes` or `maxSourceBytes` disables Instruction collection before any +Instruction probe. + +### AC-11: Path and symlink evidence + +Native final-component behavior is preserved for file and directory Skill +symlinks and file Instruction symlinks. Broken links and non-file targets are +excluded. Authorization attaches to the configured lexical root, so a link +beneath an authorized project/user/explicit root may resolve off-tree. Returned +evidence retains the lexical configured path and never exposes the target +realpath. Paths use `node:path` and support Windows, macOS, Linux, spaces, and +Unicode without assuming POSIX separators. + +### AC-12: Minimal diagnostics and evidence claim + +Diagnostics use this provider-local shape: + +```js +{ + qualifiedDshVersion: "0.1.1-rc.2", + qualifiedDshSourceSha: "b150a551b8d465e31e418e1b2eaf5e79bbb7d28e", + evidenceKind: "configured-not-observed", + configurationSource: "qualified-defaults" | "caller-overrides", + userHomeCollection: "included" | "not-authorized", + instructionCollection: "enabled" | "disabled-by-byte-limit", + runtimeResolution: { + cordis: false, + profile: false, + preset: false, + runtimeSkills: false + }, + shadowedSkills: [], + skippedSkills: [], + instructionDecisions: [], + diagnosticsTruncated: false +} +``` + +Skill reasons are limited to `missing-skill-file`, `malformed`, +`invalid-name`, `invalid-invocation`, `unsupported-entry`, and `unavailable`. +Instruction reasons are limited to `unavailable`, `source-too-large`, +`duplicate-content`, `budget-omitted`, `budget-truncated`, and +`budget-not-represented`. Diagnostic lists are deterministically bounded by an +internal provider constant; its numeric value is not public API. No aggregate +counters, per-list omitted counters, or exact unsupported English prose are +contractual. + +### AC-13: Minimal CLI + +The public DSH-relevant CLI is limited to: + +```text +--provider dsh +--workspace +--cwd +--dsh-home +--include-user-home[=] +``` + +Only `cwd` and DSH-scoped `includeUserHome` require new forwarding. Agents home, +default-root control, custom/bundled roots, root markers, candidate arrays, and +byte budgets remain programmatic-only in P0 and do not appear in help. + +### AC-14: Native credential-free proof + +A test-only native smoke installs pinned public DSH packages in a temporary +prefix and compares native Skills, Instructions, authorization, symlink, path, +deduplication, and budgeting outcomes with Better Harness. It performs no model +request, uses no API key or authenticated service, runs on the repository's +Windows/macOS/Linux matrix, and cleans temporary state in `finally`. + +## Provider API and return contract + +The canonical API is: + +```js +export async function collectDshCustomizeInventory(options = {}) +``` + +Public CLI-backed options are `workspace`, `cwd`, `dshHome`/`dsh-home`/`home`, +and `includeUserHome`. Programmatic-only options are `dshAgentsHome`, +`includeDefaultRoots`, `customSkillDirs`, `bundledSkillDir`, +`projectRootMarkers`, `instructionFileCandidates`, +`localInstructionFileCandidates`, `maxBytes`, and `maxSourceBytes`. + +The return envelope is: + +```js +{ + generatedAt, + provider: "dsh", + dshHome, + dshAgentsHome, + workspace, + cwd, + projectRoot, + tabs: MANAGE_TABS, + plugins: [], + manage: { + plugins: [], + mcps: [], + skills: [], + subagents: [], + rules: [], + commands: [], + hooks: [] + }, + diagnostics, + unsupported: [] +} +``` + +Skill items use the existing `skill` shape with validated declared name, +description, scope, source label/kind, lexical file path, and evidence. Rule +items use the existing `rule` shape with display path as name, fixed +non-content description, scope, source label/kind, lexical path, and evidence. +Neither item exposes body text, Instruction prose, metadata, invocation policy, +rank, digest, rendered text, or real target. + +## Non-goals + +- runtime/in-process or scoped Skill enumeration +- complete Cordis, Profile, Preset, or provider-registration resolution +- dynamic post-start Instruction reconciliation +- MCP, Plugin, Profile, lifecycle, or managed-shell inventory +- `ASSET_PRACTICES`, checkup, practice reports, lint, or baselines +- evidence-bundle, report, output, or rendering integration +- session-analysis changes +- Public Quickstart or complete DSH host parity +- upstream DSH changes +- a new shared schema or generic Skill collector +- any claim that configured assets were used at runtime + +## Plan and Tasks + +1. Add RED tests for the exact host capability and missing canonical provider. +2. Add RED provider tests for AC-2 through AC-13 using synthetic temporary + roots and a lazy provider loader. +3. Add the credential-free native comparison smoke for AC-14. +4. Add direct production `yaml@2.9.0` and its lockfile entry during GREEN. +5. Implement the single DSH provider without changing generic recursive Skill + collection or shared item schemas. +6. Register only the provider and `AGENT_CUSTOMIZE` capability. +7. Forward only the minimal public CLI options. +8. Update bounded DSH configured-assets references and adapter documentation. +9. Run focused tests, native smokes, cross-platform CI, docs/link checks, + `npm run check`, and pack verification. +10. Perform Story/spec/test/risk traceability review before commit and PR. + +## Test and Review Evidence + +| Acceptance | Required evidence | +| --- | --- | +| AC-1 | Host-support and provider-map architecture tests | +| AC-2, AC-12 | Provider envelope, content-safety, and minimal diagnostics tests | +| AC-3, AC-4 | Layout and standards-compatible YAML validation fixtures | +| AC-5 | Root-rank, malformed-fallback, custom-order, shadowing, and runtime-boundary fixtures | +| AC-6 | Workspace-below-project-root, cwd validation, and nearest-marker fixtures | +| AC-7 | Node permission-model no-read proof plus opt-in and explicit-root fixtures | +| AC-8, AC-9 | Ordered hierarchy, candidate filtering, path/content dedup, and source-failure fixtures | +| AC-10 | Full-fit, suffix, UTF-8 truncation, empty, zero-content, and notice-only fixtures | +| AC-11 | File/directory/broken symlink and lexical-evidence fixtures on supported platforms | +| AC-13 | CLI help and argument-forwarding process tests | +| AC-14 | Pinned native owner comparison with no credentials and `finally` cleanup | + +RED is valid only when fixtures parse and initialize successfully and failures +identify absent #101 production behavior. Existing relevant tests must be green +before RED changes and remain green afterward except for the intentionally +changed DSH capability expectation. + +### Risks + +| Risk | Mitigation | +| --- | --- | +| Filesystem view overstated as runtime truth | `configured-not-observed`, unresolved runtime fields, and filesystem-only tests | +| Implicit access to user data | Default-closed permission-model test proving no filesystem probes | +| Repository ancestry broadens unexpectedly | Nearest-marker and fixed-candidate/root tests; expose `projectRoot` | +| YAML behavior drifts from native DSH | Direct pinned YAML dependency plus native compatibility fixtures | +| Generic rule sorting changes precedence | Assert exact `manage.rules` order through the public provider | +| Byte-budget approximation changes represented sources | Compare outcomes with native DSH renderer in the smoke | +| Symlink target leaks | Assert lexical evidence and serialized absence of real target/content | +| Developer-preview churn | Pin release/SHA and require requalification for later DSH versions | + +## Documentation claim boundary + +After every acceptance scenario passes, documentation may describe a qualified +developer-preview DSH configured-assets slice for filesystem Skills and +cwd-sensitive Instructions. It must continue to distinguish configured assets +from observed use and must not claim full runtime configuration, lifecycle, +report-loop, evidence-bundle, `ASSET_PRACTICES`, or Public Quickstart support. diff --git a/package-lock.json b/package-lock.json index 261878bb..66a05b3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ ], "dependencies": { "@vscode/tree-sitter-wasm": "0.3.1", - "esbuild-wasm": "0.28.2" + "esbuild-wasm": "0.28.2", + "yaml": "2.9.0" }, "bin": { "better-harness": "scripts/better-harness.mjs" @@ -6910,6 +6911,21 @@ "node": ">=16.0.0" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", diff --git a/package.json b/package.json index d71852a1..3ddc0053 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "preview:canvas": "node scripts/harness-analysis/canvas-preview-server.mjs", "test": "vitest run", "test:ci": "vitest run --config vitest.ci.config.mjs", + "test:dsh-configured-assets-native": "node scripts/dsh-configured-assets/native-smoke.mjs", "test:dsh-native": "node scripts/dsh-skill-discovery/native-smoke.mjs" }, "packageManager": "npm@10.9.3", @@ -116,7 +117,8 @@ }, "dependencies": { "@vscode/tree-sitter-wasm": "0.3.1", - "esbuild-wasm": "0.28.2" + "esbuild-wasm": "0.28.2", + "yaml": "2.9.0" }, "devDependencies": { "conventional-changelog-cli": "^4.1.0", diff --git a/references/agent-customize/README.md b/references/agent-customize/README.md index e94cce3c..887bf60a 100644 --- a/references/agent-customize/README.md +++ b/references/agent-customize/README.md @@ -16,7 +16,8 @@ authority, routing, overlap, observed use, and maintenance boundaries. - Inventory and authority: `global-assets.md`. - Provider-specific notes: `platforms/claude.md`, `platforms/codex.md`, `platforms/qoder.md`, `platforms/qwen.md`, `platforms/copilot.md`, - `platforms/pi.md`, `platforms/kimi.md`, and `platforms/workbuddy.md`. + `platforms/pi.md`, `platforms/kimi.md`, `platforms/workbuddy.md`, and + `platforms/dsh.md`. ## Does Not Own diff --git a/references/agent-customize/global-assets.md b/references/agent-customize/global-assets.md index 22917345..dee179db 100644 --- a/references/agent-customize/global-assets.md +++ b/references/agent-customize/global-assets.md @@ -1,7 +1,8 @@ # Global Coding-Agent Assets Use this reference when a readiness run, screenshot, or user request points to -Cursor, Qoder, Codex, Claude, Qwen, Copilot, or Kimi Code settings, installed +Cursor, Qoder, Codex, Claude, Qwen, Copilot, Kimi Code, or DeepSeek Harness +(DSH) settings, installed assets, global skills, user hooks, commands, agents, plugins, MCPs, or memories. Treat this as a configured asset inventory, not a session behavior report. @@ -29,14 +30,15 @@ report. Run the read-only inventory when user-home or installed assets are in scope: ```bash - /scripts/agent-customize/cli.mjs inventory --provider --workspace + /scripts/agent-customize/cli.mjs inventory --provider --workspace /scripts/coding-agent-practices/inventory.mjs --workspace --include-user-home --include-memories --format markdown coding-agent-practices asset-integrity --workspace --language --json [--include-memories] [--include-user-home] ``` Use `--cursor-home `, `--qoder-home `, `--codex-home `, `--claude-home `, `--qwen-home `, `--copilot-home `, -`--kimi-home `, `--claude-state `, `--codex-app-path `, or +`--kimi-home `, `--dsh-home `, `--claude-state `, +`--codex-app-path `, or `--shared-cache ` for fixtures, alternate installs, or non-standard homes. Use the `agent-customize` command as the provider-specific configured asset source of truth; use the @@ -51,6 +53,10 @@ continue with [Claude Code Configured Assets](platforms/claude.md). For Kimi Code configured-asset locations and evidence boundaries, continue with [Kimi Code Configured Assets](platforms/kimi.md). +For DSH's filesystem-only winners, cwd-sensitive Instruction sources, privacy +default, and unresolved runtime boundary, continue with +[DeepSeek Harness Configured Assets](platforms/dsh.md). + The provider-labelled asset-integrity command reuses that inventory for a lightweight second pass. It checks Memory filename-title collisions/similarity, enabled Plugin canonical/display-name and non-empty capability-fingerprint overlap, diff --git a/references/agent-customize/platforms/dsh.md b/references/agent-customize/platforms/dsh.md new file mode 100644 index 00000000..ad68baa1 --- /dev/null +++ b/references/agent-customize/platforms/dsh.md @@ -0,0 +1,72 @@ +# DeepSeek Harness Configured Assets + +Use this reference for the bounded DeepSeek Harness (DSH) configured-assets +provider. It is qualified against DSH `0.1.1-rc.2` at source +`b150a551b8d465e31e418e1b2eaf5e79bbb7d28e`; later versions are not implicitly +qualified. + +## Inventory + +Run the read-only CLI from an authorized workspace: + +```bash +better-harness agent-customize inventory --provider dsh --workspace [--cwd ] [--dsh-home ] [--include-user-home[=true]] +``` + +`workspace` is the analysis-selection and cwd-containment boundary. `cwd` +defaults to the workspace and must be that directory or lexically beneath it. +DSH walks upward from cwd to the nearest `.git` marker; that directory is the +project root, or cwd is used when no marker exists. + +User-home collection is off by default. Without `--include-user-home`, the +provider does not inspect DSH or Agents user Skill roots, DSH's global +`AGENTS.md`, or an ambient bundled Skill root. `--dsh-home` selects a home but +does not authorize reading those sources by itself. + +## Filesystem Skills + +Only DSH's native one-level forms are discovered: `//SKILL.md` +and `/.md`. Valid YAML frontmatter supplies the Skill identity and +description. Duplicate declared names use this filesystem precedence: + +1. project `.dsh/skills`; +2. project `.agents/skills`; +3. programmatic custom Skill roots, in declaration order; +4. authorized DSH user Skills; +5. authorized Agents user Skills; +6. an explicitly authorized or opted-in bundled Skill root. + +Only the winning filesystem definition enters `manage.skills`; malformed and +shadowed candidates remain bounded diagnostics. The runtime/in-process rank +between project and custom Skills is not enumerable, so the result is never a +claim about the complete runtime winner. + +## Instructions + +Authorized Instructions are considered in native order: DSH's global +`AGENTS.md`, then every directory from project root through cwd, with base +`AGENTS.md`/`CLAUDE.md` candidates before local +`AGENTS.local.md`/`CLAUDE.local.md` candidates in each directory. Regular-file, +source-byte, same-directory content-deduplication, and aggregate rendered-byte +limits decide which sources appear in `manage.rules`. Better Harness returns +paths and non-content descriptions only; it never serializes Instruction text, +digests, rendered framing, or symlink targets. + +## Evidence Boundary + +Every result is `configured-not-observed`. Filesystem configuration and +applicability do not prove runtime use. Runtime/in-process or scoped Skill +providers and active Cordis, Profile, and Preset composition remain unresolved. +DSH advertises only `sessionAnalysis` and `agentCustomize`; this provider does +not add asset-practices, checkup, evidence-bundle, report, rendering, output, +lifecycle, managed-shell, or public Quickstart support. + +Repository contributors can compare the Better Harness collector with pinned +native DSH behavior without credentials or a model request: + +```bash +npm run test:dsh-configured-assets-native +``` + +The canonical acceptance contract is the +[dated configured-assets specification](../../../docs/specs/2026-08-23-101-deepseek-harness-configured-assets.md). diff --git a/references/agent-customize/routing.md b/references/agent-customize/routing.md index 398ece2a..517c8d9c 100644 --- a/references/agent-customize/routing.md +++ b/references/agent-customize/routing.md @@ -31,13 +31,15 @@ Route by ownership before choosing a vendor-specific feature: - Agent guides (`AGENTS.md`, `CLAUDE.md`, Copilot, Cursor, Qoder rules) -> `agents-md-review.md`. -- Cursor/Qoder/Codex/Claude/Qwen/Copilot/Kimi project or user assets -> +- Cursor/Qoder/Codex/Claude/Qwen/Copilot/Kimi/DSH project or user assets -> `global-assets.md`; for Claude-specific configured-asset scope, then load `platforms/claude.md`; for Codex-specific operating practice, then load `platforms/codex.md`; for Qoder-specific feature taxonomy, then load `platforms/qoder.md`; for Copilot-specific operating practice, then load `platforms/copilot.md`; for Kimi-specific configured-asset scope, then load - `platforms/kimi.md`. For installed, user-home, settings screenshot, plugin + `platforms/kimi.md`; for DSH filesystem Skills and cwd-sensitive + Instructions, then load `platforms/dsh.md`. For installed, user-home, + settings screenshot, plugin cache, or memory scope, run the Global/User Asset Pass. - Prior decision, user correction, remembered preference, stale recall, cross-window adoption, or memory-safety question -> `memory-review.md` after diff --git a/scripts/agent-customize/cli.mjs b/scripts/agent-customize/cli.mjs index c98eee1e..ce5021c8 100644 --- a/scripts/agent-customize/cli.mjs +++ b/scripts/agent-customize/cli.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { parseArgs } from "../session-analysis/index.mjs"; +import { parseArgs, parseBooleanFlag } from "../session-analysis/index.mjs"; import { HOST_CAPABILITIES, hostHomeOptionKeys, @@ -25,6 +25,7 @@ function usage() { "Collect configured agent-customize inventory for one provider as JSON.", `Provider home overrides: ${CUSTOMIZE_HOME_OPTIONS.slice(0, 4).join(", ")},`, `${CUSTOMIZE_HOME_OPTIONS.slice(4).join(", ")}, --claude-state, --codex-app-path, --qoder-shared-client-cache-root.`, + "DSH configured-assets options: --cwd , --include-user-home[=].", "", ].join("\n"); } @@ -48,11 +49,14 @@ function summarize(inventory, options) { piHome: inventory.piHome, workbuddyHome: inventory.workbuddyHome, grokHome: inventory.grokHome, + dshHome: inventory.dshHome, claudeStatePath: inventory.claudeStatePath, kimiHome: inventory.kimiHome, codexAppPath: inventory.codexAppPath, sharedClientCacheRoot: inventory.sharedClientCacheRoot, workspace: inventory.workspace, + cwd: inventory.cwd, + projectRoot: inventory.projectRoot, tab, query: options.query ?? "", scopeKind: options.scope ?? options["scope-kind"], @@ -91,6 +95,12 @@ async function main() { codexAppPath: options["codex-app-path"], qoderSharedClientCacheRoot: options["qoder-shared-client-cache-root"] ?? options["shared-client-cache-root"], workspace: options.workspace, + ...(options.provider === "dsh" + ? { + cwd: options.cwd, + includeUserHome: parseBooleanFlag(options["include-user-home"] ?? false), + } + : {}), }); const payload = command === "manage" diff --git a/scripts/agent-customize/providers/dsh.mjs b/scripts/agent-customize/providers/dsh.mjs new file mode 100644 index 00000000..e2c11be0 --- /dev/null +++ b/scripts/agent-customize/providers/dsh.mjs @@ -0,0 +1,769 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { parse as parseYaml } from "yaml"; + +import { MANAGE_TABS } from "../constants.mjs"; + +const QUALIFIED_DSH_VERSION = "0.1.1-rc.2"; +const QUALIFIED_DSH_SOURCE_SHA = "b150a551b8d465e31e418e1b2eaf5e79bbb7d28e"; +const DIAGNOSTIC_LIST_LIMIT = 100; +const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const DEFAULT_MAX_BYTES = 65_536; +const DEFAULT_MAX_SOURCE_BYTES = 1_048_576; +const DEFAULT_INSTRUCTION_CANDIDATES = ["AGENTS.md", "CLAUDE.md"]; +const DEFAULT_LOCAL_INSTRUCTION_CANDIDATES = ["AGENTS.local.md", "CLAUDE.local.md"]; +const SYSTEM_REMINDER_OPEN = ""; +const SYSTEM_REMINDER_CLOSE = ""; +const WORKSPACE_CONTEXT_INTRO = "The following workspace instructions may be relevant to your work. " + + "Use them as guidance when applicable. More specific instructions take precedence over broader ones. " + + "They do not override system, developer, or direct user instructions."; +const COMPACT_WORKSPACE_CONTEXT_INTRO = "Workspace instructions were omitted or truncated to fit the configured byte budget."; + +function expandDshHome(value) { + if (value === "~") return os.homedir(); + if (value.startsWith("~/") || value.startsWith("~\\")) { + return path.join(os.homedir(), value.slice(2)); + } + return value; +} + +function assertPathOption(options, key) { + if (options[key] !== undefined && typeof options[key] !== "string") { + throw new TypeError(`${key} must be a path string`); + } +} + +function validateOptions(options) { + if (!options || typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("DSH configured-assets options must be an object"); + } + for (const key of ["workspace", "cwd", "dshHome", "dsh-home", "home", "dshAgentsHome", "bundledSkillDir"]) { + assertPathOption(options, key); + } + for (const key of ["includeUserHome", "includeDefaultRoots"]) { + if (options[key] !== undefined && typeof options[key] !== "boolean") { + throw new TypeError(`${key} must be a boolean`); + } + } + for (const key of [ + "customSkillDirs", + "projectRootMarkers", + "instructionFileCandidates", + "localInstructionFileCandidates", + ]) { + if (options[key] !== undefined && ( + !Array.isArray(options[key]) || options[key].some((value) => typeof value !== "string") + )) { + throw new TypeError(`${key} must be an array of strings`); + } + } + for (const key of ["maxBytes", "maxSourceBytes"]) { + if (options[key] !== undefined && typeof options[key] !== "number") { + throw new TypeError(`${key} must be a number`); + } + } +} + +function isInside(root, candidate) { + const relative = path.relative(root, candidate); + return relative === "" || ( + relative !== ".." + && !relative.startsWith(`..${path.sep}`) + && !path.isAbsolute(relative) + ); +} + +async function requireDirectory(value, field) { + let info; + try { + info = await stat(value); + } catch { + throw new TypeError(`${field} must be an existing directory`); + } + if (!info.isDirectory()) throw new TypeError(`${field} must be an existing directory`); +} + +async function markerExists(directory, marker) { + try { + await stat(path.join(directory, marker)); + return true; + } catch { + return false; + } +} + +async function findProjectRoot(cwd, markers) { + let current = cwd; + for (;;) { + for (const marker of markers) { + if (await markerExists(current, marker)) return current; + } + const parent = path.dirname(current); + if (parent === current) return cwd; + current = parent; + } +} + +function emptyManageCollections() { + return { + plugins: [], + mcps: [], + skills: [], + subagents: [], + rules: [], + commands: [], + hooks: [], + }; +} + +function diagnosticsFor(options, includeUserHome, instructionCollection = "enabled") { + const configuredKeys = [ + "dshHome", + "dsh-home", + "home", + "dshAgentsHome", + "includeUserHome", + "includeDefaultRoots", + "customSkillDirs", + "bundledSkillDir", + "projectRootMarkers", + "instructionFileCandidates", + "localInstructionFileCandidates", + "maxBytes", + "maxSourceBytes", + ]; + return { + qualifiedDshVersion: QUALIFIED_DSH_VERSION, + qualifiedDshSourceSha: QUALIFIED_DSH_SOURCE_SHA, + evidenceKind: "configured-not-observed", + configurationSource: configuredKeys.some((key) => options[key] !== undefined) + ? "caller-overrides" + : "qualified-defaults", + userHomeCollection: includeUserHome ? "included" : "not-authorized", + instructionCollection, + runtimeResolution: { + cordis: false, + profile: false, + preset: false, + runtimeSkills: false, + }, + shadowedSkills: [], + skippedSkills: [], + instructionDecisions: [], + diagnosticsTruncated: false, + }; +} + +function appendDiagnostic(diagnostics, collection, value) { + if (diagnostics[collection].length < DIAGNOSTIC_LIST_LIMIT) { + diagnostics[collection].push(value); + } else { + diagnostics.diagnosticsTruncated = true; + } +} + +function classifyExplicitScope(root, workspace) { + if (isInside(workspace, root)) return "project"; + if (isInside(path.resolve(os.homedir()), root)) return "user"; + return "other"; +} + +function skillSourceLabel(sourceKind) { + if (sourceKind.startsWith("project-")) return "DeepSeek Harness project"; + if (sourceKind.startsWith("user-")) return "User"; + if (sourceKind === "bundled") return "DeepSeek Harness bundled"; + return "DeepSeek Harness custom"; +} + +function skillEvidence(filePath, root) { + return { path: filePath, relativePath: path.relative(root, filePath) }; +} + +function resolveSkillRoots({ + options, + workspace, + projectRoot, + dshHome, + dshAgentsHome, + includeUserHome, +}) { + const includeDefaultRoots = options.includeDefaultRoots !== false; + const roots = []; + let order = 0; + const add = (rootPath, sourceKind, rank, scope, skipSystem = false) => { + roots.push({ + path: path.resolve(rootPath), + sourceKind, + rank, + scope, + skipSystem, + order: order += 1, + }); + }; + if (includeDefaultRoots) { + add(path.join(projectRoot, ".dsh", "skills"), "project-dsh", 100, "project"); + add(path.join(projectRoot, ".agents", "skills"), "project-agents", 200, "project"); + } + for (const customRoot of options.customSkillDirs ?? []) { + const resolved = path.resolve(customRoot); + add(resolved, "custom", 300, classifyExplicitScope(resolved, workspace)); + } + if (includeDefaultRoots && includeUserHome) { + add(path.join(dshHome, "skills"), "user-dsh", 400, "user", true); + add(path.join(dshAgentsHome, "skills"), "user-agents", 500, "user"); + } + const explicitBundled = options.bundledSkillDir; + const ambientBundled = includeDefaultRoots && includeUserHome + ? process.env.DSH_BUNDLED_SKILL_DIR || undefined + : undefined; + const bundled = explicitBundled ?? ambientBundled; + if (bundled !== undefined) { + const resolved = path.resolve(bundled); + add(resolved, "bundled", 600, classifyExplicitScope(resolved, workspace)); + } + return roots; +} + +function closingFrontmatter(raw, start) { + let lineStart = start; + while (lineStart <= raw.length) { + const nextNewline = raw.indexOf("\n", lineStart); + const lineEnd = nextNewline < 0 ? raw.length : nextNewline; + const line = raw.slice(lineStart, lineEnd).replace(/\r$/u, ""); + if (line === "---") { + return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 }; + } + if (nextNewline < 0) return undefined; + lineStart = nextNewline + 1; + } + return undefined; +} + +function parseSkillFrontmatter(raw) { + const firstLineEnd = raw.indexOf("\n"); + if (firstLineEnd < 0) return undefined; + if (raw.slice(0, firstLineEnd).replace(/\r$/u, "") !== "---") return undefined; + const start = firstLineEnd + 1; + const closing = closingFrontmatter(raw, start); + if (!closing) return undefined; + const value = parseYaml(raw.slice(start, closing.start)); + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return value; +} + +function invocationBoolean(data, key) { + if (!Object.hasOwn(data, key)) return undefined; + const value = data[key]; + if (typeof value === "boolean") return value; + if (value === 1 || value === "1") return true; + if (value === 0 || value === "0") return false; + if (typeof value === "string") { + const normalized = value.toLowerCase(); + if (["true", "yes", "on"].includes(normalized)) return true; + if (["false", "no", "off"].includes(normalized)) return false; + } + throw new TypeError(`${key} must be a native DSH boolean`); +} + +function validateInvocation(data) { + for (const legacy of ["disableModelInvocation", "modelInvocable", "userInvocable"]) { + if (Object.hasOwn(data, legacy)) throw new TypeError(`${legacy} is not supported by DSH`); + } + invocationBoolean(data, "disable-model-invocation"); + invocationBoolean(data, "user-invocable"); +} + +function errorCode(error) { + return error && typeof error === "object" && "code" in error ? error.code : undefined; +} + +async function skillEntryKind(entryPath, entry, diagnostics) { + if (entry.isDirectory()) return "directory"; + if (entry.isFile()) return "file"; + if (!entry.isSymbolicLink()) return "other"; + try { + const info = await stat(entryPath); + if (info.isDirectory()) return "directory"; + if (info.isFile()) return "file"; + return "other"; + } catch { + appendDiagnostic(diagnostics, "skippedSkills", { + filePath: entryPath, + reason: "unavailable", + }); + return "other"; + } +} + +async function parseSkillCandidate(filePath, root, diagnostics) { + let raw; + try { + raw = await readFile(filePath, "utf8"); + } catch (error) { + appendDiagnostic(diagnostics, "skippedSkills", { + filePath, + reason: ["ENOENT", "ENOTDIR"].includes(errorCode(error)) ? "missing-skill-file" : "unavailable", + }); + return undefined; + } + let data; + try { + data = parseSkillFrontmatter(raw); + } catch { + appendDiagnostic(diagnostics, "skippedSkills", { filePath, reason: "malformed" }); + return undefined; + } + if (!data) { + appendDiagnostic(diagnostics, "skippedSkills", { filePath, reason: "malformed" }); + return undefined; + } + const name = data.name; + const description = data.description; + if (typeof name !== "string" || name.length === 0 || !SKILL_NAME.test(name)) { + appendDiagnostic(diagnostics, "skippedSkills", { filePath, reason: "invalid-name" }); + return undefined; + } + if (typeof description !== "string" || description.length === 0) { + appendDiagnostic(diagnostics, "skippedSkills", { filePath, reason: "malformed" }); + return undefined; + } + try { + validateInvocation(data); + } catch { + appendDiagnostic(diagnostics, "skippedSkills", { filePath, reason: "invalid-invocation" }); + return undefined; + } + return { + id: `dsh:skill:${filePath}`, + kind: "skill", + scope: root.scope, + sourceLabel: skillSourceLabel(root.sourceKind), + sourceKind: root.sourceKind, + filePath, + name, + description, + evidence: skillEvidence(filePath, root.path), + }; +} + +async function collectSkills(roots, diagnostics) { + const candidates = []; + for (const root of roots) { + let entries; + try { + entries = await readdir(root.path, { withFileTypes: true, encoding: "utf8" }); + } catch { + continue; + } + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + if (root.skipSystem && entry.name === ".system") continue; + const entryPath = path.join(root.path, entry.name); + const kind = await skillEntryKind(entryPath, entry, diagnostics); + const filePath = kind === "directory" + ? path.join(entryPath, "SKILL.md") + : kind === "file" && entry.name.endsWith(".md") + ? entryPath + : undefined; + if (!filePath) { + if (kind !== "other" || !entry.isSymbolicLink()) { + appendDiagnostic(diagnostics, "skippedSkills", { filePath: entryPath, reason: "unsupported-entry" }); + } + continue; + } + const item = await parseSkillCandidate(filePath, root, diagnostics); + if (item) candidates.push({ item, rank: root.rank, rootOrder: root.order }); + } + } + + const winners = new Map(); + for (const candidate of candidates) { + const existing = winners.get(candidate.item.name); + if (!existing) { + winners.set(candidate.item.name, candidate); + continue; + } + appendDiagnostic(diagnostics, "shadowedSkills", { + name: candidate.item.name, + sourceKind: candidate.item.sourceKind, + filePath: candidate.item.filePath, + }); + } + return [...winners.values()] + .map((candidate) => candidate.item) + .sort((left, right) => left.name === right.name ? 0 : left.name < right.name ? -1 : 1); +} + +function resolveInstructionCandidates(value, fallback) { + return (value ?? fallback).filter((candidate) => ( + candidate !== "" + && candidate !== "." + && candidate !== ".." + && !path.isAbsolute(candidate) + && !/[\\/]/u.test(candidate) + )); +} + +function ancestorChain(root, cwd) { + const chain = []; + let current = cwd; + while (current !== root) { + chain.push(current); + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + chain.push(root); + return chain.reverse(); +} + +function instructionCandidate(filePath, displayPath, scope, rootForEvidence) { + return { filePath, displayPath, scope, rootForEvidence }; +} + +async function readBoundedInstruction(candidate, maxSourceBytes, diagnostics) { + let info; + try { + info = await stat(candidate.filePath); + } catch (error) { + if (!["ENOENT", "ENOTDIR"].includes(errorCode(error))) { + appendDiagnostic(diagnostics, "instructionDecisions", { + path: candidate.displayPath, + reason: "unavailable", + }); + } + return undefined; + } + if (!info.isFile()) { + appendDiagnostic(diagnostics, "instructionDecisions", { + path: candidate.displayPath, + reason: "unavailable", + }); + return undefined; + } + if (info.size > maxSourceBytes) { + appendDiagnostic(diagnostics, "instructionDecisions", { + path: candidate.displayPath, + reason: "source-too-large", + }); + return undefined; + } + const chunks = []; + let bytes = 0; + try { + const stream = createReadStream(candidate.filePath, { encoding: "utf8" }); + for await (const chunk of stream) { + const text = String(chunk); + bytes += Buffer.byteLength(text, "utf8"); + if (bytes > maxSourceBytes) { + stream.destroy(); + appendDiagnostic(diagnostics, "instructionDecisions", { + path: candidate.displayPath, + reason: "source-too-large", + }); + return undefined; + } + chunks.push(text); + } + } catch { + appendDiagnostic(diagnostics, "instructionDecisions", { + path: candidate.displayPath, + reason: "unavailable", + }); + return undefined; + } + return { ...candidate, content: chunks.join("") }; +} + +function instructionDigest(content) { + return createHash("sha1").update(content.trim()).digest("hex"); +} + +function instructionRule(file) { + return { + id: `dsh:rule:${file.filePath}`, + kind: "rule", + scope: file.scope, + sourceLabel: file.scope === "user" ? "User" : "DeepSeek Harness project", + sourceKind: "dsh-instruction", + filePath: file.filePath, + name: file.displayPath, + description: "Applicable DSH Instruction source", + evidence: skillEvidence(file.filePath, file.rootForEvidence), + }; +} + +function byteLength(value) { + return Buffer.byteLength(value, "utf8"); +} + +function truncateUtf8(value, maxBytes) { + const bytes = Buffer.from(value, "utf8"); + if (bytes.length <= maxBytes) return value; + let end = Math.max(0, Math.trunc(maxBytes)); + while (end > 0 && (bytes.readUInt8(end) & 0xc0) === 0x80) end -= 1; + return bytes.subarray(0, end).toString("utf8"); +} + +function sectionText(file) { + return `Instructions from: ${file.displayPath}\n\n${file.content}`; +} + +function markerText(maxBytes, omitted, truncated) { + if (omitted.length === 0 && truncated.length === 0) return ""; + const parts = []; + if (omitted.length > 0) parts.push(`omitted ${omitted.map((file) => file.displayPath).join(", ")}`); + if (truncated.length > 0) { + parts.push(`truncated ${truncated.map((item) => ( + `${item.displayPath} from ${item.originalBytes} to ${item.includedBytes} bytes` + )).join(", ")}`); + } + return `Workspace instruction budget ${maxBytes} bytes: ${parts.join("; ")}`; +} + +function escapedFrameBody(value) { + return value.replaceAll(SYSTEM_REMINDER_CLOSE, "<\\/system-reminder>"); +} + +function buildInstructionText(files, maxBytes, omitted, truncated, intro) { + const marker = markerText(maxBytes, omitted, truncated); + const body = [marker, intro, ...files.map(sectionText)].filter((block) => block.length > 0); + return [SYSTEM_REMINDER_OPEN, escapedFrameBody(body.join("\n\n")), SYSTEM_REMINDER_CLOSE].join("\n"); +} + +function withTruncatedContent(file, includedBytes) { + return { ...file, content: truncateUtf8(file.content, includedBytes) }; +} + +function truncateInstructionToFit(file, maxBytes, omitted, intro) { + const originalBytes = byteLength(file.content); + let low = 0; + let high = originalBytes; + let best = withTruncatedContent(file, 0); + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const candidate = withTruncatedContent(file, mid); + const truncated = [{ + displayPath: file.displayPath, + originalBytes, + includedBytes: byteLength(candidate.content), + }]; + const text = buildInstructionText([candidate], maxBytes, omitted, truncated, intro); + if (byteLength(text) <= maxBytes) { + best = candidate; + low = mid + 1; + } else { + high = mid - 1; + } + } + return best; +} + +function budgetInstructionFiles(files, maxBytes) { + if (files.length === 0) return { included: [], omitted: [], truncated: [], represented: true }; + const fullText = buildInstructionText(files, maxBytes, [], [], WORKSPACE_CONTEXT_INTRO); + if (byteLength(fullText) <= maxBytes) { + return { included: files, omitted: [], truncated: [], represented: true }; + } + for (let start = 1; start < files.length; start += 1) { + const included = files.slice(start); + const omitted = files.slice(0, start); + const suffixText = buildInstructionText(included, maxBytes, omitted, [], WORKSPACE_CONTEXT_INTRO); + if (byteLength(suffixText) <= maxBytes) { + return { included, omitted, truncated: [], represented: true }; + } + } + const mostSpecific = files.at(-1); + const omitted = files.slice(0, -1); + const originalBytes = byteLength(mostSpecific.content); + for (const intro of [WORKSPACE_CONTEXT_INTRO, COMPACT_WORKSPACE_CONTEXT_INTRO]) { + const truncatedFile = truncateInstructionToFit(mostSpecific, maxBytes, omitted, intro); + const includedBytes = byteLength(truncatedFile.content); + const truncated = [{ displayPath: mostSpecific.displayPath, originalBytes, includedBytes }]; + const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, intro); + if (byteLength(text) <= maxBytes) { + return { + included: includedBytes > 0 || originalBytes === 0 ? [mostSpecific] : [], + omitted, + truncated, + represented: includedBytes > 0 || originalBytes === 0, + }; + } + } + const truncated = [{ displayPath: mostSpecific.displayPath, originalBytes, includedBytes: 0 }]; + const compactNotice = escapedFrameBody(markerText(maxBytes, omitted, truncated)); + const compactWithHeading = escapedFrameBody([compactNotice, sectionText(withTruncatedContent(mostSpecific, 0))].join("\n\n")); + if (byteLength(compactWithHeading) <= maxBytes && originalBytes === 0) { + return { included: [mostSpecific], omitted, truncated, represented: true }; + } + return { included: [], omitted, truncated, represented: false }; +} + +async function collectInstructions({ + projectRoot, + cwd, + dshHome, + includeUserHome, + instructionFileCandidates, + localInstructionFileCandidates, + maxBytes, + maxSourceBytes, + diagnostics, +}) { + const candidates = []; + if (includeUserHome) { + candidates.push(instructionCandidate( + path.join(dshHome, "AGENTS.md"), + "$DSH_HOME/AGENTS.md", + "user", + dshHome, + )); + } + for (const directory of ancestorChain(projectRoot, cwd)) { + for (const names of [instructionFileCandidates, localInstructionFileCandidates]) { + for (const name of names) { + const filePath = path.join(directory, name); + candidates.push(instructionCandidate( + filePath, + path.relative(projectRoot, filePath), + "project", + projectRoot, + )); + } + } + } + + const loaded = []; + const seenPaths = new Set(); + for (const candidate of candidates) { + if (seenPaths.has(candidate.filePath)) continue; + seenPaths.add(candidate.filePath); + const file = await readBoundedInstruction(candidate, maxSourceBytes, diagnostics); + if (file) loaded.push(file); + } + + const deduped = []; + const digestsByDirectory = new Map(); + for (const file of loaded) { + const directory = path.dirname(file.displayPath); + const digest = instructionDigest(file.content); + const digests = digestsByDirectory.get(directory) ?? new Set(); + digestsByDirectory.set(directory, digests); + if (digests.has(digest)) { + appendDiagnostic(diagnostics, "instructionDecisions", { + path: file.displayPath, + reason: "duplicate-content", + }); + continue; + } + digests.add(digest); + deduped.push(file); + } + + const budgeted = budgetInstructionFiles(deduped, maxBytes); + for (const file of budgeted.omitted) { + appendDiagnostic(diagnostics, "instructionDecisions", { + path: file.displayPath, + reason: "budget-omitted", + }); + } + for (const item of budgeted.truncated) { + appendDiagnostic(diagnostics, "instructionDecisions", { + path: item.displayPath, + reason: "budget-truncated", + }); + } + if (!budgeted.represented && deduped.length > 0) { + appendDiagnostic(diagnostics, "instructionDecisions", { + path: deduped.at(-1).displayPath, + reason: "budget-not-represented", + }); + } + return budgeted.included.map(instructionRule); +} + +export async function collectDshCustomizeInventory(options = {}) { + validateOptions(options); + const workspace = path.resolve(options.workspace ?? process.cwd()); + const cwd = path.resolve(options.cwd ?? workspace); + if (!isInside(workspace, cwd)) { + throw new TypeError("cwd must be equal to or inside workspace"); + } + await requireDirectory(workspace, "workspace"); + await requireDirectory(cwd, "cwd"); + + const explicitDshHome = options.dshHome ?? options["dsh-home"] ?? options.home; + const environmentDshHome = process.env.DSH_HOME; + const dshHomeInput = explicitDshHome ?? ( + environmentDshHome !== undefined && environmentDshHome.trim().length > 0 + ? environmentDshHome + : path.join(os.homedir(), ".dsh") + ); + const dshHome = path.resolve(expandDshHome(dshHomeInput)); + const dshAgentsHome = path.resolve( + options.dshAgentsHome ?? process.env.DSH_AGENTS_HOME ?? path.join(os.homedir(), ".agents"), + ); + const projectRootMarkers = options.projectRootMarkers ?? [".git"]; + const projectRoot = await findProjectRoot(cwd, projectRootMarkers); + const includeUserHome = options.includeUserHome === true; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const maxSourceBytes = options.maxSourceBytes ?? DEFAULT_MAX_SOURCE_BYTES; + const instructionsEnabled = maxBytes > 0 + && Number.isFinite(maxBytes) + && maxSourceBytes > 0 + && Number.isFinite(maxSourceBytes); + const diagnostics = diagnosticsFor( + options, + includeUserHome, + instructionsEnabled ? "enabled" : "disabled-by-byte-limit", + ); + const skillRoots = resolveSkillRoots({ + options, + workspace, + projectRoot, + dshHome, + dshAgentsHome, + includeUserHome, + }); + const skills = await collectSkills(skillRoots, diagnostics); + const manage = emptyManageCollections(); + manage.skills = skills; + if (instructionsEnabled) { + manage.rules = await collectInstructions({ + projectRoot, + cwd, + dshHome, + includeUserHome, + instructionFileCandidates: resolveInstructionCandidates( + options.instructionFileCandidates, + DEFAULT_INSTRUCTION_CANDIDATES, + ), + localInstructionFileCandidates: resolveInstructionCandidates( + options.localInstructionFileCandidates, + DEFAULT_LOCAL_INSTRUCTION_CANDIDATES, + ), + maxBytes, + maxSourceBytes, + diagnostics, + }); + } + + return { + generatedAt: new Date().toISOString(), + provider: "dsh", + dshHome, + dshAgentsHome, + workspace, + cwd, + projectRoot, + tabs: MANAGE_TABS, + plugins: [], + manage, + diagnostics, + unsupported: [], + }; +} diff --git a/scripts/agent-customize/providers/index.mjs b/scripts/agent-customize/providers/index.mjs index 7cb988e1..86afbca1 100644 --- a/scripts/agent-customize/providers/index.mjs +++ b/scripts/agent-customize/providers/index.mjs @@ -2,6 +2,7 @@ import { collectClaudeCustomizeInventory } from "./claude.mjs"; import { collectCodexCustomizeInventory } from "./codex.mjs"; import { collectCopilotCustomizeInventory } from "./copilot.mjs"; import { collectCursorCustomizeInventory } from "./cursor.mjs"; +import { collectDshCustomizeInventory } from "./dsh.mjs"; import { collectGrokCustomizeInventory } from "./grok.mjs"; import { collectPiCustomizeInventory } from "./pi.mjs"; import { collectKimiCustomizeInventory } from "./kimi.mjs"; @@ -21,6 +22,7 @@ export const PROVIDER_COLLECTORS = new Map([ ["kimi", collectKimiCustomizeInventory], ["workbuddy", collectWorkbuddyCustomizeInventory], ["grok", collectGrokCustomizeInventory], + ["dsh", collectDshCustomizeInventory], ]); export const SUPPORTED_CUSTOMIZE_PROVIDERS = hostIdsFor(HOST_CAPABILITIES.AGENT_CUSTOMIZE); diff --git a/scripts/dsh-configured-assets/native-smoke.mjs b/scripts/dsh-configured-assets/native-smoke.mjs new file mode 100644 index 00000000..88654cc3 --- /dev/null +++ b/scripts/dsh-configured-assets/native-smoke.mjs @@ -0,0 +1,479 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { + access, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPOSITORY_ROOT = path.resolve(HERE, "../.."); +const AGENT_CUSTOMIZE_PATH = path.join(REPOSITORY_ROOT, "scripts", "agent-customize", "index.mjs"); +const DSH_NATIVE_VERSION = "0.1.1-rc.2"; +const DSH_NATIVE_SOURCE_SHA = "b150a551b8d465e31e418e1b2eaf5e79bbb7d28e"; +const DSH_PACKAGES = [ + "@deepseek-ai/dsh-agent", + "@deepseek-ai/dsh-agent-instructions", + "@deepseek-ai/dsh-fs", + "@deepseek-ai/dsh-fs-local", + "@deepseek-ai/dsh-home-paths", + "@deepseek-ai/dsh-invariants", + "@deepseek-ai/dsh-llm", + "@deepseek-ai/dsh-scope", + "@deepseek-ai/dsh-session", + "@deepseek-ai/dsh-skill", + "@deepseek-ai/dsh-skill-filesystem", + "@deepseek-ai/dsh-tools", +]; + +async function installNativeOwners(prefix) { + const specs = [ + "@deepseek-ai/cordis@4.0.1", + ...DSH_PACKAGES.map((packageName) => `${packageName}@${DSH_NATIVE_VERSION}`), + ]; + const args = [ + "install", + "--prefix", prefix, + "--no-package-lock", + "--no-save", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ...specs, + ]; + const npmCli = process.env.npm_execpath; + const command = npmCli ? process.execPath : process.platform === "win32" ? "npm.cmd" : "npm"; + const commandArgs = npmCli ? [npmCli, ...args] : args; + await new Promise((resolve, reject) => { + const child = spawn(command, commandArgs, { + stdio: "inherit", + shell: !npmCli && process.platform === "win32", + }); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`native DSH owner install failed (${signal ?? code})`)); + }); + }); +} + +async function write(filePath, content) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content, "utf8"); +} + +function skill(name, description, body = "NATIVE PRIVATE SKILL BODY") { + return ["---", `name: ${name}`, `description: ${description}`, "---", "", body, ""].join("\n"); +} + +async function directorySkill(root, entry, name, description) { + const filePath = path.join(root, entry, "SKILL.md"); + await write(filePath, skill(name, description)); + return filePath; +} + +async function packageEntryPath(nodeModules, packageName) { + const packageRoot = path.join(nodeModules, ...packageName.split("/")); + const manifest = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8")); + if (packageName.startsWith("@deepseek-ai/dsh-")) { + assert.equal(manifest.version, DSH_NATIVE_VERSION, `${packageName} must remain pinned`); + } + return path.join(packageRoot, manifest.main ?? "lib/index.js"); +} + +async function loadPackage(nodeModules, packageName) { + return import(pathToFileURL(await packageEntryPath(nodeModules, packageName))); +} + +async function createFixture(scratch) { + const repository = path.join(scratch, "repo with 空格"); + const workspace = path.join(repository, "packages", "api"); + const cwd = path.join(workspace, "src"); + const dshHome = path.join(scratch, "dsh home"); + const agentsHome = path.join(scratch, "agents home"); + const customOne = path.join(scratch, "custom-one"); + const customTwo = path.join(scratch, "custom-two"); + const bundled = path.join(scratch, "bundled"); + const external = path.join(scratch, "external targets"); + const budgetRepository = path.join(scratch, "budget repo"); + const budgetCwd = path.join(budgetRepository, "nested"); + const normalizationRepository = path.join(scratch, "normalization repo"); + const normalizationCwd = path.join(normalizationRepository, "workspace"); + const syntheticHome = path.join(scratch, "synthetic home"); + await mkdir(path.join(repository, ".git"), { recursive: true }); + await mkdir(cwd, { recursive: true }); + await mkdir(path.join(budgetRepository, ".git"), { recursive: true }); + await mkdir(budgetCwd, { recursive: true }); + await mkdir(path.join(normalizationRepository, ".git"), { recursive: true }); + await mkdir(normalizationCwd, { recursive: true }); + await mkdir(syntheticHome, { recursive: true }); + + await directorySkill(path.join(repository, ".dsh", "skills"), "alpha", "alpha", "project dsh"); + await write(path.join(repository, ".dsh", "skills", "flat.md"), skill("flat-skill", "flat")); + await write(path.join(repository, ".dsh", "skills", "fallback", "SKILL.md"), + "---\nname: fallback\ndescription: [broken\n---\n"); + await directorySkill(path.join(repository, ".agents", "skills"), "alpha", "alpha", "project agents shadow"); + await directorySkill(path.join(repository, ".agents", "skills"), "beta", "beta", "project agents"); + await directorySkill(path.join(repository, ".agents", "skills"), "fallback", "fallback", "valid fallback"); + await directorySkill(customOne, "custom", "custom-skill", "custom one"); + await directorySkill(customOne, "tie-z", "custom-tie", "first custom root"); + await directorySkill(customTwo, "tie-a", "custom-tie", "second custom root"); + await directorySkill(path.join(dshHome, "skills"), "user", "user-skill", "user dsh"); + await directorySkill(path.join(agentsHome, "skills"), "agents", "agents-skill", "user agents"); + await directorySkill(bundled, "bundled", "bundled-skill", "bundled"); + + const symlinkCases = { file: false, directory: false, broken: false }; + const externalSkill = path.join(external, "linked.md"); + const externalSkillDirectory = path.join(external, "linked-directory"); + const brokenSkillTarget = path.join(external, "broken.md"); + await write(externalSkill, skill("linked-skill", "linked file")); + await directorySkill(externalSkillDirectory, "entry", "linked-directory-skill", "linked directory"); + await write(brokenSkillTarget, skill("broken-skill", "removed target")); + await mkdir(customOne, { recursive: true }); + for (const [kind, target, linkPath, type] of [ + ["file", externalSkill, path.join(customOne, "linked.md"), "file"], + [ + "directory", + path.join(externalSkillDirectory, "entry"), + path.join(customOne, "linked-directory"), + process.platform === "win32" ? "junction" : "dir", + ], + ["broken", brokenSkillTarget, path.join(customOne, "broken.md"), "file"], + ]) { + try { + await symlink(target, linkPath, type); + symlinkCases[kind] = true; + } catch (error) { + if (!["EPERM", "EACCES", "ENOSYS"].includes(error?.code)) throw error; + } + } + if (symlinkCases.broken) await rm(brokenSkillTarget, { force: true }); + + await write(path.join(dshHome, "AGENTS.md"), "GLOBAL NATIVE INSTRUCTION"); + await write(path.join(repository, "AGENTS.md"), "ROOT NATIVE INSTRUCTION"); + await write(path.join(repository, "CLAUDE.md"), "ROOT CLAUDE INSTRUCTION"); + await write(path.join(repository, "packages", "AGENTS.md"), "PACKAGES NATIVE INSTRUCTION"); + await write(path.join(workspace, "AGENTS.md"), "SAME API INSTRUCTION\n"); + await write(path.join(workspace, "CLAUDE.md"), " SAME API INSTRUCTION \n"); + await write(path.join(workspace, "AGENTS.local.md"), "API LOCAL INSTRUCTION"); + await write(path.join(cwd, "AGENTS.md"), "🙂".repeat(2_000)); + await write(path.join(budgetRepository, "AGENTS.md"), `ROOT${" ".repeat(400)}`); + await write(path.join(budgetCwd, "AGENTS.md"), `LEAF${" ".repeat(400)}`); + + return { + repository, + workspace, + cwd, + dshHome, + agentsHome, + customOne, + customTwo, + bundled, + external, + symlinkCases, + budgetRepository, + budgetCwd, + normalizationCwd, + syntheticHome, + }; +} + +async function probeHomeNormalization(nodeModules, fixture) { + const nativeHomePath = await packageEntryPath(nodeModules, "@deepseek-ai/dsh-home-paths"); + const cases = [ + { label: "blank environment", envDshHome: "" }, + { label: "whitespace environment", envDshHome: " " }, + { label: "forward-slash environment tilde", envDshHome: "~/dsh-test" }, + { label: "backslash environment tilde", envDshHome: "~\\dsh-test" }, + { label: "explicit backslash tilde", envDshHome: "~/environment-home", explicitDshHome: "~\\dsh-test" }, + { label: "explicit blank", envDshHome: "~/environment-home", explicitDshHome: "" }, + ]; + const script = [ + `const NativeHome = await import(${JSON.stringify(pathToFileURL(nativeHomePath).href)});`, + `const BetterHarness = await import(${JSON.stringify(pathToFileURL(AGENT_CUSTOMIZE_PATH).href)});`, + `const cases = ${JSON.stringify(cases)};`, + `const baseOptions = ${JSON.stringify({ + provider: "dsh", + workspace: fixture.normalizationCwd, + cwd: fixture.normalizationCwd, + dshAgentsHome: path.join(fixture.syntheticHome, ".agents"), + includeUserHome: false, + })};`, + "const results = [];", + "for (const current of cases) {", + " process.env.DSH_HOME = current.envDshHome;", + " const configured = Object.hasOwn(current, 'explicitDshHome') ? current.explicitDshHome : undefined;", + " const native = NativeHome.resolveDshHome(configured, process.env);", + " const options = { ...baseOptions };", + " if (Object.hasOwn(current, 'explicitDshHome')) options.dshHome = current.explicitDshHome;", + " const inventory = await BetterHarness.collectAgentCustomizeInventory(options);", + " results.push({ label: current.label, native, betterHarness: inventory.dshHome });", + "}", + "process.stdout.write(JSON.stringify({ cwd: process.cwd(), results }));", + ].join("\n"); + const result = spawnSync(process.execPath, ["--input-type=module", "--eval", script], { + cwd: fixture.normalizationCwd, + encoding: "utf8", + env: { + ...process.env, + HOME: fixture.syntheticHome, + USERPROFILE: fixture.syntheticHome, + }, + }); + assert.equal(result.status, 0, result.stderr); + const probe = JSON.parse(result.stdout); + assert.deepEqual(probe.results.map((entry) => entry.native), [ + path.join(fixture.syntheticHome, ".dsh"), + path.join(fixture.syntheticHome, ".dsh"), + path.join(fixture.syntheticHome, "dsh-test"), + path.join(fixture.syntheticHome, "dsh-test"), + path.join(fixture.syntheticHome, "dsh-test"), + probe.cwd, + ]); + return probe; +} + +async function verifyNativeOwners(nodeModules, fixture) { + const { Context } = await loadPackage(nodeModules, "@deepseek-ai/cordis"); + const { default: SkillRegistry } = await loadPackage(nodeModules, "@deepseek-ai/dsh-skill"); + const SkillFileSystem = await loadPackage(nodeModules, "@deepseek-ai/dsh-skill-filesystem"); + const Instructions = await loadPackage(nodeModules, "@deepseek-ai/dsh-agent-instructions"); + + const ctx = new Context(); + await ctx.plugin(SkillRegistry); + await ctx.plugin(SkillFileSystem, { + dshHome: fixture.dshHome, + agentsHome: fixture.agentsHome, + customSkillDirs: [fixture.customOne, fixture.customTwo], + bundledSkillDir: fixture.bundled, + includeDefaultRoots: true, + watch: false, + }); + const snapshot = await ctx.skills.snapshot({ cwd: fixture.cwd }); + const skillNames = snapshot.skills.map((entry) => entry.name); + assert.deepEqual(skillNames, [ + "agents-skill", + "alpha", + "beta", + "bundled-skill", + "custom-skill", + "custom-tie", + "fallback", + "flat-skill", + ...(fixture.symlinkCases.directory ? ["linked-directory-skill"] : []), + ...(fixture.symlinkCases.file ? ["linked-skill"] : []), + "user-skill", + ]); + assert.equal(skillNames.includes("broken-skill"), false); + assert.equal(snapshot.skills.find((entry) => entry.name === "alpha")?.source, "project-dsh"); + assert.equal(snapshot.skills.find((entry) => entry.name === "fallback")?.source, "project-agents"); + assert.equal(snapshot.skills.find((entry) => entry.name === "custom-tie")?.description, "first custom root"); + + const discovered = await Instructions.discoverBaselineInstructionFiles({ + cwd: fixture.cwd, + dshHome: fixture.dshHome, + }); + assert.deepEqual(discovered.map((file) => file.displayPath), [ + "$DSH_HOME/AGENTS.md", + "AGENTS.md", + "CLAUDE.md", + path.join("packages", "AGENTS.md"), + path.join("packages", "api", "AGENTS.md"), + path.join("packages", "api", "CLAUDE.md"), + path.join("packages", "api", "AGENTS.local.md"), + path.join("packages", "api", "src", "AGENTS.md"), + ]); + + const full = await Instructions.loadBaselineInstructions({ + cwd: fixture.cwd, + dshHome: fixture.dshHome, + maxSourceBytes: 1_048_576, + maxBytes: 65_536, + }); + assert.ok(full); + assert.equal(Buffer.byteLength(full.text, "utf8") <= 65_536, true); + assert.match(full.text, /GLOBAL NATIVE INSTRUCTION/u); + assert.match(full.text, /ROOT NATIVE INSTRUCTION/u); + assert.match(full.text, /SAME API INSTRUCTION/u); + assert.equal(full.text.includes(`Instructions from: ${path.join("packages", "api", "CLAUDE.md")}`), false); + assert.doesNotMatch(full.text, /�/u); + + const sourceBounded = await Instructions.loadBaselineInstructions({ + cwd: fixture.cwd, + dshHome: fixture.dshHome, + maxSourceBytes: 64, + maxBytes: 65_536, + }); + assert.ok(sourceBounded); + assert.equal(sourceBounded.text.includes(path.join("packages", "api", "src", "AGENTS.md")), false); + + const budgeted = await Instructions.loadBaselineInstructions({ + cwd: fixture.cwd, + dshHome: fixture.dshHome, + maxSourceBytes: 1_048_576, + maxBytes: 1_024, + }); + assert.ok(budgeted); + assert.equal(Buffer.byteLength(budgeted.text, "utf8") <= 1_024, true); + assert.ok(budgeted.omitted.length > 0 || budgeted.truncated.length > 0); + assert.doesNotMatch(budgeted.text, /�/u); + + const whitespaceBudgeted = await Instructions.loadBaselineInstructions({ + cwd: fixture.budgetCwd, + maxSourceBytes: 1_048_576, + maxBytes: 512, + }); + assert.ok(whitespaceBudgeted); + assert.equal(Buffer.byteLength(`ROOT${" ".repeat(400)}`, "utf8"), 404); + assert.equal(Buffer.byteLength(`LEAF${" ".repeat(400)}`, "utf8"), 404); + const whitespaceBudgetRules = ["AGENTS.md", path.join("nested", "AGENTS.md")].filter((displayPath) => ( + whitespaceBudgeted.text.includes(`Instructions from: ${displayPath}`) + )); + assert.deepEqual(whitespaceBudgetRules, [path.join("nested", "AGENTS.md")]); + assert.ok(whitespaceBudgeted.omitted.length > 0); + assert.ok(whitespaceBudgeted.truncated.length > 0); + + return { + skillNames, + instructionCandidates: discovered.map((file) => file.displayPath), + deduplicatedApiClaude: !full.text.includes(`Instructions from: ${path.join("packages", "api", "CLAUDE.md")}`), + sourceLimit: "verified", + aggregateBudget: "verified", + whitespaceBudgetRules, + utf8: "verified", + symlinkCases: fixture.symlinkCases, + }; +} + +async function compareBetterHarness(fixture, native, homeNormalization) { + try { + await access(AGENT_CUSTOMIZE_PATH); + } catch { + throw new Error("DSH configured-assets provider is not implemented yet; native DSH half passed"); + } + const { collectAgentCustomizeInventory } = await import(pathToFileURL(AGENT_CUSTOMIZE_PATH)); + assert.equal(typeof collectAgentCustomizeInventory, "function"); + + const optedIn = await collectAgentCustomizeInventory({ + provider: "dsh", + workspace: fixture.workspace, + cwd: fixture.cwd, + dshHome: fixture.dshHome, + dshAgentsHome: fixture.agentsHome, + customSkillDirs: [fixture.customOne, fixture.customTwo], + bundledSkillDir: fixture.bundled, + includeUserHome: true, + }); + assert.deepEqual(optedIn.manage.skills.map((entry) => entry.name), native.skillNames); + assert.equal(optedIn.projectRoot, fixture.repository); + assert.deepEqual(optedIn.manage.rules.map((entry) => entry.name), [ + "$DSH_HOME/AGENTS.md", + "AGENTS.md", + "CLAUDE.md", + path.join("packages", "AGENTS.md"), + path.join("packages", "api", "AGENTS.md"), + path.join("packages", "api", "AGENTS.local.md"), + path.join("packages", "api", "src", "AGENTS.md"), + ]); + + const defaultClosed = await collectAgentCustomizeInventory({ + provider: "dsh", + workspace: fixture.workspace, + cwd: fixture.cwd, + dshHome: fixture.dshHome, + dshAgentsHome: fixture.agentsHome, + includeUserHome: false, + }); + assert.equal(defaultClosed.manage.skills.some((entry) => ["user-skill", "agents-skill"].includes(entry.name)), false); + assert.equal(defaultClosed.manage.rules.some((entry) => entry.scope === "user"), false); + + const explicit = await collectAgentCustomizeInventory({ + provider: "dsh", + workspace: fixture.workspace, + cwd: fixture.cwd, + customSkillDirs: [fixture.customOne], + bundledSkillDir: fixture.bundled, + includeUserHome: false, + }); + assert.ok(explicit.manage.skills.some((entry) => entry.name === "custom-skill")); + assert.ok(explicit.manage.skills.some((entry) => entry.name === "bundled-skill")); + + const budgeted = await collectAgentCustomizeInventory({ + provider: "dsh", + workspace: fixture.workspace, + cwd: fixture.cwd, + maxBytes: 1_024, + maxSourceBytes: 1_048_576, + }); + assert.ok(budgeted.diagnostics.instructionDecisions.some((entry) => ( + entry.reason === "budget-omitted" || entry.reason === "budget-truncated" + ))); + + const whitespaceBudgeted = await collectAgentCustomizeInventory({ + provider: "dsh", + workspace: fixture.budgetCwd, + cwd: fixture.budgetCwd, + maxBytes: 512, + maxSourceBytes: 1_048_576, + }); + assert.deepEqual( + whitespaceBudgeted.manage.rules.map((entry) => entry.name), + native.whitespaceBudgetRules, + ); + assert.ok(whitespaceBudgeted.diagnostics.instructionDecisions.some((entry) => ( + entry.path === "AGENTS.md" && entry.reason === "budget-omitted" + ))); + assert.ok(whitespaceBudgeted.diagnostics.instructionDecisions.some((entry) => ( + entry.path === path.join("nested", "AGENTS.md") && entry.reason === "budget-truncated" + ))); + assert.doesNotMatch(JSON.stringify(whitespaceBudgeted), /ROOT|LEAF/u); + for (const entry of homeNormalization.results) { + assert.equal(entry.betterHarness, entry.native, entry.label); + } + assert.doesNotMatch(JSON.stringify(optedIn), /NATIVE PRIVATE SKILL BODY|NATIVE INSTRUCTION|SAME API INSTRUCTION/u); +} + +let installation; +let cleanupInstallation = false; +let scratch; +try { + const provided = process.env.DSH_NATIVE_NODE_MODULES; + if (provided) { + installation = path.resolve(provided); + } else { + const prefix = await mkdtemp(path.join(os.tmpdir(), "better-harness-dsh-assets-native-")); + cleanupInstallation = true; + await installNativeOwners(prefix); + installation = path.join(prefix, "node_modules"); + } + scratch = await mkdtemp(path.join(os.tmpdir(), "better-harness-dsh-assets-smoke-")); + const fixture = await createFixture(scratch); + const native = await verifyNativeOwners(installation, fixture); + const homeNormalization = await probeHomeNormalization(installation, fixture); + process.stdout.write(`${JSON.stringify({ + phase: "native-dsh", + status: "pass", + dshVersion: DSH_NATIVE_VERSION, + sourceSha: DSH_NATIVE_SOURCE_SHA, + credentialUsed: false, + platform: process.platform, + homeNormalization: "verified", + ...native, + })}\n`); + await compareBetterHarness(fixture, native, homeNormalization); + process.stdout.write(`${JSON.stringify({ phase: "better-harness-comparison", status: "pass" })}\n`); +} finally { + if (scratch) await rm(scratch, { recursive: true, force: true }); + if (cleanupInstallation && installation) { + await rm(path.dirname(installation), { recursive: true, force: true }); + } +} diff --git a/scripts/host-support/index.mjs b/scripts/host-support/index.mjs index e605e8d3..47e51e56 100644 --- a/scripts/host-support/index.mjs +++ b/scripts/host-support/index.mjs @@ -111,7 +111,10 @@ export const HOST_DESCRIPTORS = Object.freeze([ id: "dsh", displayName: "DeepSeek Harness", aliases: ["DeepSeek Harness"], - capabilities: [HOST_CAPABILITIES.SESSION_ANALYSIS], + capabilities: [ + HOST_CAPABILITIES.SESSION_ANALYSIS, + HOST_CAPABILITIES.AGENT_CUSTOMIZE, + ], sessionScopeTokens: [".dsh", "dsh", "deepseek-harness"], }), ]); diff --git a/scripts/npm-package/create-bundle.mjs b/scripts/npm-package/create-bundle.mjs index 65088984..83cbd60e 100644 --- a/scripts/npm-package/create-bundle.mjs +++ b/scripts/npm-package/create-bundle.mjs @@ -152,6 +152,19 @@ function collectEsbuildWasmFiles() { return entries; } +function collectYamlFiles() { + let packageJson; + try { + packageJson = require.resolve("yaml/package.json"); + } catch { + throw new Error("Missing yaml. Run npm install before creating the runtime bundle."); + } + + const entries = []; + collectPathEntries(path.dirname(packageJson), "node_modules/yaml", entries); + return entries; +} + function collectPathEntries(rootPath, relativePath, entries) { if (!fs.existsSync(rootPath) || shouldSkip(rootPath) || shouldSkipRuntimePath(relativePath)) { return; @@ -186,6 +199,7 @@ function collectEntries() { } entries.push(...collectTreeSitterFiles().filter((entry) => fs.existsSync(entry.source))); entries.push(...collectEsbuildWasmFiles().filter((entry) => fs.existsSync(entry.source))); + entries.push(...collectYamlFiles().filter((entry) => fs.existsSync(entry.source))); const byPath = new Map(); for (const entry of entries) { diff --git a/scripts/npm-package/verify-pack.mjs b/scripts/npm-package/verify-pack.mjs index 25bc362c..c70f9bac 100644 --- a/scripts/npm-package/verify-pack.mjs +++ b/scripts/npm-package/verify-pack.mjs @@ -356,6 +356,9 @@ const requiredBundleEntries = [ "vendor/esbuild-wasm/LICENSE.md", "vendor/esbuild-wasm/lib/main.js", "vendor/esbuild-wasm/esbuild.wasm", + "node_modules/yaml/package.json", + "node_modules/yaml/LICENSE", + "node_modules/yaml/dist/index.js", ]; const forbiddenBundlePrefixes = [ ".claude-plugin/", @@ -370,7 +373,6 @@ const forbiddenBundlePrefixes = [ ".idea/", ".qoder/", "assets/wasm/", - "node_modules/", "scripts/packaging/", "skills/loop-blueprint/", "skills/harness/", @@ -403,6 +405,11 @@ for (const prefix of forbiddenBundlePrefixes) { fail(`runtime bundle has unexpected path ${prefix}`); } } +for (const entry of bundleEntries) { + if (entry.startsWith("node_modules/") && !entry.startsWith("node_modules/yaml/")) { + fail(`runtime bundle has unexpected dependency path ${entry}`); + } +} if (hasPathSegment(bundleEntries, ".plugin-eval")) { fail("runtime bundle contains generated .plugin-eval state"); } diff --git a/scripts/packaging/antigravity/verify-antigravity-plugin.mjs b/scripts/packaging/antigravity/verify-antigravity-plugin.mjs index 863faf64..34db68c3 100644 --- a/scripts/packaging/antigravity/verify-antigravity-plugin.mjs +++ b/scripts/packaging/antigravity/verify-antigravity-plugin.mjs @@ -14,10 +14,12 @@ export const RUNTIME_ENTRY = "scripts/better-harness.mjs"; export const RUNTIME_DEPENDENCIES = Object.freeze([ "@vscode/tree-sitter-wasm", "esbuild-wasm", + "yaml", ]); export const RUNTIME_DEPENDENCY_LICENSES = Object.freeze({ "@vscode/tree-sitter-wasm": "LICENSE", "esbuild-wasm": "LICENSE.md", + yaml: "LICENSE", }); export const GRAPH_LIMITS = Object.freeze({ markdownNodes: 128, @@ -116,6 +118,8 @@ const REQUIRED_FILES = Object.freeze([ "node_modules/@vscode/tree-sitter-wasm/LICENSE", "node_modules/esbuild-wasm/package.json", "node_modules/esbuild-wasm/LICENSE.md", + "node_modules/yaml/package.json", + "node_modules/yaml/LICENSE", ]); export class AntigravityArtifactError extends Error { @@ -156,10 +160,10 @@ function isContained(candidate, parent) { ); } -function hasGeneratedName(relativePath) { +function hasGeneratedName(relativePath, { allowDist = false } = {}) { const names = relativePath.split("/"); return names.some((name) => ( - GENERATED_NAMES.has(name) + (GENERATED_NAMES.has(name) && !(allowDist && name === "dist")) || name === ".env" || name.startsWith(".env.") || name.endsWith(".tmp") @@ -172,6 +176,8 @@ export function isAllowedArtifactPath(relativePath) { return false; } const normalized = path.posix.normalize(relativePath); + const isYamlDependency = normalized === "node_modules/yaml" + || normalized.startsWith("node_modules/yaml/"); if ( normalized !== relativePath || normalized === "." @@ -179,7 +185,7 @@ export function isAllowedArtifactPath(relativePath) { || normalized.startsWith("../") || path.posix.isAbsolute(normalized) || normalized.includes("\\") - || hasGeneratedName(normalized) + || hasGeneratedName(normalized, { allowDist: isYamlDependency }) ) { return false; } @@ -221,6 +227,7 @@ export function isAllowedArtifactPath(relativePath) { ) { return true; } + if (isYamlDependency) return true; return false; } diff --git a/scripts/packaging/verify-host-plugin.mjs b/scripts/packaging/verify-host-plugin.mjs index e09a1b5a..92be9ae6 100644 --- a/scripts/packaging/verify-host-plugin.mjs +++ b/scripts/packaging/verify-host-plugin.mjs @@ -11,10 +11,12 @@ export const SUPPORTED_HOSTS = Object.freeze(["codex"]); export const RUNTIME_DEPENDENCIES = Object.freeze([ "@vscode/tree-sitter-wasm", "esbuild-wasm", + "yaml", ]); export const RUNTIME_DEPENDENCY_LICENSES = Object.freeze({ "@vscode/tree-sitter-wasm": "LICENSE", "esbuild-wasm": "LICENSE.md", + yaml: "LICENSE", }); const HOSTS = Object.freeze({ diff --git a/test/agents/agent-customize-dsh.test.mjs b/test/agents/agent-customize-dsh.test.mjs new file mode 100644 index 00000000..7ec1f7ca --- /dev/null +++ b/test/agents/agent-customize-dsh.test.mjs @@ -0,0 +1,885 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + access, + chmod, + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { test } from "vitest"; + +import { MANAGE_TABS } from "../../scripts/agent-customize/constants.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPOSITORY_ROOT = path.resolve(HERE, "../.."); +const PROVIDER_PATH = path.join(REPOSITORY_ROOT, "scripts", "agent-customize", "providers", "dsh.mjs"); +const CLI_PATH = path.join(REPOSITORY_ROOT, "scripts", "agent-customize", "cli.mjs"); +const DSH_VERSION = "0.1.1-rc.2"; +const DSH_SOURCE_SHA = "b150a551b8d465e31e418e1b2eaf5e79bbb7d28e"; + +async function loadDshProvider() { + try { + await access(PROVIDER_PATH); + } catch { + assert.fail("DSH configured-assets provider is not implemented yet"); + } + const module = await import(`${pathToFileURL(PROVIDER_PATH).href}?red-contract=${Date.now()}`); + assert.equal( + typeof module.collectDshCustomizeInventory, + "function", + "DSH configured-assets provider must export collectDshCustomizeInventory", + ); + return module.collectDshCustomizeInventory; +} + +async function withTempRoot(prefix, callback) { + const root = await mkdtemp(path.join(os.tmpdir(), prefix)); + try { + return await callback(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function write(filePath, content) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content, "utf8"); +} + +function skillDocument({ name, description = "Fixture skill", fields = [], body = "PRIVATE SKILL BODY" }) { + return [ + "---", + `name: ${name}`, + `description: ${description}`, + ...fields, + "---", + "", + body, + "", + ].join("\n"); +} + +async function writeDirectorySkill(root, entry, values) { + const filePath = path.join(root, entry, "SKILL.md"); + await write(filePath, skillDocument(values)); + return filePath; +} + +async function writeFlatSkill(root, filename, values) { + const filePath = path.join(root, filename); + await write(filePath, skillDocument(values)); + return filePath; +} + +async function createRepository(root, workspaceSegments = []) { + const repository = path.join(root, "repo"); + const workspace = path.join(repository, ...workspaceSegments); + await mkdir(path.join(repository, ".git"), { recursive: true }); + await mkdir(workspace, { recursive: true }); + return { repository, workspace }; +} + +function names(items) { + return items.map((item) => item.name); +} + +function probeDshHome({ workspace, cwd, syntheticHome, envDshHome, explicitDshHome }) { + const options = { + workspace, + cwd, + includeUserHome: false, + dshAgentsHome: path.join(syntheticHome, ".agents"), + }; + if (explicitDshHome !== undefined) options.dshHome = explicitDshHome; + const script = [ + `const module = await import(${JSON.stringify(pathToFileURL(PROVIDER_PATH).href)});`, + `const result = await module.collectDshCustomizeInventory(${JSON.stringify(options)});`, + "process.stdout.write(JSON.stringify({ dshHome: result.dshHome, cwd: process.cwd() }));", + ].join("\n"); + const env = { + ...process.env, + HOME: syntheticHome, + USERPROFILE: syntheticHome, + DSH_HOME: envDshHome, + }; + const result = spawnSync(process.execPath, ["--input-type=module", "--eval", script], { + cwd, + encoding: "utf8", + env, + }); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout); +} + +function itemByName(items, name) { + return items.find((item) => item.name === name); +} + +function assertStandardEmptyCollections(inventory) { + assert.deepEqual(inventory.plugins, []); + for (const key of ["plugins", "mcps", "subagents", "commands", "hooks"]) { + assert.deepEqual(inventory.manage[key], [], key); + } +} + +async function symlinkOrSkip(t, target, linkPath, type) { + try { + await symlink(target, linkPath, type); + return true; + } catch (error) { + if (["EPERM", "EACCES", "ENOSYS"].includes(error?.code)) { + t.skip(`symbolic links unavailable: ${error.code}`); + return false; + } + throw error; + } +} + +test("DSH provider owns the standard configured-assets envelope without runtime-use claims", async () => { + await withTempRoot("better-harness-dsh-envelope-", async (root) => { + const { repository, workspace } = await createRepository(root); + const collect = await loadDshProvider(); + const inventory = await collect({ workspace, cwd: workspace, includeUserHome: false }); + + assert.equal(inventory.provider, "dsh"); + assert.equal(inventory.workspace, path.resolve(workspace)); + assert.equal(inventory.cwd, path.resolve(workspace)); + assert.equal(inventory.projectRoot, path.resolve(repository)); + assert.deepEqual(inventory.tabs, MANAGE_TABS); + assert.ok(Array.isArray(inventory.manage.skills)); + assert.ok(Array.isArray(inventory.manage.rules)); + assertStandardEmptyCollections(inventory); + assert.equal(inventory.diagnostics.evidenceKind, "configured-not-observed"); + assert.deepEqual(inventory.diagnostics.runtimeResolution, { + cordis: false, + profile: false, + preset: false, + runtimeSkills: false, + }); + assert.doesNotMatch(JSON.stringify(inventory), /invoked|instruction followed|complete runtime/iu); + }); +}); + +test("DSH Skills use one-level directory and flat layouts with declared-name identity", async () => { + await withTempRoot("better-harness-dsh-layout-", async (root) => { + const { repository, workspace } = await createRepository(root); + const dshSkills = path.join(repository, ".dsh", "skills"); + const agentSkills = path.join(repository, ".agents", "skills"); + await writeDirectorySkill(dshSkills, "alpha-entry", { name: "alpha", description: "DSH alpha" }); + await writeFlatSkill(dshSkills, "flat-entry.md", { name: "flat-skill", description: "Flat" }); + await writeDirectorySkill(path.join(dshSkills, "nested"), "not-valid", { + name: "nested-skill", + description: "Must not recurse", + }); + await writeDirectorySkill(agentSkills, "alpha", { name: "alpha", description: "Agents shadow" }); + await writeDirectorySkill(agentSkills, "beta-entry", { name: "beta", description: "Agents beta" }); + + const collect = await loadDshProvider(); + const inventory = await collect({ workspace, includeUserHome: false }); + + assert.deepEqual(names(inventory.manage.skills), ["alpha", "beta", "flat-skill"]); + assert.match(itemByName(inventory.manage.skills, "alpha").filePath, /\.dsh/u); + assert.match(itemByName(inventory.manage.skills, "beta").filePath, /\.agents/u); + assert.equal(itemByName(inventory.manage.skills, "nested-skill"), undefined); + }); +}); + +test("DSH Skill frontmatter preserves YAML types and native invocation validation", async () => { + await withTempRoot("better-harness-dsh-yaml-", async (root) => { + const { repository, workspace } = await createRepository(root); + const skills = path.join(repository, ".dsh", "skills"); + await writeFlatSkill(skills, "quoted.md", { + name: "quoted", + description: '"Use when: a colon is present"', + fields: ["unknown-field: accepted", "metadata: { owner: dsh, nested: { enabled: true } }"], + }); + await write(path.join(skills, "folded.md"), [ + "---", "name: folded", "description: >", " Folded", " description", "---", "PRIVATE FOLDED BODY", + ].join("\n")); + await write(path.join(skills, "literal.md"), [ + "---", "name: literal", "description: |", " Literal", " description", "---", "PRIVATE LITERAL BODY", + ].join("\n")); + for (const [name, key, value] of [ + ["bool-true", "disable-model-invocation", "true"], + ["bool-false", "disable-model-invocation", "FALSE"], + ["bool-one", "user-invocable", "1"], + ["bool-zero", "user-invocable", "0"], + ["bool-yes", "disable-model-invocation", "YES"], + ["bool-no", "disable-model-invocation", "no"], + ["bool-on", "user-invocable", "ON"], + ["bool-off", "user-invocable", "off"], + ]) { + await writeFlatSkill(skills, `${name}.md`, { name, fields: [`${key}: ${value}`] }); + } + await write(path.join(skills, "missing-frontmatter.md"), "name: missing-frontmatter\ndescription: invalid\n"); + await write(path.join(skills, "malformed.md"), "---\nname: malformed\ndescription: [unterminated\n---\n"); + await write(path.join(skills, "numeric-name.md"), "---\nname: 123\ndescription: invalid\n---\n"); + await write(path.join(skills, "boolean-description.md"), "---\nname: boolean-description\ndescription: false\n---\n"); + await writeFlatSkill(skills, "bad-name.md", { name: "Bad Name" }); + await write(path.join(skills, "missing-name.md"), "---\ndescription: missing name\n---\n"); + await write(path.join(skills, "missing-description.md"), "---\nname: missing-description\n---\n"); + await writeFlatSkill(skills, "invalid-invocation.md", { + name: "invalid-invocation", + fields: ["user-invocable: sometimes"], + }); + for (const [filename, legacy] of [ + ["legacy-disable.md", "disableModelInvocation"], + ["legacy-model.md", "modelInvocable"], + ["legacy-user.md", "userInvocable"], + ]) { + await writeFlatSkill(skills, filename, { + name: filename.slice(0, -3), + fields: [`${legacy}: true`], + }); + } + + const collect = await loadDshProvider(); + const inventory = await collect({ workspace, includeUserHome: false }); + const found = names(inventory.manage.skills); + for (const expected of [ + "quoted", "folded", "literal", "bool-true", "bool-false", "bool-one", "bool-zero", + "bool-yes", "bool-no", "bool-on", "bool-off", + ]) { + assert.ok(found.includes(expected), expected); + } + for (const invalid of [ + "missing-frontmatter", "malformed", "numeric-name", "boolean-description", "Bad Name", + "missing-name", "missing-description", "invalid-invocation", "legacy-disable", "legacy-model", "legacy-user", + ]) { + assert.equal(found.includes(invalid), false, invalid); + } + assert.match(itemByName(inventory.manage.skills, "quoted").description, /Use when: a colon is present/u); + assert.doesNotMatch(JSON.stringify(inventory), /PRIVATE (?:FOLDED|LITERAL|SKILL) BODY/u); + assert.equal(Object.hasOwn(itemByName(inventory.manage.skills, "quoted"), "metadata"), false); + assert.equal(Object.hasOwn(itemByName(inventory.manage.skills, "bool-true"), "invocation"), false); + }); +}); + +test("DSH filesystem winners follow native ranks, malformed fallback, and custom declaration order", async () => { + await withTempRoot("better-harness-dsh-precedence-", async (root) => { + const { repository, workspace } = await createRepository(root); + const customOne = path.join(root, "custom-one"); + const customTwo = path.join(root, "custom-two"); + const dshHome = path.join(root, "home", ".dsh"); + const agentsHome = path.join(root, "home", ".agents"); + const bundled = path.join(root, "bundled"); + await writeDirectorySkill(path.join(repository, ".dsh", "skills"), "winner", { + name: "winner", description: "rank 100", + }); + await writeDirectorySkill(path.join(repository, ".agents", "skills"), "winner", { + name: "winner", description: "rank 200", + }); + await write(path.join(repository, ".dsh", "skills", "fallback", "SKILL.md"), + "---\nname: fallback\ndescription: [broken\n---\n"); + await writeDirectorySkill(path.join(repository, ".agents", "skills"), "fallback", { + name: "fallback", description: "valid lower candidate", + }); + await writeDirectorySkill(customOne, "tie-z", { name: "custom-tie", description: "first root" }); + await writeDirectorySkill(customTwo, "tie-a", { name: "custom-tie", description: "second root" }); + await writeFlatSkill(customOne, "a-lexical.md", { name: "lexical-tie", description: "lexically first" }); + await writeFlatSkill(customOne, "z-lexical.md", { name: "lexical-tie", description: "lexically second" }); + await writeDirectorySkill(path.join(dshHome, "skills"), "winner", { name: "winner", description: "rank 400" }); + await writeDirectorySkill(path.join(agentsHome, "skills"), "winner", { name: "winner", description: "rank 500" }); + await writeDirectorySkill(bundled, "winner", { name: "winner", description: "rank 600" }); + + const collect = await loadDshProvider(); + const inventory = await collect({ + workspace, + includeUserHome: true, + dshHome, + dshAgentsHome: agentsHome, + customSkillDirs: [customOne, customTwo], + bundledSkillDir: bundled, + }); + + assert.equal(itemByName(inventory.manage.skills, "winner").description, "rank 100"); + assert.equal(itemByName(inventory.manage.skills, "fallback").description, "valid lower candidate"); + assert.equal(itemByName(inventory.manage.skills, "custom-tie").description, "first root"); + assert.equal(itemByName(inventory.manage.skills, "lexical-tie").description, "lexically first"); + assert.equal(inventory.manage.skills.filter((item) => item.name === "winner").length, 1); + assert.ok(inventory.diagnostics.shadowedSkills.some((item) => item.name === "winner")); + assert.equal(inventory.diagnostics.runtimeResolution.runtimeSkills, false); + assert.equal(inventory.diagnostics.evidenceKind, "configured-not-observed"); + }); +}); + +test("DSH default privacy performs no ambient user/global filesystem reads", async () => { + await withTempRoot("better-harness-dsh-no-read-", async (root) => { + const { workspace } = await createRepository(root, ["workspace"]); + const denied = path.join(root, "denied ambient home"); + const dshHome = path.join(denied, ".dsh"); + const agentsHome = path.join(denied, ".agents"); + const bundled = path.join(denied, "bundled"); + await writeDirectorySkill(path.join(dshHome, "skills"), "private", { + name: "private-user-skill", description: "must not be probed", + }); + await writeDirectorySkill(path.join(agentsHome, "skills"), "private", { + name: "private-agents-skill", description: "must not be probed", + }); + await write(path.join(dshHome, "AGENTS.md"), "PRIVATE GLOBAL INSTRUCTION"); + await writeDirectorySkill(bundled, "private", { + name: "private-bundled-skill", description: "must not be probed", + }); + await loadDshProvider(); + + const script = [ + `const module = await import(${JSON.stringify(pathToFileURL(PROVIDER_PATH).href)});`, + `const result = await module.collectDshCustomizeInventory(${JSON.stringify({ + workspace, + cwd: workspace, + dshHome, + dshAgentsHome: agentsHome, + includeUserHome: false, + })});`, + "if (result.diagnostics.userHomeCollection !== 'not-authorized') throw new Error('authorization state');", + "process.stdout.write(JSON.stringify(result));", + ].join("\n"); + const result = spawnSync(process.execPath, [ + "--permission", + `--allow-fs-read=${REPOSITORY_ROOT}`, + `--allow-fs-read=${path.dirname(workspace)}`, + "--input-type=module", + "--eval", + script, + ], { + encoding: "utf8", + env: { + ...process.env, + HOME: path.join(root, "synthetic-os-home"), + DSH_HOME: dshHome, + DSH_AGENTS_HOME: agentsHome, + DSH_BUNDLED_SKILL_DIR: bundled, + }, + }); + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(result.stdout, /private-(?:user|agents|bundled)-skill|PRIVATE GLOBAL/u); + }); +}); + +test("DSH user opt-in and explicit external roots obey distinct authorization boundaries", async () => { + await withTempRoot("better-harness-dsh-authorized-", async (root) => { + const { workspace } = await createRepository(root, ["workspace"]); + const dshHome = path.join(root, "synthetic-home", ".dsh"); + const agentsHome = path.join(root, "synthetic-home", ".agents"); + const custom = path.join(root, "external", "custom"); + const customSibling = path.join(root, "external", "sibling"); + const bundled = path.join(root, "external", "bundled"); + const projectCustom = path.join(workspace, "explicit-skills"); + await writeDirectorySkill(path.join(workspace, ".dsh", "skills"), "default-project", { name: "default-project" }); + await writeDirectorySkill(path.join(dshHome, "skills"), "user", { name: "user-skill" }); + await writeDirectorySkill(path.join(dshHome, "skills", ".system"), "hidden", { name: "hidden-system-skill" }); + await writeDirectorySkill(path.join(agentsHome, "skills"), "agents", { name: "agents-skill" }); + await writeDirectorySkill(path.join(agentsHome, "skills"), ".system", { name: "agents-system-skill" }); + await write(path.join(dshHome, "AGENTS.md"), "AUTHORIZED GLOBAL INSTRUCTION"); + await writeDirectorySkill(custom, "custom", { name: "custom-skill" }); + await writeDirectorySkill(customSibling, "sibling", { name: "sibling-must-not-appear" }); + await writeDirectorySkill(bundled, "bundled", { name: "bundled-skill" }); + await writeDirectorySkill(projectCustom, "project-custom", { name: "project-custom-skill" }); + + const collect = await loadDshProvider(); + const explicitOnly = await collect({ + workspace, + includeUserHome: false, + dshHome, + dshAgentsHome: agentsHome, + includeDefaultRoots: false, + customSkillDirs: [custom, projectCustom], + bundledSkillDir: bundled, + }); + assert.deepEqual(names(explicitOnly.manage.skills), ["bundled-skill", "custom-skill", "project-custom-skill"]); + assert.equal(itemByName(explicitOnly.manage.skills, "custom-skill").scope, "other"); + assert.equal(itemByName(explicitOnly.manage.skills, "project-custom-skill").scope, "project"); + assert.equal(names(explicitOnly.manage.skills).includes("default-project"), false); + assert.equal(names(explicitOnly.manage.skills).includes("sibling-must-not-appear"), false); + assert.equal(explicitOnly.manage.rules.length, 0); + + const optedIn = await collect({ + workspace, + includeUserHome: true, + dshHome, + dshAgentsHome: agentsHome, + customSkillDirs: [custom], + bundledSkillDir: bundled, + }); + assert.ok(names(optedIn.manage.skills).includes("user-skill")); + assert.ok(names(optedIn.manage.skills).includes("agents-skill")); + assert.ok(names(optedIn.manage.skills).includes("agents-system-skill")); + assert.equal(names(optedIn.manage.skills).includes("hidden-system-skill"), false); + assert.equal(optedIn.manage.rules[0].scope, "user"); + assert.equal(optedIn.diagnostics.userHomeCollection, "included"); + + const relativeCustom = path.relative(process.cwd(), custom); + const relative = await collect({ workspace, customSkillDirs: [relativeCustom] }); + assert.ok(names(relative.manage.skills).includes("custom-skill")); + const literalTilde = await collect({ workspace, customSkillDirs: ["~/skills"] }); + assert.equal(names(literalTilde.manage.skills).includes("user-skill"), false); + const literalAgentsHome = await collect({ + workspace, + dshAgentsHome: "~/agents", + includeUserHome: false, + }); + assert.equal(literalAgentsHome.dshAgentsHome, path.resolve("~/agents")); + + const previousBundled = process.env.DSH_BUNDLED_SKILL_DIR; + process.env.DSH_BUNDLED_SKILL_DIR = bundled; + try { + const ambientBundled = await collect({ + workspace, + dshHome, + dshAgentsHome: agentsHome, + includeUserHome: true, + }); + assert.ok(names(ambientBundled.manage.skills).includes("bundled-skill")); + } finally { + if (previousBundled === undefined) delete process.env.DSH_BUNDLED_SKILL_DIR; + else process.env.DSH_BUNDLED_SKILL_DIR = previousBundled; + } + }); +}); + +test("DSH home normalization matches native blank-environment and tilde semantics", async () => { + await withTempRoot("better-harness-dsh-home-normalization-", async (root) => { + const { workspace } = await createRepository(root, ["workspace"]); + const syntheticHome = path.join(root, "synthetic home"); + await mkdir(syntheticHome, { recursive: true }); + + for (const envDshHome of ["", " "]) { + assert.equal( + probeDshHome({ workspace, cwd: workspace, syntheticHome, envDshHome }).dshHome, + path.join(syntheticHome, ".dsh"), + ); + } + for (const envDshHome of ["~/dsh-test", "~\\dsh-test"]) { + assert.equal( + probeDshHome({ workspace, cwd: workspace, syntheticHome, envDshHome }).dshHome, + path.join(syntheticHome, "dsh-test"), + ); + } + assert.equal( + probeDshHome({ + workspace, + cwd: workspace, + syntheticHome, + envDshHome: path.join(root, "environment-home"), + explicitDshHome: "~\\dsh-test", + }).dshHome, + path.join(syntheticHome, "dsh-test"), + ); + const explicitBlank = probeDshHome({ + workspace, + cwd: workspace, + syntheticHome, + envDshHome: path.join(root, "environment-home"), + explicitDshHome: "", + }); + assert.equal(explicitBlank.dshHome, explicitBlank.cwd); + }); +}); + +test("DSH workspace and cwd select the nearest native project root above workspace", async () => { + await withTempRoot("better-harness-dsh-project-root-", async (root) => { + const parentInstruction = path.join(root, "AGENTS.md"); + const { repository, workspace } = await createRepository(root, ["packages", "api"]); + const cwd = path.join(workspace, "src"); + await mkdir(cwd, { recursive: true }); + await write(parentInstruction, "OUTSIDE NEAREST PROJECT ROOT"); + await write(path.join(repository, "AGENTS.md"), "REPOSITORY INSTRUCTION"); + await write(path.join(repository, "packages", "AGENTS.md"), "PACKAGES INSTRUCTION"); + await write(path.join(workspace, "AGENTS.md"), "API INSTRUCTION"); + await write(path.join(cwd, "AGENTS.md"), "CWD INSTRUCTION"); + await write(path.join(repository, "unrelated.txt"), "UNRELATED SIBLING SENTINEL"); + await writeDirectorySkill(path.join(repository, ".dsh", "skills"), "root-skill", { name: "root-skill" }); + + const collect = await loadDshProvider(); + const inventory = await collect({ workspace, cwd, includeUserHome: false }); + assert.equal(inventory.projectRoot, path.resolve(repository)); + assert.ok(names(inventory.manage.skills).includes("root-skill")); + assert.deepEqual(names(inventory.manage.rules), [ + "AGENTS.md", + path.join("packages", "AGENTS.md"), + path.join("packages", "api", "AGENTS.md"), + path.join("packages", "api", "src", "AGENTS.md"), + ]); + const serialized = JSON.stringify(inventory); + assert.doesNotMatch(serialized, /OUTSIDE NEAREST PROJECT ROOT|UNRELATED SIBLING SENTINEL/u); + + const permissionScript = [ + `const module = await import(${JSON.stringify(pathToFileURL(PROVIDER_PATH).href)});`, + `const result = await module.collectDshCustomizeInventory(${JSON.stringify({ + workspace, + cwd, + includeUserHome: false, + })});`, + `if (result.projectRoot !== ${JSON.stringify(path.resolve(repository))}) throw new Error('project root');`, + ].join("\n"); + const bounded = spawnSync(process.execPath, [ + "--permission", + `--allow-fs-read=${REPOSITORY_ROOT}`, + `--allow-fs-read=${repository}`, + "--input-type=module", + "--eval", + permissionScript, + ], { encoding: "utf8", env: { ...process.env, HOME: path.join(root, "synthetic-home") } }); + assert.equal(bounded.status, 0, bounded.stderr); + + const defaultCwd = await collect({ workspace, includeUserHome: false }); + assert.equal(defaultCwd.cwd, path.resolve(workspace)); + await assert.rejects(() => collect({ workspace, cwd: root }), /cwd|workspace/iu); + await assert.rejects(() => collect({ workspace, cwd: path.join(root, "missing") }), /cwd|directory/iu); + const fileCwd = path.join(workspace, "file.txt"); + await write(fileCwd, "not a directory"); + await assert.rejects(() => collect({ workspace, cwd: fileCwd }), /cwd|directory/iu); + await assert.rejects(() => collect({ workspace: path.join(root, "missing-workspace") }), /workspace|directory/iu); + await assert.rejects(() => collect({ workspace: fileCwd }), /workspace|directory/iu); + }); +}); + +test("DSH Instructions preserve global, root-to-cwd, base, and local candidate order", async () => { + await withTempRoot("better-harness-dsh-instruction-order-", async (root) => { + const { repository, workspace } = await createRepository(root, ["packages", "api"]); + const cwd = path.join(workspace, "src"); + const dshHome = path.join(root, "fake DSH home"); + await mkdir(cwd, { recursive: true }); + await write(path.join(dshHome, "AGENTS.md"), "GLOBAL"); + await write(path.join(repository, "AGENTS.md"), "ROOT AGENTS"); + await write(path.join(repository, "CLAUDE.md"), "ROOT CLAUDE"); + await write(path.join(repository, "packages", "AGENTS.md"), "PACKAGES AGENTS"); + await write(path.join(workspace, "AGENTS.md"), "API AGENTS"); + await write(path.join(workspace, "CLAUDE.md"), "API CLAUDE"); + await write(path.join(workspace, "AGENTS.local.md"), "API AGENTS LOCAL"); + await write(path.join(workspace, "CLAUDE.local.md"), "API CLAUDE LOCAL"); + await write(path.join(cwd, "AGENTS.md"), "SRC AGENTS"); + + const collect = await loadDshProvider(); + const inventory = await collect({ workspace, cwd, dshHome, includeUserHome: true }); + assert.deepEqual(names(inventory.manage.rules), [ + "$DSH_HOME/AGENTS.md", + "AGENTS.md", + "CLAUDE.md", + path.join("packages", "AGENTS.md"), + path.join("packages", "api", "AGENTS.md"), + path.join("packages", "api", "CLAUDE.md"), + path.join("packages", "api", "AGENTS.local.md"), + path.join("packages", "api", "CLAUDE.local.md"), + path.join("packages", "api", "src", "AGENTS.md"), + ]); + }); +}); + +test("DSH Instruction candidates filter path values and deduplicate paths and same-directory content", async () => { + await withTempRoot("better-harness-dsh-instruction-dedup-", async (root) => { + const { repository, workspace } = await createRepository(root, ["project"]); + await write(path.join(repository, "AGENTS.md"), "SAME CONTENT\n"); + await write(path.join(repository, "CLAUDE.md"), " SAME CONTENT \n"); + await write(path.join(workspace, "AGENTS.md"), "SAME CONTENT"); + await write(path.join(workspace, "CUSTOM.md"), "CUSTOM SENTINEL PROSE"); + const absoluteCandidate = path.join(repository, "CUSTOM.md"); + + const collect = await loadDshProvider(); + const inventory = await collect({ + workspace, + instructionFileCandidates: [ + "", ".", "..", absoluteCandidate, "nested/AGENTS.md", "nested\\AGENTS.md", + "AGENTS.md", "AGENTS.md", "CLAUDE.md", "CUSTOM.md", + ], + localInstructionFileCandidates: [], + }); + assert.deepEqual(names(inventory.manage.rules), [ + "AGENTS.md", + path.join("project", "AGENTS.md"), + path.join("project", "CUSTOM.md"), + ]); + assert.ok(inventory.diagnostics.instructionDecisions.some((item) => item.reason === "duplicate-content")); + const serialized = JSON.stringify(inventory); + assert.doesNotMatch(serialized, /SAME CONTENT|CUSTOM SENTINEL PROSE/u); + assert.equal(/digest|sha1|sha-1/iu.test(serialized), false); + }); +}); + +test("DSH Instruction source limits exclude failed sources without collapsing independent rules", async (t) => { + await withTempRoot("better-harness-dsh-source-limit-", async (root) => { + const { repository, workspace } = await createRepository(root, ["project"]); + await write(path.join(repository, "AGENTS.md"), "under"); + await write(path.join(repository, "CLAUDE.md"), "this source is over the limit"); + await mkdir(path.join(repository, "AGENTS.local.md"), { recursive: true }); + await symlinkOrSkip(t, path.join(root, "missing-target"), path.join(repository, "CLAUDE.local.md"), "file"); + + const collect = await loadDshProvider(); + const inventory = await collect({ workspace, maxSourceBytes: 8, maxBytes: 65_536 }); + assert.deepEqual(names(inventory.manage.rules), ["AGENTS.md"]); + assert.ok(inventory.diagnostics.instructionDecisions.some((item) => item.reason === "source-too-large")); + + const disappearing = path.join(workspace, "AGENTS.md"); + await write(disappearing, "disappears after stat"); + const disappearingProbe = spawnSync(process.execPath, [ + "--experimental-test-module-mocks", + "--input-type=module", + "--eval", + String.raw` + import { mock } from "node:test"; + import path from "node:path"; + import { pathToFileURL } from "node:url"; + const actual = await import("node:fs/promises"); + const watched = path.resolve(process.env.DSH_DISAPPEARING_SOURCE); + let removed = false; + mock.module("node:fs/promises", { + namedExports: { + ...actual, + stat: async (candidate, ...args) => { + const result = await actual.stat(candidate, ...args); + if (!removed && path.resolve(String(candidate)) === watched) { + removed = true; + await actual.rm(watched, { force: true }); + } + return result; + }, + }, + }); + const provider = await import(pathToFileURL(process.env.DSH_PROVIDER_PATH).href); + const inventory = await provider.collectDshCustomizeInventory({ + workspace: process.env.DSH_WORKSPACE, + maxSourceBytes: 1_000, + maxBytes: 65_536, + }); + process.stdout.write(JSON.stringify({ + names: inventory.manage.rules.map((item) => item.name), + decisions: inventory.diagnostics.instructionDecisions, + })); + mock.restoreAll(); + `, + ], { + encoding: "utf8", + env: { + ...process.env, + HOME: path.join(root, "synthetic-home"), + DSH_DISAPPEARING_SOURCE: disappearing, + DSH_PROVIDER_PATH: PROVIDER_PATH, + DSH_WORKSPACE: workspace, + }, + }); + assert.equal(disappearingProbe.status, 0, disappearingProbe.stderr); + const disappearingResult = JSON.parse(disappearingProbe.stdout); + assert.equal(disappearingResult.names.includes(path.join(path.basename(workspace), "AGENTS.md")), false); + assert.ok(disappearingResult.names.includes("AGENTS.md")); + assert.ok(disappearingResult.decisions.some((item) => item.reason === "unavailable")); + + if (process.platform !== "win32") { + const unreadable = path.join(workspace, "AGENTS.md"); + await write(unreadable, "unreadable"); + await chmod(unreadable, 0o000); + try { + const withUnreadable = await collect({ workspace, maxSourceBytes: 1_000, maxBytes: 65_536 }); + assert.equal(names(withUnreadable.manage.rules).includes(path.join(path.basename(workspace), "AGENTS.md")), false); + assert.ok(names(withUnreadable.manage.rules).includes("AGENTS.md")); + } finally { + await chmod(unreadable, 0o600); + } + } + }); +}); + +test("DSH aggregate budgeting exposes only natively represented Instruction sources", async () => { + await withTempRoot("better-harness-dsh-budget-", async (root) => { + const { repository, workspace } = await createRepository(root, ["deep"]); + const cwd = path.join(workspace, "src"); + await mkdir(cwd, { recursive: true }); + await write(path.join(repository, "AGENTS.md"), "b".repeat(40_000)); + await write(path.join(workspace, "AGENTS.md"), "m".repeat(40_000)); + await write(path.join(cwd, "AGENTS.md"), "🙂".repeat(8_000)); + const collect = await loadDshProvider(); + + const full = await collect({ workspace, cwd, maxBytes: 200_000 }); + assert.equal(full.manage.rules.length, 3); + + const suffix = await collect({ workspace, cwd, maxBytes: 10_000 }); + assert.deepEqual(names(suffix.manage.rules), [path.join("deep", "src", "AGENTS.md")]); + assert.ok(suffix.diagnostics.instructionDecisions.some((item) => item.reason === "budget-omitted")); + assert.ok(suffix.diagnostics.instructionDecisions.some((item) => item.reason === "budget-truncated")); + + await write(path.join(cwd, "AGENTS.md"), ""); + const empty = await collect({ workspace, cwd, maxBytes: 512 }); + assert.ok(names(empty.manage.rules).includes(path.join("deep", "src", "AGENTS.md"))); + + await write(path.join(cwd, "AGENTS.md"), "non-empty"); + const noticeOnly = await collect({ workspace, cwd, maxBytes: 8 }); + assert.deepEqual(noticeOnly.manage.rules, []); + assert.ok(noticeOnly.diagnostics.instructionDecisions.some((item) => item.reason === "budget-not-represented")); + + const disabled = await collect({ workspace, cwd, maxBytes: 0 }); + assert.deepEqual(disabled.manage.rules, []); + assert.equal(disabled.diagnostics.instructionCollection, "disabled-by-byte-limit"); + + for (const limits of [ + { maxSourceBytes: 0 }, + { maxSourceBytes: -1 }, + { maxSourceBytes: Number.POSITIVE_INFINITY }, + { maxBytes: Number.NaN }, + ]) { + const sourceDisabled = await collect({ workspace, cwd, ...limits }); + assert.deepEqual(sourceDisabled.manage.rules, []); + assert.equal(sourceDisabled.diagnostics.instructionCollection, "disabled-by-byte-limit"); + } + }); +}); + +test("DSH aggregate budgeting preserves raw whitespace-heavy Instruction bytes", async () => { + await withTempRoot("better-harness-dsh-raw-budget-", async (root) => { + const { repository, workspace } = await createRepository(root, ["nested"]); + const rootContent = `ROOT${" ".repeat(400)}`; + const leafContent = `LEAF${" ".repeat(400)}`; + assert.equal(Buffer.byteLength(rootContent, "utf8"), 404); + assert.equal(Buffer.byteLength(leafContent, "utf8"), 404); + await write(path.join(repository, "AGENTS.md"), rootContent); + await write(path.join(workspace, "AGENTS.md"), leafContent); + + const collect = await loadDshProvider(); + const inventory = await collect({ + workspace, + cwd: workspace, + maxBytes: 512, + maxSourceBytes: 1_048_576, + }); + + assert.deepEqual(names(inventory.manage.rules), [path.join("nested", "AGENTS.md")]); + assert.ok(inventory.diagnostics.instructionDecisions.some((item) => ( + item.path === "AGENTS.md" && item.reason === "budget-omitted" + ))); + assert.ok(inventory.diagnostics.instructionDecisions.some((item) => ( + item.path === path.join("nested", "AGENTS.md") && item.reason === "budget-truncated" + ))); + assert.doesNotMatch(JSON.stringify(inventory), /ROOT|LEAF/u); + }); +}); + +test("DSH symlinks retain authorized lexical evidence without exposing target realpaths", async (t) => { + await withTempRoot("better-harness-dsh-links-", async (root) => { + const { repository, workspace } = await createRepository(root, ["workspace with 空格"]); + const external = path.join(root, "off-tree target 空格"); + const externalSkillDir = path.join(external, "directory-skill"); + const externalSkillFile = path.join(external, "file-skill.md"); + const externalInstruction = path.join(external, "instruction.md"); + await write(path.join(externalSkillDir, "SKILL.md"), skillDocument({ name: "linked-directory" })); + await write(externalSkillFile, skillDocument({ name: "linked-file" })); + await write(externalInstruction, "PRIVATE OFF-TREE INSTRUCTION PROSE"); + const skills = path.join(repository, ".dsh", "skills"); + await mkdir(skills, { recursive: true }); + if (!await symlinkOrSkip(t, externalSkillDir, path.join(skills, "directory-link"), process.platform === "win32" ? "junction" : "dir")) return; + if (!await symlinkOrSkip(t, externalSkillFile, path.join(skills, "file-link.md"), "file")) return; + await symlinkOrSkip(t, path.join(root, "broken"), path.join(skills, "broken-link.md"), "file"); + if (!await symlinkOrSkip(t, externalInstruction, path.join(workspace, "AGENTS.md"), "file")) return; + + const collect = await loadDshProvider(); + const inventory = await collect({ workspace }); + assert.ok(names(inventory.manage.skills).includes("linked-directory")); + assert.ok(names(inventory.manage.skills).includes("linked-file")); + assert.equal(names(inventory.manage.skills).includes("broken-link"), false); + assert.ok(names(inventory.manage.rules).includes(path.join("workspace with 空格", "AGENTS.md"))); + const serialized = JSON.stringify(inventory); + assert.doesNotMatch(serialized, new RegExp(external.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u")); + assert.doesNotMatch(serialized, /PRIVATE OFF-TREE INSTRUCTION PROSE/u); + for (const item of [...inventory.manage.skills, ...inventory.manage.rules]) { + assert.equal(path.isAbsolute(item.evidence.path), true); + } + }); +}); + +test("DSH diagnostics stay minimal, deterministic, and bounded", async () => { + await withTempRoot("better-harness-dsh-diagnostics-", async (root) => { + const { repository, workspace } = await createRepository(root); + const skills = path.join(repository, ".dsh", "skills"); + await writeDirectorySkill(skills, "winner", { name: "winner" }); + await writeDirectorySkill(path.join(repository, ".agents", "skills"), "winner", { name: "winner" }); + await mkdir(skills, { recursive: true }); + await Promise.all(Array.from({ length: 512 }, (_, index) => write( + path.join(skills, `malformed-${String(index).padStart(3, "0")}.md`), + "---\nname: [broken\n---\n", + ))); + await write(path.join(repository, "AGENTS.md"), "duplicate"); + await write(path.join(repository, "CLAUDE.md"), " duplicate \n"); + + const collect = await loadDshProvider(); + const first = await collect({ workspace }); + const second = await collect({ workspace }); + const diagnostics = first.diagnostics; + assert.equal(diagnostics.qualifiedDshVersion, DSH_VERSION); + assert.equal(diagnostics.qualifiedDshSourceSha, DSH_SOURCE_SHA); + assert.equal(diagnostics.evidenceKind, "configured-not-observed"); + assert.equal(diagnostics.configurationSource, "qualified-defaults"); + assert.equal(diagnostics.userHomeCollection, "not-authorized"); + assert.deepEqual(diagnostics.runtimeResolution, { + cordis: false, + profile: false, + preset: false, + runtimeSkills: false, + }); + assert.equal(diagnostics.shadowedSkills.length, 1); + assert.ok(diagnostics.skippedSkills.length > 0); + assert.ok(diagnostics.skippedSkills.length < 512); + assert.ok(diagnostics.instructionDecisions.some((item) => item.reason === "duplicate-content")); + assert.equal(diagnostics.diagnosticsTruncated, true); + assert.deepEqual(second.diagnostics, diagnostics); + for (const removed of [ + "rootCount", "entryCount", "validCandidateCount", "effectiveCount", "candidateCount", + "observedCount", "deduplicatedCount", "includedCount", "shadowedOmittedCount", + "skippedOmittedCount", "instructionOutcomeOmittedCount", + ]) { + assert.equal(Object.hasOwn(diagnostics, removed), false, removed); + } + }); +}); + +test("agent-customize exposes only the minimal public DSH CLI surface", async () => { + await withTempRoot("better-harness-dsh-cli-", async (root) => { + const { workspace } = await createRepository(root); + const dshHome = path.join(root, "dsh-home"); + const help = spawnSync(process.execPath, [CLI_PATH, "--help"], { + encoding: "utf8", + env: { ...process.env, HOME: path.join(root, "synthetic-home") }, + }); + assert.equal(help.status, 0, help.stderr); + assert.match(help.stdout, /\bdsh\b/u); + assert.match(help.stdout, /--workspace/u); + assert.match(help.stdout, /--cwd/u); + assert.match(help.stdout, /--dsh-home/u); + assert.match(help.stdout, /--include-user-home/u); + for (const flag of [ + "--dsh-agents-home", + "--dsh-custom-skill-dir", + "--dsh-bundled-skill-dir", + "--dsh-include-default-roots", + "--dsh-project-root-marker", + "--dsh-instruction-file-candidate", + "--dsh-local-instruction-file-candidate", + "--dsh-max-instruction-bytes", + "--dsh-max-instruction-source-bytes", + ]) { + assert.equal(help.stdout.includes(flag), false, flag); + } + + const inventory = spawnSync(process.execPath, [ + CLI_PATH, + "inventory", + "--provider", "dsh", + "--workspace", workspace, + "--cwd", workspace, + "--dsh-home", dshHome, + "--include-user-home=false", + ], { + encoding: "utf8", + env: { ...process.env, HOME: path.join(root, "synthetic-home") }, + }); + assert.equal(inventory.status, 0, inventory.stderr); + const payload = JSON.parse(inventory.stdout); + assert.equal(payload.provider, "dsh"); + assert.equal(payload.cwd, path.resolve(workspace)); + assert.equal(payload.dshHome, path.resolve(dshHome)); + assert.equal(payload.diagnostics.userHomeCollection, "not-authorized"); + }); +}); diff --git a/test/plugins/antigravity-plugin-artifact.test.mjs b/test/plugins/antigravity-plugin-artifact.test.mjs index 4d34949d..f68e579b 100644 --- a/test/plugins/antigravity-plugin-artifact.test.mjs +++ b/test/plugins/antigravity-plugin-artifact.test.mjs @@ -85,6 +85,7 @@ function basePackage(overrides = {}) { dependencies: { "@vscode/tree-sitter-wasm": "1.0.0", "esbuild-wasm": "1.0.0", + "yaml": "1.0.0", }, ...overrides, }; @@ -101,6 +102,7 @@ function baseSourcePackage(overrides = {}) { dependencies: { "@vscode/tree-sitter-wasm": "1.0.0", "esbuild-wasm": "1.0.0", + "yaml": "1.0.0", }, ...overrides, }; @@ -197,6 +199,12 @@ async function createArtifact({ ); await writeArtifactFile(pluginRoot, "node_modules/esbuild-wasm/worker.mjs", "export default true;\n"); await writeArtifactFile(pluginRoot, "node_modules/esbuild-wasm/LICENSE.md"); + await writeArtifactFile( + pluginRoot, + "node_modules/yaml/package.json", + '{"name":"yaml","version":"1.0.0"}\n', + ); + await writeArtifactFile(pluginRoot, "node_modules/yaml/LICENSE"); return { container, pluginRoot }; } @@ -454,7 +462,7 @@ test("freezes the pinned canonical Markdown closure and source link classificati const closure = await verifyMarkdownSourceClosure(repositoryRoot); assert.deepEqual( { nodes: closure.nodes, edges: closure.edges, files: closure.files.length }, - { nodes: 104, edges: 302, files: 107 }, + { nodes: 106, edges: 305, files: 109 }, ); for (const required of [ "AGENTS.md", @@ -644,6 +652,7 @@ test("enforces the closed Better Harness artifact package schema", async () => { dependencies: { "@vscode/tree-sitter-wasm": "", "esbuild-wasm": "1.0.0", + "yaml": "1.0.0", }, }), code: "package-dependency-version-invalid", @@ -653,6 +662,7 @@ test("enforces the closed Better Harness artifact package schema", async () => { dependencies: { "@vscode/tree-sitter-wasm": 1, "esbuild-wasm": "1.0.0", + "yaml": "1.0.0", }, }), code: "package-dependency-version-invalid", @@ -809,6 +819,8 @@ test("requires the complete artifact and dependency license profile", async () = "node_modules/@vscode/tree-sitter-wasm/package.json", "node_modules/@vscode/tree-sitter-wasm/LICENSE", "node_modules/esbuild-wasm/LICENSE.md", + "node_modules/yaml/package.json", + "node_modules/yaml/LICENSE", ]) { await withArtifact({}, async ({ pluginRoot }) => { await rm(path.join(pluginRoot, ...relativePath.split("/"))); @@ -971,7 +983,7 @@ test("builds, verifies, runs, and atomically replaces the real pinned artifact", const verified = await verifyAntigravityPluginArtifact(outputRoot); assert.deepEqual( { nodes: verified.markdownClosure.nodes, edges: verified.markdownClosure.edges, files: verified.markdownClosure.files.length }, - { nodes: 104, edges: 302, files: 107 }, + { nodes: 106, edges: 305, files: 109 }, ); assert.equal(verified.runtimeClosure.modules, 19); const help = spawnSync(process.execPath, ["scripts/better-harness.mjs", "--help"], { @@ -1036,6 +1048,7 @@ test("fails closed on source metadata, dependency, and staged closure errors", a dependencies: { "@vscode/tree-sitter-wasm": "1.0.0", "esbuild-wasm": "1.0.0", + "yaml": "1.0.0", unexpected: "1.0.0", }, }), diff --git a/test/plugins/host-plugin-artifact.test.mjs b/test/plugins/host-plugin-artifact.test.mjs index b4b8db17..cc101d27 100644 --- a/test/plugins/host-plugin-artifact.test.mjs +++ b/test/plugins/host-plugin-artifact.test.mjs @@ -57,10 +57,20 @@ test("runtime bundle includes the project license and runtime docs", async () => "scripts/workspace-topology/manifests.mjs", "vendor/tree-sitter-wasm/LICENSE", "vendor/esbuild-wasm/LICENSE.md", + "node_modules/yaml/package.json", + "node_modules/yaml/LICENSE", + "node_modules/yaml/dist/index.js", ]) { assert.ok(bundle.entries.includes(requiredPath), requiredPath); assert.ok(archiveEntries.has(requiredPath), requiredPath); } + assert.equal( + bundle.entries.some((entry) => ( + entry.startsWith("node_modules/") && !entry.startsWith("node_modules/yaml/") + )), + false, + "runtime bundle contains an undeclared node_modules package", + ); for (const forbiddenPrefix of [ "docs/.docusaurus", "docs/.gitignore", @@ -143,6 +153,8 @@ async function createMinimalHostArtifact(root) { "node_modules/@vscode/tree-sitter-wasm/LICENSE", "node_modules/esbuild-wasm/package.json", "node_modules/esbuild-wasm/LICENSE.md", + "node_modules/yaml/package.json", + "node_modules/yaml/LICENSE", ]) { await writeArtifactFile(root, relativePath); } @@ -276,6 +288,7 @@ test("Codex host plugin artifact is isolated and portable", async () => { "docs/glossary.md", "node_modules/@vscode/tree-sitter-wasm/LICENSE", "node_modules/esbuild-wasm/LICENSE.md", + "node_modules/yaml/LICENSE", ]) { assert.equal((await lstat(path.join(pluginRoot, requiredPath))).isFile(), true, requiredPath); } @@ -298,7 +311,7 @@ test("Codex host plugin artifact is isolated and portable", async () => { assert.equal(await lstat(path.join(pluginRoot, forbidden)).catch(() => null), null, forbidden); } - for (const dependency of ["@vscode/tree-sitter-wasm", "esbuild-wasm"]) { + for (const dependency of ["@vscode/tree-sitter-wasm", "esbuild-wasm", "yaml"]) { const stats = await lstat(path.join(pluginRoot, "node_modules", dependency, "package.json")); assert.equal(stats.isFile(), true); assert.equal(stats.isSymbolicLink(), false); diff --git a/test/plugins/host-support.test.mjs b/test/plugins/host-support.test.mjs index 362f6261..1bbff24e 100644 --- a/test/plugins/host-support.test.mjs +++ b/test/plugins/host-support.test.mjs @@ -45,10 +45,13 @@ test("capability projections are immutable, ordered, and independently addressed } const dsh = getHostDescriptor("dsh"); assert.ok(Object.isFrozen(dsh.capabilities)); - assert.deepEqual(dsh.capabilities, [HOST_CAPABILITIES.SESSION_ANALYSIS]); + assert.deepEqual(dsh.capabilities, [ + HOST_CAPABILITIES.SESSION_ANALYSIS, + HOST_CAPABILITIES.AGENT_CUSTOMIZE, + ]); assert.equal(hostIdsFor(HOST_CAPABILITIES.SESSION_ANALYSIS).includes("dsh"), true); + assert.equal(hostIdsFor(HOST_CAPABILITIES.AGENT_CUSTOMIZE).includes("dsh"), true); for (const capability of [ - HOST_CAPABILITIES.AGENT_CUSTOMIZE, HOST_CAPABILITIES.ASSET_PRACTICES, HOST_CAPABILITIES.HARNESS_REPORT, HOST_CAPABILITIES.REPORT_RENDERING,