From b5f1cfcf466644d69d16e55a31f4b1e2e7761b1b Mon Sep 17 00:00:00 2001 From: Diego Bustamante Date: Tue, 6 Oct 2026 10:08:02 -0400 Subject: [PATCH 1/4] feat(harness): v2 host smoke test and config schema validation in CI (issue #27) --- .github/workflows/ci.yml | 16 ++ CHANGELOG.md | 11 ++ bun.lock | 46 ++---- docs/reference/opencode-v2-facts.md | 24 +-- package.json | 6 + tests/config-schema-validator.ts | 88 +++++++++++ tests/fixture-config-schema.test.ts | 168 ++++++++++++++++++++ tests/v2-host-harness.ts | 229 ++++++++++++++++++++++++++++ tests/v2-host.test.ts | 182 ++++++++++++++++++++++ 9 files changed, 725 insertions(+), 45 deletions(-) create mode 100644 tests/config-schema-validator.ts create mode 100644 tests/fixture-config-schema.test.ts create mode 100644 tests/v2-host-harness.ts create mode 100644 tests/v2-host.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8ccabf..eafb8a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,19 @@ jobs: - name: Test run: bun test + + v2-harness: + name: V2 verification harness + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Host smoke test and config schema validation (issue #27) + run: bun test tests/v2-host.test.ts tests/fixture-config-schema.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e2baab5..3dcc53f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- V2 verification harness (issue #27, child of spec #23): `tests/v2-host.test.ts` loads the built package entry the way the v2 host does — real `@opencode/plugin` Effect activation, registrations audited against `@opencode/schema` `Agent.Info` (all ten agents, permission `evaluate` hook) and fails on registration loss; `tests/fixture-config-schema.test.ts` validates every produced fixture config (created default, splices, legacy-tolerated no-ops, README quick-start snippet) against the pinned `Config.Info`; a dedicated `V2 verification harness` CI job gates both +- `docs/reference/opencode-v2-facts.md` §14 dispositions from the harness run: root-index entrypoint fallback settled negative under Bun (§13 row 9) with per-item reachability for the remaining open flags + +### Fixed + +- **Registration loss under v2**: `package.json` now declares `exports["./server"]` pointing at the package entry — without it the host's server-kind resolution misses its only resolvable candidate under Bun (Bun's `ResolveMessage` is not an `Error`, so the root-index fallback's catch never fires) and the plugin is disabled at the entry stage; every consumer install was silently registering nothing + ## [0.9.2] - 2026-10-04 ### Added diff --git a/bun.lock b/bun.lock index 3effced..df68ae6 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,8 @@ "yaml": "^2.7.1", }, "devDependencies": { + "@opencode/schema": "2.0.23", + "@opencode/util": "2.0.23", "@types/bun": "latest", "@types/folder-hash": "^4.0.4", "@types/node": "latest", @@ -273,7 +275,7 @@ "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -283,7 +285,7 @@ "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], - "brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], @@ -423,7 +425,7 @@ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "minimatch": ["minimatch@7.4.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-Brg/fp/iAVDOQoHxkuN5bEYhyQlZhxddI78yWsCbeEwTHXQjlNLtiJDUsp1GIptVqMI7/gkJMz4vVAc01mpoBw=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], @@ -591,26 +593,16 @@ "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@npmcli/arborist/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@npmcli/git/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], - "@npmcli/map-workspaces/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@npmcli/promise-spawn/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], - "@opencode/util/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "@tufjs/models/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "folder-hash/minimatch": ["minimatch@7.4.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-Brg/fp/iAVDOQoHxkuN5bEYhyQlZhxddI78yWsCbeEwTHXQjlNLtiJDUsp1GIptVqMI7/gkJMz4vVAc01mpoBw=="], "gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], "gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], - "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "ignore-walk/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -639,21 +631,11 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - "@npmcli/arborist/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], - "@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], - "@npmcli/map-workspaces/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], - "@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], - "@opencode/util/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], - - "@tufjs/models/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], - - "glob/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], - - "ignore-walk/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], + "folder-hash/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], @@ -671,18 +653,12 @@ "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "@npmcli/arborist/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "@npmcli/map-workspaces/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "@opencode/util/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "folder-hash/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "@tufjs/models/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "ignore-walk/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/docs/reference/opencode-v2-facts.md b/docs/reference/opencode-v2-facts.md index 11fd059..9406457 100644 --- a/docs/reference/opencode-v2-facts.md +++ b/docs/reference/opencode-v2-facts.md @@ -5,6 +5,7 @@ Issue #24 (child of spec #23). Every opencode v2 API fact taught by this suite m 1. **Pinned source**: `anomalyco/opencode` at tag **v2.0.23**, commit `0fd7e2829449b052abf0078666669302923d77af`, tagged 2026-10-05 ("release: v2.0.23"). Snapshot taken 2026-10-05. Cited as `source: #` (paths relative to the repo root at the pinned commit). 2. **Official v2 docs**: live `opencode.ai/v2/docs/...` pages, and the docs bundled in the snapshot under `packages/web/src/content/docs/`. Cited as `docs: ` or `docs(in-repo): `. In-repo docs are **partially stale** (see Contradictions) — they rank below source and are never cited alone for a fact source contradicts. 3. **DeepWiki**: tertiary, never cited in this record for anything the first two settle. +4. **Verification harness** (issue #27): the pinned published v2 contract packages (`@opencode/plugin`, `@opencode/schema`, `@opencode/util`, all 2.0.23) exercised under the repo's Bun runtime by `tests/v2-host.test.ts` and `tests/fixture-config-schema.test.ts`. Cited as `harness: tests/#`. A harness citation settles only what the published packages can exhibit; it cannot observe the unpublished core host. Status vocabulary: **settled** (source is definitive), **hedged** (source cannot fully settle; guidance may state it with qualifying wording), **open-harness** (must be confirmed empirically by the verification harness before guidance asserts it as fact). @@ -112,7 +113,7 @@ Settled discovery: `source: packages/core/src/config/plugin/agent.ts#legacySourc - **String forms**: `-target` (or wildcard patterns) = removal/disable; `file://…`, `./…`, `../…`, absolute = local path resolved from the config file's directory; anything else = npm/Git package spec. Settled. `source: packages/core/src/config/plugin/source.ts#parse, scan`. - **Directory discovery**: every config root is scanned for `plugin/` and `plugins/` children; `.ts`/`.js` files, directories, and symlinks to either are admitted, sorted by path. Settled. `source: packages/core/src/plugin/source-directory.ts#names, discover`. - **Precedence**: auto-discovered first, explicit config applied last (so config can remove auto-discovered packages); config files merge lowest→highest (global → explicit → direct → project). Settled. `source: packages/core/src/config/plugin/source.ts#scan` (comment + order), `packages/core/src/config.ts#load`. Live docs agree: `docs: opencode.ai/v2/docs/plugins`. -- **Entrypoint resolution** (per package): `exports["./server"]` else package root index (subpaths tried: `server`, ``); `./tui` and `./rpc` exports enable TUI/RPC features. Standalone `.ts`/`.js` files load as-is. The server entrypoint must resolve inside the package directory. Settled. `source: packages/plugin/src/host.ts#Target, Entrypoints, resolve`; `packages/core/src/plugin/module.ts#load` (features tui/rpc, containment check in `config/plugin/source.ts#scan`). +- **Entrypoint resolution** (per package): `exports["./server"]` else package root index (subpaths tried: `server`, ``); `./tui` and `./rpc` exports enable TUI/RPC features. Standalone `.ts`/`.js` files load as-is. The server entrypoint must resolve inside the package directory. Settled. `source: packages/plugin/src/host.ts#Target, Entrypoints, resolve`; `packages/core/src/plugin/module.ts#load` (features tui/rpc, containment check in `config/plugin/source.ts#scan`). **Harness amendment (issue #27): the root-index fallback is unreachable under the Bun runtime — a package whose server-kind candidates both miss is disabled at the entry stage. Every distributed plugin package must declare `exports["./server"]`. Settled. `harness: tests/v2-host.test.ts` (see §14.6).** - **Install mechanics**: npm/Git specs install via `@npmcli/arborist` (not a shell-out to bun/npm) into a generation cache at `/npm///node_modules/`. Keys: `name@spec` for registry, `git--` for Git. Loaded generation = newest; startup loads cached immediately and installs missing in background; unpinned specs are checked for updates without auto-upgrade; exact versions and full commit hashes stay pinned; last 2 generations kept, 7-day retention. Settled. `source: packages/util/src/npm.ts` (`directory`, `install`, `collect`, `check`, `update`), `packages/core/src/plugin/supervisor.ts`, `packages/core/src/plugin/update.ts`. - **CLI management**: `opencode plugin add|list|check|update|remove`; `cli.json` in the global config dir configures CLI-only plugins. Settled. docs: `opencode.ai/v2/docs/plugins`; `source: packages/cli/src/config/config.ts` (`path.join(global.config, "cli.json")`). - **Local plugin dependencies**: live v2 docs no longer document the v1 `.opencode/package.json` + `bun install` behavior. **open-harness** (§14.1). @@ -189,19 +190,22 @@ Model-domain hooks are provider-scopable via `{ providerID }` (`ModelHooks` opti | 6 | Migration-guide claim "V2 discovers `.opencode/plugin/` and `.opencode/plugins/`" | Confirmed in source (`source-directory.ts#names`). Settled — guide assertion validated. | | 7 | Migration-guide claim "npm plugins installed automatically using Bun" appears only in stale in-repo docs | Source uses arborist directly, no bun. Settled. | | 8 | `CONTEXT.md` glossary "Plugin install" still says the package is listed in a config file's `plugin` array | Record is right for v2 (`plugins`); the glossary entry needs a v2 update — flagged here for the domain-docs pass per `docs/agents/domain.md`. Open (doc debt, not a source question). | +| 9 | C1's working assumption "root-index fallback works locally via `Host.resolve`" | **Settled negative** (issue #27). The published `Host.resolve` filters candidate misses through `error instanceof Error`, but Bun's `ResolveMessage` (thrown by `Bun.resolveSync`, which `@opencode/util/runtime-import` uses under Bun) is **not an `Error`** (`harness` probe, oven-sh/bun#7531) — the first missed candidate rethrows and later candidates never run; the aggregate call also evaluates `tui`/`rpc` eagerly, so it throws for any package without `./tui`. The core loader resolves **per kind** with try/catch (entry-stage failure disables that one plugin; startup continues — tertiary DeepWiki reading of `packages/opencode/src/plugin/loader.ts`, the snapshot's `packages/core` paths unpublished), so a package whose `server` candidate misses is silently disabled. Consequence: `exports["./server"]` is mandatory; `opencode-architect` declares it as of issue #27, and generated-package templates lack it (flagged for the guidance tickets). Settled. `harness: tests/v2-host.test.ts` | ## 14. Open flags → verification-harness inputs -Facts the pinned source cannot fully settle; harness must confirm before guidance states them unqualified: +Facts the pinned source cannot fully settle. The harness (issue #27, `tests/v2-host.test.ts` + `tests/fixture-config-schema.test.ts`) reaches only the published plugin-contract surface; items needing the unpublished core host runtime carry an explicit reachability disposition and stay **open-harness** until a core-host harness exists. -1. Local plugin dependency installation at startup (whether a `package.json` beside local plugins triggers install, and with which package manager) — §7. -2. `ctx.permission.rules()` session-scoped override end-to-end behavior (inheritance timing for child sessions) — §4. -3. Built-in unremovable plugin ids `opencode.config.policy` / `opencode.provider.opencode` as observed at runtime — §7. Source locator: `opencode.config.policy` in `packages/core/src/config/plugin/policy.ts`; `opencode.provider.opencode` in `packages/core/src/plugin/provider/opencode.ts` (not in policy.ts); a third built-in, `opencode.config.agent` (`packages/core/src/config/plugin/agent.ts`), has no removal-override claim in docs. -4. Instruction-file discovery set (exact ambient filenames, e.g. `AGENTS.md` variants) — §12. -5. `experimental.ws.*` hook stability (names/shapes may change; treat as hedged in guidance until then) — §9. -6. Generated-package compatibility: whether a plugin package whose exports expose only `.` (no `./server`) loads from npm cache (root-index fallback works locally via `Host.resolve`; confirm under the installed-package path) — §7. -7. TUI plugin lifecycle under `opencode` TUI host (feature flag exists in server; TUI-side load order not visible in server source) — §11. -8. Whether v1 `keybinds`/`theme`/`tui` keys auto-migrate, and to what, at runtime — §5 (in-repo docs claim only). +1. Local plugin dependency installation at startup (whether a `package.json` beside local plugins triggers install, and with which package manager) — §7. **Not reachable**: lives in the core supervisor/install path; the published packages expose no install machinery. Remains open-harness. +2. `ctx.permission.rules()` session-scoped override end-to-end behavior (inheritance timing for child sessions) — §4. **Not reachable**: needs a live session runtime. Remains open-harness. +3. Built-in unremovable plugin ids `opencode.config.policy` / `opencode.provider.opencode` as observed at runtime — §7. Source locator: `opencode.config.policy` in `packages/core/src/config/plugin/policy.ts`; `opencode.provider.opencode` in `packages/core/src/plugin/provider/opencode.ts` (not in policy.ts); a third built-in, `opencode.config.agent` (`packages/core/src/config/plugin/agent.ts`), has no removal-override claim in docs. **Not reachable**: built-ins are core-registration behavior. Remains open-harness. +4. Instruction-file discovery set (exact ambient filenames, e.g. `AGENTS.md` variants) — §12. **Not reachable**: discovery runs inside the core config loader. Remains open-harness. +5. `experimental.ws.*` hook stability (names/shapes may change; treat as hedged in guidance until then) — §9. **Partially confirmed**: the pinned 2.0.23 types expose the documented shapes; stability across versions cannot be observed from one pin. Remains hedged in guidance. +6. ~~Generated-package compatibility: whether a plugin package whose exports expose only `.` (no `./server`) loads from npm cache~~ — **Settled negative (issue #27)**. Under the Bun runtime the root-index fallback never runs: the server-kind candidate miss throws a `ResolveMessage` that the published resolution code cannot catch (it filters on `error instanceof Error`), so the core loader disables the plugin at the entry stage. The root-index fallback only ever worked where candidate misses are catchable `Error`s (Node). Every plugin package must declare `exports["./server"]`; `opencode-architect` does as of issue #27, generated-package templates must follow (see §13 row 9). `harness: tests/v2-host.test.ts` +7. TUI plugin lifecycle under `opencode` TUI host (feature flag exists in server; TUI-side load order not visible in server source) — §11. **Not reachable**: needs the TUI host process. Remains open-harness. +8. Whether v1 `keybinds`/`theme`/`tui` keys auto-migrate, and to what, at runtime — §5 (in-repo docs claim only). **Not reachable**: migration runs in the core config loader; the published schema ignores unknown keys and cannot observe it. Remains open-harness. + +Harness coverage that IS settled empirically at the pinned versions (issue #27): the built `opencode-architect` entry satisfies the v2 Module contract and, activated the way the host activates plugins, registers all ten agents (each decoding against `@opencode/schema` `Agent.Info`) plus exactly one permission `evaluate` hook that rewrites bundled `external_directory` asks (`harness: tests/v2-host.test.ts`); every config file the installer/editor produces — created default, splices into rich v2 configs, legacy-tolerated no-ops, and the README quick-start snippet — decodes against the pinned `Config.Info` (`harness: tests/fixture-config-schema.test.ts`). CI gates both via the `V2 verification harness` job. ## 15. Rules for guidance tickets (C5–C7, the guidance-rewrite children of spec #23) diff --git a/package.json b/package.json index 5090fa7..899e0b4 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ "private": false, "type": "module", "module": "index.ts", + "exports": { + ".": "./index.ts", + "./server": "./index.ts" + }, "bin": { "opencode-architect": "src/cli.ts" }, @@ -57,6 +61,8 @@ "yaml": "^2.7.1" }, "devDependencies": { + "@opencode/schema": "2.0.23", + "@opencode/util": "2.0.23", "@types/bun": "latest", "@types/folder-hash": "^4.0.4", "@types/node": "latest", diff --git a/tests/config-schema-validator.ts b/tests/config-schema-validator.ts new file mode 100644 index 0000000..aceb66d --- /dev/null +++ b/tests/config-schema-validator.ts @@ -0,0 +1,88 @@ +import { Schema } from "effect"; +import { Config } from "@opencode/schema"; + +export interface SchemaVerdict { + ok: boolean; + config: Config.Info | null; + issue: string | null; +} + +const decodeConfig = Schema.decodeUnknownResult(Config.Info); + +export class ConfigSchemaValidator { + public validateValue(value: unknown): SchemaVerdict { + const result = decodeConfig(value); + if (result._tag === "Success") return { ok: true, config: result.success, issue: null }; + return { ok: false, config: null, issue: flattenIssue(result.failure) }; + } + + public validateText(text: string, lenient: boolean): SchemaVerdict { + const parseable = lenient ? stripJsonc(text) : text; + let parsed: unknown; + try { + parsed = JSON.parse(parseable); + } catch (error) { + return { ok: false, config: null, issue: `fixture is not parseable JSON: ${String(error)}` }; + } + return this.validateValue(parsed); + } +} + +function stripJsonc(text: string): string { + return blankComments(text).replace(/,(\s*[}\]])/g, "$1"); +} + +function blankComments(text: string): string { + const chars = text.split(""); + let inString = false; + let inLineComment = false; + let inBlockComment = false; + for (let i = 0; i < chars.length; i++) { + const current = chars[i]; + const next = i + 1 < chars.length ? chars[i + 1] : ""; + if (inLineComment) { + if (current === "\n") inLineComment = false; + else chars[i] = " "; + continue; + } + if (inBlockComment) { + if (current === "*" && next === "/") { + chars[i] = " "; + chars[i + 1] = " "; + i++; + inBlockComment = false; + } else { + chars[i] = " "; + } + continue; + } + if (inString) { + if (current === "\\") i++; + else if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + continue; + } + if (current === "/" && next === "/") { + chars[i] = " "; + chars[i + 1] = " "; + inLineComment = true; + continue; + } + if (current === "/" && next === "*") { + chars[i] = " "; + chars[i + 1] = " "; + inBlockComment = true; + } + } + return chars.join(""); +} + +function flattenIssue(failure: unknown): string { + const issue = (failure as { issue?: unknown } | null)?.issue; + const message = (issue as { message?: unknown } | null)?.message; + if (typeof message === "string" && message.length > 0) return message; + return JSON.stringify(failure).slice(0, 300); +} diff --git a/tests/fixture-config-schema.test.ts b/tests/fixture-config-schema.test.ts new file mode 100644 index 0000000..e8fa47a --- /dev/null +++ b/tests/fixture-config-schema.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { PluginConfigEditor } from "../src/plugin-config"; +import { ConfigSchemaValidator } from "./config-schema-validator"; + +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); + +const RICH_V2_CONFIG = `{ + "$schema": "https://opencode.ai/config.json", + "model": { "providerID": "anthropic", "model": "claude-sonnet-4" }, + "permissions": [ + { "action": "shell", "resource": "bun test*", "effect": "allow" }, + { "action": "external_directory", "resource": "**", "effect": "ask" } + ], + "agents": { + "helper": { "mode": "subagent", "description": "A helper", "color": "#22c55e" } + }, + "commands": { + "review": { "template": "Review the current changes.", "description": "Review" } + }, + "mcp": { + "docs": { "type": "remote", "url": "https://mcp.example.com/sse" } + }, + "skills": ["./skills"], + "instructions": ["AGENTS.md"], + "plugins": [ + "other-plugin@latest", + { "package": "configured-plugin", "options": { "region": "eu" } } + ] +} +`; + +const LEGACY_AND_V2_CONFIG = `{ + "plugin": ["other-legacy@1"], + "plugins": ["kept-entry"] +} +`; + +async function makeProject(configFileName: string | null, contents: string | null): Promise { + const projectDir = await mkdtemp(path.join(tmpdir(), "opencode-architect-config-schema-")); + if (configFileName !== null && contents !== null) { + await writeFile(path.join(projectDir, configFileName), contents); + } + return projectDir; +} + +async function readProducedConfig(configPath: string): Promise { + return readFile(configPath, "utf-8"); +} + +function readmeQuickStartConfig(): string { + const readme = readFileSync(path.join(REPO_ROOT, "README.md"), "utf-8"); + const match = readme.match(/```json\n([\s\S]*?)```/); + if (!match) throw new Error("README carries no json snippet to validate"); + return match[1] as string; +} + +describe("produced fixture configs against the v2 config schema", () => { + const validator = new ConfigSchemaValidator(); + const editor = new PluginConfigEditor(); + + test("the config created by the installer for a clean scope validates", async () => { + const projectDir = await makeProject(null, null); + try { + const outcome = await editor.ensurePluginEntry("opencode-architect", { scope: "local", projectDir }); + expect(outcome.action).toBe("created"); + const text = await readProducedConfig(outcome.configPath ?? ""); + const verdict = validator.validateText(text, true); + expect(verdict.issue).toBeNull(); + expect(verdict.ok).toBe(true); + expect(verdict.config?.plugins).toEqual(["opencode-architect@latest"]); + } finally { + await rm(projectDir, { recursive: true, force: true }); + } + }); + + test("a splice into a rich v2 config keeps the whole file schema-valid", async () => { + const projectDir = await makeProject("opencode.json", RICH_V2_CONFIG); + try { + const outcome = await editor.ensurePluginEntry("opencode-architect", { scope: "local", projectDir }); + expect(outcome.action).toBe("updated"); + const text = await readProducedConfig(outcome.configPath ?? ""); + const verdict = validator.validateText(text, false); + expect(verdict.issue).toBeNull(); + expect(verdict.ok).toBe(true); + expect(verdict.config?.plugins).toHaveLength(3); + expect(Object.keys(verdict.config?.agents ?? {})).toContain("helper"); + } finally { + await rm(projectDir, { recursive: true, force: true }); + } + }); + + test("a legacy-only registration is a tolerated no-op and the untouched file validates", async () => { + const projectDir = await makeProject("opencode.json", "{\n \"plugin\": [\"opencode-architect@latest\"]\n}\n"); + try { + const outcome = await editor.ensurePluginEntry("opencode-architect", { scope: "local", projectDir }); + expect(outcome.action).toBe("noop"); + expect(outcome.warning).toContain("legacy v1"); + const text = await readProducedConfig(outcome.configPath ?? ""); + const verdict = validator.validateText(text, false); + expect(verdict.ok).toBe(true); + } finally { + await rm(projectDir, { recursive: true, force: true }); + } + }); + + test("a config holding both the legacy v1 key and a live v2 array validates after the splice", async () => { + const projectDir = await makeProject("opencode.json", LEGACY_AND_V2_CONFIG); + try { + const outcome = await editor.ensurePluginEntry("opencode-architect", { scope: "local", projectDir }); + expect(outcome.action).toBe("updated"); + const text = await readProducedConfig(outcome.configPath ?? ""); + const verdict = validator.validateText(text, false); + expect(verdict.ok).toBe(true); + expect(verdict.config?.plugins).toEqual(["opencode-architect@latest", "kept-entry"]); + } finally { + await rm(projectDir, { recursive: true, force: true }); + } + }); + + test("the quick-start config taught in the README validates", () => { + const verdict = validator.validateText(readmeQuickStartConfig(), false); + expect(verdict.issue).toBeNull(); + expect(verdict.ok).toBe(true); + expect(verdict.config?.plugins).toEqual(["opencode-architect@latest"]); + }); + + test("the pinned schema rejects v1 permission maps and invalid rule effects", () => { + expect(validator.validateValue({ permissions: { bash: "allow" } }).ok).toBe(false); + expect( + validator.validateValue({ permissions: [{ action: "shell", resource: "*", effect: "sometimes" }] }).ok, + ).toBe(false); + expect(validator.validateValue({ plugins: [{ package: 42 }] }).ok).toBe(false); + }); + + test("unparseable fixtures are reported as issues, never thrown", async () => { + const projectDir = await makeProject("opencode.jsonc", "{ not json ]"); + try { + const text = await readProducedConfig(path.join(projectDir, "opencode.jsonc")); + const verdict = validator.validateText(text, true); + expect(verdict.ok).toBe(false); + expect(verdict.issue).toContain("not parseable"); + } finally { + await rm(projectDir, { recursive: true, force: true }); + } + }); + + test("schema validation accepts every config variant the editor can produce from a v2 baseline", async () => { + const variants: Array<[string, string]> = [ + ["opencode.json", RICH_V2_CONFIG], + ["opencode.jsonc", `{\n // comment\n "plugins": ["a@1"],\n}`], + ]; + for (const [fileName, contents] of variants) { + const projectDir = await makeProject(null, null); + await mkdir(projectDir, { recursive: true }); + await writeFile(path.join(projectDir, fileName), contents); + const outcome = await editor.ensurePluginEntry("opencode-architect", { scope: "local", projectDir }); + expect(outcome.action).toBe("updated"); + const text = await readProducedConfig(outcome.configPath ?? ""); + const verdict = validator.validateText(text, fileName.endsWith(".jsonc")); + expect(verdict.ok).toBe(true); + await rm(projectDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/v2-host-harness.ts b/tests/v2-host-harness.ts new file mode 100644 index 0000000..6be4c11 --- /dev/null +++ b/tests/v2-host-harness.ts @@ -0,0 +1,229 @@ +import path from "node:path"; +import { mkdir, mkdtemp, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { Effect, Schema } from "effect"; +import { resolveModule } from "@opencode/util/runtime-import"; +import * as Host from "@opencode/plugin/host"; +import { Agent, Plugin } from "@opencode/plugin/effect"; +import type { AgentEditor } from "@opencode/plugin/effect/agent"; +import type { PermissionEvaluation } from "@opencode/plugin/effect/permission"; +import { AGENT_FILENAMES } from "../src/agent-loader"; + +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); +const PACKAGE_NAME = "opencode-architect"; +const SERVER_SUBPATHS = ["server", ""] as const; +const IGNORED_RESOLUTION_CODES = new Set([ + "ENOENT", + "ENOTDIR", + "MODULE_NOT_FOUND", + "ERR_MODULE_NOT_FOUND", + "ERR_PACKAGE_PATH_NOT_EXPORTED", + "ERR_UNSUPPORTED_DIR_IMPORT", +]); + +export interface ModuleContractCheck { + entrypoint: string; + id: string; + effectKind: "effect" | "setup" | null; + violation: string | null; +} + +export interface HostAgentRecord { + id: string; + name: string; + mode: string; + system: string; + description: string; + permissions: Array<{ action: string; resource: string; effect: string }>; + schemaDecoded: boolean; + schemaIssue: string | null; +} + +export interface RegistrationAudit { + pluginId: string; + entrypointResolved: boolean; + agentsExpected: string[]; + agentsRegistered: string[]; + missingAgents: string[]; + unexpectedAgents: string[]; + permissionHookCount: number; + violations: string[]; +} + +export interface RecordingContext { + context: Plugin.Context; + agents: Map; + permissionHooks: Array<(input: PermissionEvaluation) => Effect.Effect>; + restore: (() => void) | null; +} + +const SUITE_MODES = new Set(["subagent", "primary"]); +const PERMISSION_EFFECTS = new Set(["allow", "ask", "deny"]); +const decodeAgentInfo = Schema.decodeUnknownResult(Agent.Info); + +export class V2HostHarness { + public suiteAgentNames(): string[] { + return AGENT_FILENAMES.map((filename) => path.basename(filename, ".md")); + } + + public resolveServerEntrypoint(directory: string, name: string | null): string | null { + try { + return serverKindEntry(directory, name); + } catch { + return null; + } + } + + public async mountInstalledLayout(): Promise<{ cacheRoot: string; dispose: () => Promise }> { + const cacheRoot = await mkdtemp(path.join(tmpdir(), "opencode-architect-host-cache-")); + const linkPath = path.join(cacheRoot, "node_modules", PACKAGE_NAME); + await mkdir(path.dirname(linkPath), { recursive: true }); + await symlink(REPO_ROOT, linkPath, process.platform === "win32" ? "junction" : "dir"); + return { cacheRoot, dispose: () => rm(cacheRoot, { recursive: true, force: true }) }; + } + + public async loadInstalled(cacheRoot: string): Promise<{ definition: Plugin.Plugin; check: ModuleContractCheck }> { + const entrypoint = this.resolveServerEntrypoint(cacheRoot, PACKAGE_NAME); + if (entrypoint === null) { + throw new Error(`Host.resolve found no server entrypoint for ${PACKAGE_NAME} under ${cacheRoot}`); + } + const check = this.moduleContract(await Host.load(entrypoint), entrypoint); + if (check.violation !== null) throw new Error(`Built entry violates the v2 Module contract: ${check.violation}`); + const loaded = (await Host.load(entrypoint)) as { default: Plugin.Plugin }; + return { definition: loaded.default, check }; + } + + public moduleContract(loaded: unknown, entrypoint: string): ModuleContractCheck { + const definition = (loaded as { default?: unknown } | null)?.default; + if (definition === null || typeof definition !== "object") { + return { entrypoint, id: "", effectKind: null, violation: "module default export is not an object" }; + } + const candidate = definition as { id?: unknown; effect?: unknown; setup?: unknown }; + if (typeof candidate.id !== "string" || candidate.id.length === 0) { + return { entrypoint, id: "", effectKind: null, violation: "definition id is not a non-empty string" }; + } + if (typeof candidate.effect === "function") { + return { entrypoint, id: candidate.id, effectKind: "effect", violation: null }; + } + if (typeof candidate.setup === "function") { + return { entrypoint, id: candidate.id, effectKind: "setup", violation: null }; + } + return { entrypoint, id: candidate.id, effectKind: null, violation: "definition has neither effect nor setup function" }; + } + + public recordingContext(directory: string): RecordingContext { + const agents = new Map(); + const permissionHooks: Array<(input: PermissionEvaluation) => Effect.Effect> = []; + const context = { + location: { directory }, + agent: { transform: (callback: (editor: AgentEditor) => void) => Effect.sync(() => callback(recordingAgentEditor(agents))) }, + permission: { + hook: (_name: string, callback: (input: PermissionEvaluation) => Effect.Effect) => + Effect.sync(() => { + permissionHooks.push(callback); + }), + }, + } as unknown as Plugin.Context; + return { context, agents, permissionHooks, restore: null }; + } + + public activate(definition: Plugin.Plugin, recording: RecordingContext): Promise { + return Effect.runPromise(Effect.scoped(definition.effect(recording.context))); + } + + public audit(recording: RecordingContext, pluginId: string, entrypointResolved: boolean): RegistrationAudit { + const agentsExpected = this.suiteAgentNames(); + const agentsRegistered = [...recording.agents.keys()]; + const audit: RegistrationAudit = { + pluginId, + entrypointResolved, + agentsExpected, + agentsRegistered, + missingAgents: agentsExpected.filter((name) => !recording.agents.has(name)), + unexpectedAgents: agentsRegistered.filter((name) => !agentsExpected.includes(name)), + permissionHookCount: recording.permissionHooks.length, + violations: [], + }; + if (!entrypointResolved) audit.violations.push("plugin entrypoint did not resolve under Host.resolve"); + for (const name of audit.missingAgents) audit.violations.push(`agent not registered: ${name}`); + for (const name of audit.unexpectedAgents) audit.violations.push(`unexpected agent registered: ${name}`); + for (const name of agentsRegistered) this.auditAgent(audit, name, recording.agents.get(name)); + if (recording.permissionHooks.length !== 1) { + audit.violations.push(`permission evaluate hook registered ${recording.permissionHooks.length} times, expected 1`); + } + if (pluginId !== "opencode-architect") audit.violations.push(`plugin id is "${pluginId}", expected "opencode-architect"`); + return audit; + } + + private auditAgent(audit: RegistrationAudit, name: string, record: unknown): void { + const agent = normalizeAgent(record); + const decoded = decodeAgentInfo(record); + if (decoded._tag !== "Success") { + agent.schemaDecoded = false; + agent.schemaIssue = issueText(decoded.failure); + audit.violations.push(`agent ${name} does not decode against @opencode/schema Agent.Info: ${agent.schemaIssue}`); + } + if (!SUITE_MODES.has(agent.mode)) audit.violations.push(`agent ${name} has unsupported mode "${agent.mode}"`); + if (agent.system.trim().length === 0) audit.violations.push(`agent ${name} has an empty system prompt`); + if (agent.description.trim().length === 0) audit.violations.push(`agent ${name} has an empty description`); + for (const rule of agent.permissions) { + if (!PERMISSION_EFFECTS.has(rule.effect)) { + audit.violations.push(`agent ${name} permission rule ${rule.action} has invalid effect "${rule.effect}"`); + } + } + } +} + +function serverKindEntry(directory: string, name: string | null): string | null { + for (const subpath of SERVER_SUBPATHS) { + const specifier = + name === null ? path.resolve(directory, subpath || "index") : [name, subpath].filter(Boolean).join("/"); + try { + return resolveModule(specifier, directory); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || !IGNORED_RESOLUTION_CODES.has(String(error.code))) { + throw error; + } + } + } + return null; +} + +function recordingAgentEditor(registered: Map): AgentEditor { + return { + list: () => [...registered.values()] as never, + get: (id: string) => registered.get(id) as never, + default: (id: string | undefined) => { + if (typeof id === "string") registered.set(id, Agent.Info.default(Agent.ID.make(id))); + }, + update: (id: string, update: (agent: never) => void) => { + const current = registered.get(id) ?? Agent.Info.default(Agent.ID.make(id)); + update(current as never); + registered.set(id, current); + }, + remove: (id: string) => { + registered.delete(id); + }, + }; +} + +function normalizeAgent(record: unknown): HostAgentRecord { + const candidate = (record ?? {}) as Partial; + return { + id: candidate.id ?? "", + name: candidate.name ?? "", + mode: candidate.mode ?? "", + system: candidate.system ?? "", + description: candidate.description ?? "", + permissions: Array.isArray(candidate.permissions) ? candidate.permissions : [], + schemaDecoded: true, + schemaIssue: null, + }; +} + +function issueText(failure: unknown): string { + const issue = (failure as { issue?: unknown } | null)?.issue; + const message = (issue as { message?: unknown } | null)?.message; + if (typeof message === "string" && message.length > 0) return message; + return JSON.stringify(failure).slice(0, 300); +} diff --git a/tests/v2-host.test.ts b/tests/v2-host.test.ts new file mode 100644 index 0000000..46f6375 --- /dev/null +++ b/tests/v2-host.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Effect } from "effect"; +import type { PermissionEvaluation } from "@opencode/plugin/effect/permission"; +import { OpencodeArchitectPlugin } from "../src/plugin"; +import { captureConsole } from "./test-helpers"; +import { V2HostHarness } from "./v2-host-harness"; + +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); + +async function makeInstalledPackage(layout: { exports: boolean; serverEntry: boolean }): Promise { + const cacheRoot = await mkdtemp(path.join(tmpdir(), "opencode-architect-host-pkg-")); + const packageDir = path.join(cacheRoot, "node_modules", "opencode-architect"); + await mkdir(packageDir, { recursive: true }); + const manifest: Record = { + name: "opencode-architect", + version: "0.0.0-harness", + type: "module", + module: "index.ts", + }; + if (layout.exports) { + manifest.exports = layout.serverEntry + ? { ".": "./index.ts", "./server": "./server.ts" } + : { ".": "./index.ts", "./server": "./index.ts" }; + } + await writeFile(path.join(packageDir, "package.json"), JSON.stringify(manifest)); + await writeFile( + path.join(packageDir, "index.ts"), + "const plugin = { id: 'opencode-architect.harness-fixture', effect: () => Effect.void };\nexport default plugin;\n", + ); + if (layout.serverEntry) { + await writeFile(path.join(packageDir, "server.ts"), "export default { id: 'server-entry', effect: () => Effect.void };\n"); + } + return cacheRoot; +} + +async function makeEmptyPackage(): Promise { + const packageDir = await mkdtemp(path.join(tmpdir(), "opencode-architect-host-empty-")); + await writeFile(path.join(packageDir, "package.json"), JSON.stringify({ name: "opencode-architect", type: "module" })); + return packageDir; +} + +describe("v2 host entrypoint resolution (facts §14.6)", () => { + const harness = new V2HostHarness(); + + test("the built package resolves through the ./server export from an installed-package layout", async () => { + const mounted = await harness.mountInstalledLayout(); + try { + const entrypoint = harness.resolveServerEntrypoint(mounted.cacheRoot, "opencode-architect"); + expect(entrypoint).not.toBeNull(); + expect(entrypoint?.replaceAll("\\", "/")).toContain("/index.ts"); + } finally { + await mounted.dispose(); + } + }); + + test("a package exposing only the root index cannot resolve its server kind under Bun (settled facts §14.6)", async () => { + const cacheRoot = await makeInstalledPackage({ exports: false, serverEntry: false }); + try { + expect(harness.resolveServerEntrypoint(cacheRoot, "opencode-architect")).toBeNull(); + } finally { + await rm(cacheRoot, { recursive: true, force: true }); + } + }); + + test("an explicit ./server export wins over the root index", async () => { + const cacheRoot = await makeInstalledPackage({ exports: true, serverEntry: true }); + try { + const entrypoint = harness.resolveServerEntrypoint(cacheRoot, "opencode-architect"); + expect(entrypoint?.replaceAll("\\", "/")).toContain("server.ts"); + } finally { + await rm(cacheRoot, { recursive: true, force: true }); + } + }); + + test("a package without any resolvable entrypoint surfaces as null, not a crash", async () => { + const packageDir = await makeEmptyPackage(); + try { + expect(harness.resolveServerEntrypoint(packageDir, null)).toBeNull(); + } finally { + await rm(packageDir, { recursive: true, force: true }); + } + }); +}); + +describe("built plugin under the v2 host contract", () => { + const harness = new V2HostHarness(); + let mounted: Awaited>; + + test("the built entry satisfies the v2 Module contract through the real host loader", async () => { + mounted = await harness.mountInstalledLayout(); + const { check } = await harness.loadInstalled(mounted.cacheRoot); + expect(check.violation).toBeNull(); + expect(check.effectKind).toBe("effect"); + expect(check.id).toBe("opencode-architect"); + expect(check.entrypoint.replaceAll("\\", "/")).toContain("/index.ts"); + }); + + test("activation registers the full agent suite and the permission evaluate hook", async () => { + const logs = captureConsole("log"); + const warnings = captureConsole("warn"); + const { definition } = await harness.loadInstalled(mounted.cacheRoot); + const recording = harness.recordingContext(tmpdir()); + + await harness.activate(definition, recording); + + const audit = harness.audit(recording, "opencode-architect", true); + expect(audit.violations).toEqual([]); + expect(audit.agentsRegistered).toHaveLength(10); + expect(audit.permissionHookCount).toBe(1); + for (const name of audit.agentsRegistered) { + const record = recording.agents.get(name) as { mode: string; permissions: unknown[] }; + expect(["subagent", "primary"]).toContain(record.mode); + expect(record.permissions.length).toBeGreaterThanOrEqual(0); + } + expect(audit.agentsRegistered).toContain("opencode-architect"); + expect(audit.agentsRegistered).toContain("opencode-skill-creator"); + logs.restore(); + warnings.restore(); + }); + + test("the registered evaluate hook rewrites bundled external_directory asks end to end", async () => { + const logs = captureConsole("log"); + const warnings = captureConsole("warn"); + const { definition } = await harness.loadInstalled(mounted.cacheRoot); + const recording = harness.recordingContext(tmpdir()); + await harness.activate(definition, recording); + + const evaluate = recording.permissionHooks[0]; + if (!evaluate) throw new Error("permission evaluate hook was not registered"); + const input = { + sessionID: "ses_harness", + action: "external_directory", + resources: [path.join(REPO_ROOT, "agents", "opencode-architect.md")], + effect: "ask", + } as unknown as PermissionEvaluation; + + await Effect.runPromise(evaluate(input)); + + expect(input.effect).toBe("allow"); + logs.restore(); + warnings.restore(); + }); +}); + +describe("the harness fails on registration loss", () => { + const harness = new V2HostHarness(); + + test("a plugin whose suite fails to load produces audit violations instead of a silent pass", async () => { + const warnings = captureConsole("warn"); + const scratch = await mkdtemp(path.join(tmpdir(), "opencode-architect-host-loss-")); + const definition = new OpencodeArchitectPlugin(path.join(scratch, "missing-agents"), scratch).toDefinition(); + const recording = harness.recordingContext(tmpdir()); + + await harness.activate(definition, recording); + + const audit = harness.audit(recording, "opencode-architect", true); + expect(audit.agentsRegistered).toHaveLength(0); + expect(audit.permissionHookCount).toBe(0); + expect(audit.violations.length).toBeGreaterThanOrEqual(10); + expect(audit.violations).toContain("permission evaluate hook registered 0 times, expected 1"); + expect(audit.missingAgents).toContain("opencode-architect"); + expect(warnings.lines[0]).toContain("bunx opencode-architect clear-cache"); + await rm(scratch, { recursive: true, force: true }); + warnings.restore(); + }); + + test("a package that fails entrypoint resolution is a violation, not a skip", async () => { + const packageDir = await makeEmptyPackage(); + try { + const entrypoint = harness.resolveServerEntrypoint(packageDir, null); + const recording = harness.recordingContext(tmpdir()); + const audit = harness.audit(recording, "opencode-architect", entrypoint !== null); + expect(audit.entrypointResolved).toBe(false); + expect(audit.violations).toContain("plugin entrypoint did not resolve under Host.resolve"); + } finally { + await rm(packageDir, { recursive: true, force: true }); + } + }); +}); From cd40afad272b43c8d7fad56cb531a208e130e178 Mon Sep 17 00:00:00 2001 From: Diego Bustamante Date: Tue, 6 Oct 2026 10:13:49 -0400 Subject: [PATCH 2/4] =?UTF-8?q?refactor(harness):=20review=20conformance?= =?UTF-8?q?=20=E2=80=94=20shared=20issue=20flattening,=20single=20host=20l?= =?UTF-8?q?oad,=20pinned=20=C2=A714.6=20mechanism=20(issue=20#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/config-schema-validator.ts | 6 ++---- tests/fixture-config-schema.test.ts | 33 ++++++++++++++--------------- tests/v2-host-harness.ts | 26 +++++++---------------- tests/v2-host.test.ts | 14 ++++++++++-- 4 files changed, 38 insertions(+), 41 deletions(-) diff --git a/tests/config-schema-validator.ts b/tests/config-schema-validator.ts index aceb66d..6eb0b74 100644 --- a/tests/config-schema-validator.ts +++ b/tests/config-schema-validator.ts @@ -30,9 +30,7 @@ export class ConfigSchemaValidator { function stripJsonc(text: string): string { return blankComments(text).replace(/,(\s*[}\]])/g, "$1"); -} - -function blankComments(text: string): string { +}function blankComments(text: string): string { const chars = text.split(""); let inString = false; let inLineComment = false; @@ -80,7 +78,7 @@ function blankComments(text: string): string { return chars.join(""); } -function flattenIssue(failure: unknown): string { +export function flattenIssue(failure: unknown): string { const issue = (failure as { issue?: unknown } | null)?.issue; const message = (issue as { message?: unknown } | null)?.message; if (typeof message === "string" && message.length > 0) return message; diff --git a/tests/fixture-config-schema.test.ts b/tests/fixture-config-schema.test.ts index e8fa47a..ae2cbe6 100644 --- a/tests/fixture-config-schema.test.ts +++ b/tests/fixture-config-schema.test.ts @@ -47,10 +47,6 @@ async function makeProject(configFileName: string | null, contents: string | nul return projectDir; } -async function readProducedConfig(configPath: string): Promise { - return readFile(configPath, "utf-8"); -} - function readmeQuickStartConfig(): string { const readme = readFileSync(path.join(REPO_ROOT, "README.md"), "utf-8"); const match = readme.match(/```json\n([\s\S]*?)```/); @@ -67,7 +63,7 @@ describe("produced fixture configs against the v2 config schema", () => { try { const outcome = await editor.ensurePluginEntry("opencode-architect", { scope: "local", projectDir }); expect(outcome.action).toBe("created"); - const text = await readProducedConfig(outcome.configPath ?? ""); + const text = await readFile(outcome.configPath ?? "", "utf-8"); const verdict = validator.validateText(text, true); expect(verdict.issue).toBeNull(); expect(verdict.ok).toBe(true); @@ -82,7 +78,7 @@ describe("produced fixture configs against the v2 config schema", () => { try { const outcome = await editor.ensurePluginEntry("opencode-architect", { scope: "local", projectDir }); expect(outcome.action).toBe("updated"); - const text = await readProducedConfig(outcome.configPath ?? ""); + const text = await readFile(outcome.configPath ?? "", "utf-8"); const verdict = validator.validateText(text, false); expect(verdict.issue).toBeNull(); expect(verdict.ok).toBe(true); @@ -99,7 +95,7 @@ describe("produced fixture configs against the v2 config schema", () => { const outcome = await editor.ensurePluginEntry("opencode-architect", { scope: "local", projectDir }); expect(outcome.action).toBe("noop"); expect(outcome.warning).toContain("legacy v1"); - const text = await readProducedConfig(outcome.configPath ?? ""); + const text = await readFile(outcome.configPath ?? "", "utf-8"); const verdict = validator.validateText(text, false); expect(verdict.ok).toBe(true); } finally { @@ -112,7 +108,7 @@ describe("produced fixture configs against the v2 config schema", () => { try { const outcome = await editor.ensurePluginEntry("opencode-architect", { scope: "local", projectDir }); expect(outcome.action).toBe("updated"); - const text = await readProducedConfig(outcome.configPath ?? ""); + const text = await readFile(outcome.configPath ?? "", "utf-8"); const verdict = validator.validateText(text, false); expect(verdict.ok).toBe(true); expect(verdict.config?.plugins).toEqual(["opencode-architect@latest", "kept-entry"]); @@ -139,7 +135,7 @@ describe("produced fixture configs against the v2 config schema", () => { test("unparseable fixtures are reported as issues, never thrown", async () => { const projectDir = await makeProject("opencode.jsonc", "{ not json ]"); try { - const text = await readProducedConfig(path.join(projectDir, "opencode.jsonc")); + const text = await readFile(path.join(projectDir, "opencode.jsonc"), "utf-8"); const verdict = validator.validateText(text, true); expect(verdict.ok).toBe(false); expect(verdict.issue).toContain("not parseable"); @@ -155,14 +151,17 @@ describe("produced fixture configs against the v2 config schema", () => { ]; for (const [fileName, contents] of variants) { const projectDir = await makeProject(null, null); - await mkdir(projectDir, { recursive: true }); - await writeFile(path.join(projectDir, fileName), contents); - const outcome = await editor.ensurePluginEntry("opencode-architect", { scope: "local", projectDir }); - expect(outcome.action).toBe("updated"); - const text = await readProducedConfig(outcome.configPath ?? ""); - const verdict = validator.validateText(text, fileName.endsWith(".jsonc")); - expect(verdict.ok).toBe(true); - await rm(projectDir, { recursive: true, force: true }); + try { + await mkdir(projectDir, { recursive: true }); + await writeFile(path.join(projectDir, fileName), contents); + const outcome = await editor.ensurePluginEntry("opencode-architect", { scope: "local", projectDir }); + expect(outcome.action).toBe("updated"); + const text = await readFile(outcome.configPath ?? "", "utf-8"); + const verdict = validator.validateText(text, fileName.endsWith(".jsonc")); + expect(verdict.ok).toBe(true); + } finally { + await rm(projectDir, { recursive: true, force: true }); + } } }); }); diff --git a/tests/v2-host-harness.ts b/tests/v2-host-harness.ts index 6be4c11..3963398 100644 --- a/tests/v2-host-harness.ts +++ b/tests/v2-host-harness.ts @@ -8,6 +8,7 @@ import { Agent, Plugin } from "@opencode/plugin/effect"; import type { AgentEditor } from "@opencode/plugin/effect/agent"; import type { PermissionEvaluation } from "@opencode/plugin/effect/permission"; import { AGENT_FILENAMES } from "../src/agent-loader"; +import { flattenIssue } from "./config-schema-validator"; const REPO_ROOT = path.resolve(import.meta.dirname, ".."); const PACKAGE_NAME = "opencode-architect"; @@ -35,7 +36,6 @@ export interface HostAgentRecord { system: string; description: string; permissions: Array<{ action: string; resource: string; effect: string }>; - schemaDecoded: boolean; schemaIssue: string | null; } @@ -54,7 +54,6 @@ export interface RecordingContext { context: Plugin.Context; agents: Map; permissionHooks: Array<(input: PermissionEvaluation) => Effect.Effect>; - restore: (() => void) | null; } const SUITE_MODES = new Set(["subagent", "primary"]); @@ -68,7 +67,7 @@ export class V2HostHarness { public resolveServerEntrypoint(directory: string, name: string | null): string | null { try { - return serverKindEntry(directory, name); + return resolveServerKindEntrypoint(directory, name); } catch { return null; } @@ -87,10 +86,10 @@ export class V2HostHarness { if (entrypoint === null) { throw new Error(`Host.resolve found no server entrypoint for ${PACKAGE_NAME} under ${cacheRoot}`); } - const check = this.moduleContract(await Host.load(entrypoint), entrypoint); + const loaded = (await Host.load(entrypoint)) as { default: unknown }; + const check = this.moduleContract(loaded, entrypoint); if (check.violation !== null) throw new Error(`Built entry violates the v2 Module contract: ${check.violation}`); - const loaded = (await Host.load(entrypoint)) as { default: Plugin.Plugin }; - return { definition: loaded.default, check }; + return { definition: loaded.default as Plugin.Plugin, check }; } public moduleContract(loaded: unknown, entrypoint: string): ModuleContractCheck { @@ -124,7 +123,7 @@ export class V2HostHarness { }), }, } as unknown as Plugin.Context; - return { context, agents, permissionHooks, restore: null }; + return { context, agents, permissionHooks }; } public activate(definition: Plugin.Plugin, recording: RecordingContext): Promise { @@ -159,8 +158,7 @@ export class V2HostHarness { const agent = normalizeAgent(record); const decoded = decodeAgentInfo(record); if (decoded._tag !== "Success") { - agent.schemaDecoded = false; - agent.schemaIssue = issueText(decoded.failure); + agent.schemaIssue = flattenIssue(decoded.failure); audit.violations.push(`agent ${name} does not decode against @opencode/schema Agent.Info: ${agent.schemaIssue}`); } if (!SUITE_MODES.has(agent.mode)) audit.violations.push(`agent ${name} has unsupported mode "${agent.mode}"`); @@ -174,7 +172,7 @@ export class V2HostHarness { } } -function serverKindEntry(directory: string, name: string | null): string | null { +function resolveServerKindEntrypoint(directory: string, name: string | null): string | null { for (const subpath of SERVER_SUBPATHS) { const specifier = name === null ? path.resolve(directory, subpath || "index") : [name, subpath].filter(Boolean).join("/"); @@ -216,14 +214,6 @@ function normalizeAgent(record: unknown): HostAgentRecord { system: candidate.system ?? "", description: candidate.description ?? "", permissions: Array.isArray(candidate.permissions) ? candidate.permissions : [], - schemaDecoded: true, schemaIssue: null, }; } - -function issueText(failure: unknown): string { - const issue = (failure as { issue?: unknown } | null)?.issue; - const message = (issue as { message?: unknown } | null)?.message; - if (typeof message === "string" && message.length > 0) return message; - return JSON.stringify(failure).slice(0, 300); -} diff --git a/tests/v2-host.test.ts b/tests/v2-host.test.ts index 46f6375..f49e14b 100644 --- a/tests/v2-host.test.ts +++ b/tests/v2-host.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { Effect } from "effect"; +import * as Host from "@opencode/plugin/host"; import type { PermissionEvaluation } from "@opencode/plugin/effect/permission"; import { OpencodeArchitectPlugin } from "../src/plugin"; import { captureConsole } from "./test-helpers"; @@ -59,6 +60,7 @@ describe("v2 host entrypoint resolution (facts §14.6)", () => { test("a package exposing only the root index cannot resolve its server kind under Bun (settled facts §14.6)", async () => { const cacheRoot = await makeInstalledPackage({ exports: false, serverEntry: false }); try { + expect(() => Host.resolve({ directory: cacheRoot, name: "opencode-architect" })).toThrow(); expect(harness.resolveServerEntrypoint(cacheRoot, "opencode-architect")).toBeNull(); } finally { await rm(cacheRoot, { recursive: true, force: true }); @@ -89,8 +91,16 @@ describe("built plugin under the v2 host contract", () => { const harness = new V2HostHarness(); let mounted: Awaited>; - test("the built entry satisfies the v2 Module contract through the real host loader", async () => { + beforeAll(async () => { mounted = await harness.mountInstalledLayout(); + }); + + afterAll(async () => { + if (!mounted) return; + await mounted.dispose(); + }); + + test("the built entry satisfies the v2 Module contract through the real host loader", async () => { const { check } = await harness.loadInstalled(mounted.cacheRoot); expect(check.violation).toBeNull(); expect(check.effectKind).toBe("effect"); From da439d4776b57ae847742550cdd04bc79f3fb177 Mon Sep 17 00:00:00 2001 From: Diego Bustamante Date: Tue, 6 Oct 2026 10:43:20 -0400 Subject: [PATCH 3/4] chore(ci): retrigger pull_request workflow for the verification harness branch (issue #27) From 20a785cc8af3deb5d3de521ab8213068e5618484 Mon Sep 17 00:00:00 2001 From: Diego Bustamante Date: Tue, 6 Oct 2026 10:46:07 -0400 Subject: [PATCH 4/4] =?UTF-8?q?fix(harness):=20root-index=20fallback=20is?= =?UTF-8?q?=20Bun-version-dependent=20=E2=80=94=20assert=20the=20cross-run?= =?UTF-8?q?time=20invariant,=20re-scope=20the=20record=20(ci,=20issue=20#2?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- docs/reference/opencode-v2-facts.md | 6 +++--- tests/v2-host.test.ts | 16 +++++++++++++--- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dcc53f..dc33923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **Registration loss under v2**: `package.json` now declares `exports["./server"]` pointing at the package entry — without it the host's server-kind resolution misses its only resolvable candidate under Bun (Bun's `ResolveMessage` is not an `Error`, so the root-index fallback's catch never fires) and the plugin is disabled at the entry stage; every consumer install was silently registering nothing +- **Registration loss under v2**: `package.json` now declares `exports["./server"]` pointing at the package entry — without it, server-kind resolution depends on the root-index fallback, which is dead on Bun 1.3.x runtimes (Bun's `ResolveMessage` is not an `Error`, so the fallback's catch never fires) and the plugin is disabled at the entry stage; with the export declared, resolution is deterministic on every observed runtime ## [0.9.2] - 2026-10-04 diff --git a/docs/reference/opencode-v2-facts.md b/docs/reference/opencode-v2-facts.md index 9406457..177a52d 100644 --- a/docs/reference/opencode-v2-facts.md +++ b/docs/reference/opencode-v2-facts.md @@ -113,7 +113,7 @@ Settled discovery: `source: packages/core/src/config/plugin/agent.ts#legacySourc - **String forms**: `-target` (or wildcard patterns) = removal/disable; `file://…`, `./…`, `../…`, absolute = local path resolved from the config file's directory; anything else = npm/Git package spec. Settled. `source: packages/core/src/config/plugin/source.ts#parse, scan`. - **Directory discovery**: every config root is scanned for `plugin/` and `plugins/` children; `.ts`/`.js` files, directories, and symlinks to either are admitted, sorted by path. Settled. `source: packages/core/src/plugin/source-directory.ts#names, discover`. - **Precedence**: auto-discovered first, explicit config applied last (so config can remove auto-discovered packages); config files merge lowest→highest (global → explicit → direct → project). Settled. `source: packages/core/src/config/plugin/source.ts#scan` (comment + order), `packages/core/src/config.ts#load`. Live docs agree: `docs: opencode.ai/v2/docs/plugins`. -- **Entrypoint resolution** (per package): `exports["./server"]` else package root index (subpaths tried: `server`, ``); `./tui` and `./rpc` exports enable TUI/RPC features. Standalone `.ts`/`.js` files load as-is. The server entrypoint must resolve inside the package directory. Settled. `source: packages/plugin/src/host.ts#Target, Entrypoints, resolve`; `packages/core/src/plugin/module.ts#load` (features tui/rpc, containment check in `config/plugin/source.ts#scan`). **Harness amendment (issue #27): the root-index fallback is unreachable under the Bun runtime — a package whose server-kind candidates both miss is disabled at the entry stage. Every distributed plugin package must declare `exports["./server"]`. Settled. `harness: tests/v2-host.test.ts` (see §14.6).** +- **Entrypoint resolution** (per package): `exports["./server"]` else package root index (subpaths tried: `server`, ``); `./tui` and `./rpc` exports enable TUI/RPC features. Standalone `.ts`/`.js` files load as-is. The server entrypoint must resolve inside the package directory. Settled. `source: packages/plugin/src/host.ts#Target, Entrypoints, resolve`; `packages/core/src/plugin/module.ts#load` (features tui/rpc, containment check in `config/plugin/source.ts#scan`). **Harness amendment (issue #27): the root-index fallback must not be relied on — it is dead on Bun 1.3.x (the candidate miss throws an uncatchable `ResolveMessage` and the plugin is disabled at the entry stage) and works only on newer runtimes (Bun ≥ 1.4.2, Node). Every distributed plugin package must declare `exports["./server"]`, which resolves on every observed runtime. `harness: tests/v2-host.test.ts` (see §14.6).** - **Install mechanics**: npm/Git specs install via `@npmcli/arborist` (not a shell-out to bun/npm) into a generation cache at `/npm///node_modules/`. Keys: `name@spec` for registry, `git--` for Git. Loaded generation = newest; startup loads cached immediately and installs missing in background; unpinned specs are checked for updates without auto-upgrade; exact versions and full commit hashes stay pinned; last 2 generations kept, 7-day retention. Settled. `source: packages/util/src/npm.ts` (`directory`, `install`, `collect`, `check`, `update`), `packages/core/src/plugin/supervisor.ts`, `packages/core/src/plugin/update.ts`. - **CLI management**: `opencode plugin add|list|check|update|remove`; `cli.json` in the global config dir configures CLI-only plugins. Settled. docs: `opencode.ai/v2/docs/plugins`; `source: packages/cli/src/config/config.ts` (`path.join(global.config, "cli.json")`). - **Local plugin dependencies**: live v2 docs no longer document the v1 `.opencode/package.json` + `bun install` behavior. **open-harness** (§14.1). @@ -190,7 +190,7 @@ Model-domain hooks are provider-scopable via `{ providerID }` (`ModelHooks` opti | 6 | Migration-guide claim "V2 discovers `.opencode/plugin/` and `.opencode/plugins/`" | Confirmed in source (`source-directory.ts#names`). Settled — guide assertion validated. | | 7 | Migration-guide claim "npm plugins installed automatically using Bun" appears only in stale in-repo docs | Source uses arborist directly, no bun. Settled. | | 8 | `CONTEXT.md` glossary "Plugin install" still says the package is listed in a config file's `plugin` array | Record is right for v2 (`plugins`); the glossary entry needs a v2 update — flagged here for the domain-docs pass per `docs/agents/domain.md`. Open (doc debt, not a source question). | -| 9 | C1's working assumption "root-index fallback works locally via `Host.resolve`" | **Settled negative** (issue #27). The published `Host.resolve` filters candidate misses through `error instanceof Error`, but Bun's `ResolveMessage` (thrown by `Bun.resolveSync`, which `@opencode/util/runtime-import` uses under Bun) is **not an `Error`** (`harness` probe, oven-sh/bun#7531) — the first missed candidate rethrows and later candidates never run; the aggregate call also evaluates `tui`/`rpc` eagerly, so it throws for any package without `./tui`. The core loader resolves **per kind** with try/catch (entry-stage failure disables that one plugin; startup continues — tertiary DeepWiki reading of `packages/opencode/src/plugin/loader.ts`, the snapshot's `packages/core` paths unpublished), so a package whose `server` candidate misses is silently disabled. Consequence: `exports["./server"]` is mandatory; `opencode-architect` declares it as of issue #27, and generated-package templates lack it (flagged for the guidance tickets). Settled. `harness: tests/v2-host.test.ts` | +| 9 | C1's working assumption "root-index fallback works locally via `Host.resolve`" | **Settled: environment-dependent, and not to be relied on** (issue #27). The published `Host.resolve` filters candidate misses through `error instanceof Error`, but Bun's `ResolveMessage` (thrown by `Bun.resolveSync`, which `@opencode/util/runtime-import` uses under Bun) is **not an `Error`** on Bun 1.3.x (probed on 1.3.14 win32; oven-sh/bun#7531) — the first missed candidate rethrows, later candidates never run, and the core loader (per-kind, try/catch; tertiary DeepWiki reading of `packages/opencode/src/plugin/loader.ts`) disables the plugin at the entry stage. On Bun 1.4.2 (observed in CI, ubuntu) candidate misses are caught and the fallback resolves. Consequence: a package without `exports["./server"]` loads only where the runtime's misses are catchable — never rely on it. `opencode-architect` declares `exports["./server"]` as of issue #27; generated-package templates lack it (flagged for the guidance tickets). `harness: tests/v2-host.test.ts` | ## 14. Open flags → verification-harness inputs @@ -201,7 +201,7 @@ Facts the pinned source cannot fully settle. The harness (issue #27, `tests/v2-h 3. Built-in unremovable plugin ids `opencode.config.policy` / `opencode.provider.opencode` as observed at runtime — §7. Source locator: `opencode.config.policy` in `packages/core/src/config/plugin/policy.ts`; `opencode.provider.opencode` in `packages/core/src/plugin/provider/opencode.ts` (not in policy.ts); a third built-in, `opencode.config.agent` (`packages/core/src/config/plugin/agent.ts`), has no removal-override claim in docs. **Not reachable**: built-ins are core-registration behavior. Remains open-harness. 4. Instruction-file discovery set (exact ambient filenames, e.g. `AGENTS.md` variants) — §12. **Not reachable**: discovery runs inside the core config loader. Remains open-harness. 5. `experimental.ws.*` hook stability (names/shapes may change; treat as hedged in guidance until then) — §9. **Partially confirmed**: the pinned 2.0.23 types expose the documented shapes; stability across versions cannot be observed from one pin. Remains hedged in guidance. -6. ~~Generated-package compatibility: whether a plugin package whose exports expose only `.` (no `./server`) loads from npm cache~~ — **Settled negative (issue #27)**. Under the Bun runtime the root-index fallback never runs: the server-kind candidate miss throws a `ResolveMessage` that the published resolution code cannot catch (it filters on `error instanceof Error`), so the core loader disables the plugin at the entry stage. The root-index fallback only ever worked where candidate misses are catchable `Error`s (Node). Every plugin package must declare `exports["./server"]`; `opencode-architect` does as of issue #27, generated-package templates must follow (see §13 row 9). `harness: tests/v2-host.test.ts` +6. ~~Generated-package compatibility: whether a plugin package whose exports expose only `.` (no `./server`) loads from npm cache~~ — **Settled (issue #27): do not rely on the root-index fallback — declare `exports["./server"]`.** The fallback is runtime-dependent: dead on Bun 1.3.x (the server-kind candidate miss throws a `ResolveMessage` that is not an `Error`, so the resolution filter rethrows and the core loader disables the plugin at the entry stage — probed on 1.3.14 win32), working on Bun ≥ 1.4.2 (candidate misses caught, root index resolves — observed on CI's 1.4.2 ubuntu) and on Node. With `exports["./server"]` declared, the server kind resolves on every observed runtime. `opencode-architect` declares it as of issue #27; generated-package templates must follow (see §13 row 9). `harness: tests/v2-host.test.ts` 7. TUI plugin lifecycle under `opencode` TUI host (feature flag exists in server; TUI-side load order not visible in server source) — §11. **Not reachable**: needs the TUI host process. Remains open-harness. 8. Whether v1 `keybinds`/`theme`/`tui` keys auto-migrate, and to what, at runtime — §5 (in-repo docs claim only). **Not reachable**: migration runs in the core config loader; the published schema ignores unknown keys and cannot observe it. Remains open-harness. diff --git a/tests/v2-host.test.ts b/tests/v2-host.test.ts index f49e14b..a876798 100644 --- a/tests/v2-host.test.ts +++ b/tests/v2-host.test.ts @@ -57,11 +57,21 @@ describe("v2 host entrypoint resolution (facts §14.6)", () => { } }); - test("a package exposing only the root index cannot resolve its server kind under Bun (settled facts §14.6)", async () => { + test("a package exposing only the root index never yields a phantom server entrypoint (facts §14.6)", async () => { const cacheRoot = await makeInstalledPackage({ exports: false, serverEntry: false }); try { - expect(() => Host.resolve({ directory: cacheRoot, name: "opencode-architect" })).toThrow(); - expect(harness.resolveServerEntrypoint(cacheRoot, "opencode-architect")).toBeNull(); + const entrypoint = harness.resolveServerEntrypoint(cacheRoot, "opencode-architect"); + expect(entrypoint === null || entrypoint.replaceAll("\\", "/").endsWith("/index.ts")).toBe(true); + let aggregate: { server?: string } | null = null; + try { + aggregate = Host.resolve({ directory: cacheRoot, name: "opencode-architect" }); + } catch { + aggregate = null; + } + const aggregateServer = aggregate?.server; + expect( + aggregateServer === undefined || aggregateServer.replaceAll("\\", "/").endsWith("/index.ts"), + ).toBe(true); } finally { await rm(cacheRoot, { recursive: true, force: true }); }