diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91fa51a..53bcae9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,12 +107,14 @@ jobs: - name: the switch left the tree clean run: git diff --exit-code && test -z "$(git status --porcelain)" - # The channel records name what each channel installs TODAY, and the harness + # The channel records name each channel's latest release, and the harness # cuts a release without telling this repository. Nightly and on dispatch, - # `scripts/report-host-drift.ts` reads the registry the way npm resolves a - # channel (dsh at its dist-tag; cordis, the loader, schemastery and pi at the - # newest version the bridge's declared ranges admit) and prints one warning - # per field that moved past `test/support/host-versions.ts`. It REPORTS and + # `scripts/report-host-drift.ts` reads the registry the way the records are + # defined (dsh at its dist-tag; pi at the newest version the bridge's range + # admits, and that range's floor; each cordis-family member at the floor of + # the `~` range the tagged cluster declares for it, the desktop's tree, so a + # newer patch inside a range is not drift) and prints one warning per field + # it compares that moved past `test/support/host-versions.ts`. It REPORTS and # never gates, so it exits 0 whatever it finds: a channel moving is the # harness's release, not a regression. P-T3 turns the report into a proposed # bump pull request; the bump follows CONTRIBUTING's release step 3. @@ -386,3 +388,191 @@ jobs: OPENCODE_E2E: "1" OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_GO_API_KEY: ${{ secrets.OPENCODE_GO_API_KEY }} + + # The published host binary with this checkout linked into a throwaway + # profile: the only run that boots `dsh` itself rather than the host packages + # the unit suite assembles. `test/e2e/host/probe-plugin.mjs` measures the + # registry, the listings, validation, where each turn is sent and the save of + # a catalog-unknown id, and `test/e2e/host-binary.e2e.ts` asserts today's + # rows (`S0_TODAY_BY_CELL`, shared with the in-process characterization). + # + # One run per harness channel (D14''), each the channel's published binary + # (`dsh@` from `test/support/host-versions.ts`) installed with npm the + # way a user installs it, pi unpatched in both cells: + # - `npm`: the published host exactly as npm resolves it, cordis and pi + # included. The record's newest pi asserts every row; a pi published since + # the record skips the rows that depend on which ids pi describes, with a + # warning (the nightly `host-drift` job names it). Never cached: + # re-resolving is the point. + # - `floor`: the tree the desktop app ships, forced with npm `overrides` + # (`floorOverrides` in host-versions.ts): the channel's whole cordis family + # at the record's versions (the floors of the ranges the cluster declares, + # what the harness vendors at the tag) and pi with its telemetry package + # at the bridge range's floor (pi patched there, not here; the patch + # touches nothing the plugin reads). Cached by the channel, its dsh and a + # hash of those overrides, so a cache filled before the record or its + # overrides moved is never reused; the rest of the tree is what npm + # resolved when the cache was filled. + # Pull requests run the `npm` cell of both channels, and only when they touch + # the host seam; dispatch and the nightly run add both `floor` cells. + # + # The scope is decided by a step rather than by `on.pull_request.paths`: a + # workflow-level path filter would silence every other job in this file. + # Offline: the probe answers every gateway request itself, and the keys it + # stores are dummies. No secret is passed. + e2e-host: + name: e2e · host binary (${{ matrix.channel }} · ${{ matrix.cell }}) + if: github.event_name != 'push' + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + channel: [alpha, rc] + cell: ${{ github.event_name == 'pull_request' && fromJSON('["npm"]') || fromJSON('["npm","floor"]') }} + env: + DSH_CHANNEL: ${{ matrix.channel }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - name: does this change touch the host seam? + id: scope + env: + EVENT: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + if [ "$EVENT" != "pull_request" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Its own command, so a diff that cannot be computed (a base ref + # that was not fetched, a deleted base branch) fails the step rather + # than reading as "nothing changed" and skipping the job green. + changed="$(git diff --name-only "origin/${BASE_REF}...HEAD")" + # The job's own definition and runner config are in scope: a pull + # request that breaks the install, the override or the e2e config + # must run the job it breaks. That includes vite.config.ts: it holds + # the pack config that builds lib/index.mjs (the only test that + # loads that bundle into a real host is this one, and its externals + # keep cordis and schemastery the host's copies) and the test + # settings vitest.e2e.config.ts inherits. test/support/host-versions.ts + # names the binary each channel installs, and scripts/host-drift.ts + # holds the family table the binary e2e asserts against the installed + # manifests. package.json is not (every + # dependency bump touches it), so a change to its `test:e2e:host` + # script alone is exercised only by the nightly run. + # Until P-R1 moves the host seam under src/host/, the modules that + # install the host patches and mounts whose behaviour depends on the + # real loader and apply order (the hidden-route mount, the listing + # patch and adapter decoration, the Google bridge, the fetch patch, + # the plane bases and the stream hook) are in scope by name. Every + # other row producer (key-capture.ts, models-catalog.ts, config.ts, + # the generated catalog) is covered on each pull request by the + # in-process characterization of the same rows, on both channels, and + # here by the nightly run. + if printf '%s\n' "$changed" \ + | grep -Eq '^(src/host/|src/lifecycle\.ts$|src/identity\.ts$|src/responses-provider\.ts$|src/responses-routes\.ts$|src/models-discovery\.ts$|src/google-bridge\.ts$|src/fetch-patch\.ts$|src/pi-provider\.ts$|src/stream-hook\.ts$|cordis\.patch\.yml$|test/e2e/host/|test/e2e/host-binary\.e2e\.ts$|test/support/host\.ts$|test/support/host-versions\.ts$|scripts/host-drift\.ts$|\.github/workflows/ci\.yml$|vitest\.e2e\.config\.ts$|vite\.config\.ts$)'; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + echo "no host-seam path changed; the host binary e2e does not run for this pull request" + fi + - if: steps.scope.outputs.run == 'true' + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - if: steps.scope.outputs.run == 'true' + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 26 + cache: pnpm + - if: steps.scope.outputs.run == 'true' + run: pnpm install --frozen-lockfile + - name: the ${{ matrix.channel }} channel's record + id: versions + if: steps.scope.outputs.run == 'true' + run: | + node --experimental-strip-types --input-type=module -e "import { createHash } from 'node:crypto'; import { CHANNELS, floorOverrides } from './test/support/host-versions.ts'; const r = CHANNELS[process.env.DSH_CHANNEL]; const overrides = JSON.stringify(floorOverrides(r)); console.log('dsh=' + r.dsh); console.log('pi=' + r.pi); console.log('piRange=' + r.piRange); console.log('floorOverrides=' + overrides); console.log('floorKey=' + createHash('sha256').update(overrides).digest('hex').slice(0, 16))" >> "$GITHUB_OUTPUT" + echo "PREFIX=$RUNNER_TEMP/dsh-host" >> "$GITHUB_ENV" + # The floor install forces every version that matters (the record's + # cordis family and the floor pi pair), so it is cached by the channel, + # its dsh and a hash of those overrides; the npm one is the point of + # re-resolving, so never. + - name: cache the floor host install + id: host-cache + if: steps.scope.outputs.run == 'true' && matrix.cell == 'floor' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/dsh-host + key: dsh-host-floor-${{ matrix.channel }}-${{ steps.versions.outputs.dsh }}-${{ steps.versions.outputs.floorKey }} + - name: install the published host into a throwaway prefix + if: steps.scope.outputs.run == 'true' && steps.host-cache.outputs.cache-hit != 'true' + env: + CELL: ${{ matrix.cell }} + DSH: ${{ steps.versions.outputs.dsh }} + FLOOR_OVERRIDES: ${{ steps.versions.outputs.floorOverrides }} + run: | + mkdir -p "$PREFIX" + # The overrides must be in the prefix's own package.json BEFORE the + # install, or npm resolves the bridge's ^range to the newest pi and + # the host's ~ ranges to the newest cordis patch. + if [ "$CELL" = floor ]; then + node -e "process.stdout.write(JSON.stringify({ private: true, overrides: JSON.parse(process.env.FLOOR_OVERRIDES) }) + '\n')" > "$PREFIX/package.json" + else + printf '{ "private": true }\n' > "$PREFIX/package.json" + fi + npm install --prefix "$PREFIX" --no-audit --no-fund "@deepseek-ai/dsh@$DSH" + - name: report the pi and the cordis family the host resolved + if: steps.scope.outputs.run == 'true' + env: + CELL: ${{ matrix.cell }} + PI: ${{ steps.versions.outputs.pi }} + FLOOR_OVERRIDES: ${{ steps.versions.outputs.floorOverrides }} + run: | + resolved="$(node -p "require(process.env.PREFIX + '/node_modules/@earendil-works/pi-ai/package.json').version")" + echo "${DSH_CHANNEL} · ${CELL}: pi $resolved" + copies="$(find "$PREFIX/node_modules" -path '*/@earendil-works/pi-ai/package.json' | wc -l)" + if [ "$copies" -ne 1 ]; then + echo "::error::expected one copy of pi under the host prefix, found $copies" + exit 1 + fi + if [ "$CELL" = npm ] && [ "$resolved" != "$PI" ]; then + echo "::warning::npm resolved pi $resolved for ${DSH_CHANNEL}, not the record's $PI: the rows that depend on which ids pi describes are skipped" + fi + # Every package the floor cell forces (the pi pair and the cordis + # family): the floor cell must hold exactly the override, a cached + # prefix included, which catches npm ignoring one; the npm cell + # reports what npm resolved, which is the channel's release as a + # user gets it. Whether the record's values are right is not this + # step's: the binary e2e holds them to the ranges the installed + # published packages declare, and the nightly drift report to the + # registry's. + failed=0 + for name in $(node -p "Object.keys(JSON.parse(process.env.FLOOR_OVERRIDES)).join(' ')"); do + want="$(node -p "JSON.parse(process.env.FLOOR_OVERRIDES)['$name']")" + got="$(node -p "require(process.env.PREFIX + '/node_modules/$name/package.json').version")" + echo "${DSH_CHANNEL} · ${CELL}: $name $got" + if [ "$CELL" = floor ] && [ "$got" != "$want" ]; then + echo "::error::the floor cell resolved $name $got, not the record's $want" + failed=1 + fi + done + exit "$failed" + - if: steps.scope.outputs.run == 'true' + run: pnpm run build + - name: boot the host binary with the probe + if: steps.scope.outputs.run == 'true' + run: pnpm run test:e2e:host --run + env: + DSH_HOST_E2E: "1" + DSH_HOST_PREFIX: ${{ runner.temp }}/dsh-host + DSH_HOST_PI_CELL: ${{ matrix.cell }} + DSH_HOST_PROBE_COPY: ${{ runner.temp }}/probe-${{ matrix.channel }}-${{ matrix.cell }}.json + - name: upload the probe's measurement + if: always() && steps.scope.outputs.run == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: host-probe-${{ matrix.channel }}-${{ matrix.cell }} + path: ${{ runner.temp }}/probe-${{ matrix.channel }}-${{ matrix.cell }}.json + if-no-files-found: ignore + retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md index 70d91a4..eb2e360 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,9 +42,12 @@ Host bundle (`lib/index.mjs`), a thin `apply` barrel over small modules: - `src/index.ts` · `src/identity.ts` · `src/lifecycle.ts` — the public barrel, the identity constants, and the `apply()` composition (one installer per side effect, in load-bearing order). The barrel is the host's contract and nothing more: `apply`, `Config`, `inject`, `name` and the config types. Everything else is internal, and `test/public-api.test.ts` pins the list, so widening it is a reviewed change. - `test/support/src.ts` — the one door tests use into `src`: each name re-exported from the module that DEFINES it, never through the barrel, so a module move edits one file. `test/architecture.test.ts` enforces both halves — every name is declared in its target, and no test outside the named exemptions (`vi.mock` targets, fresh re-imports after `vi.resetModules()`, the architecture and public-API tests, the jsdom client tests, the `reset-process-state.ts` setup file) imports a `src` path, and no jsdom test loads the door. -- `test/support/host-versions.ts` — the hosts the plugin supports, named once: one record per harness channel (`rc` = `dsh@latest`, `alpha` = `dsh@alpha`), each with its `@deepseek-ai` cluster, cordis, its loader, the bridge's pi RANGE, the newest pi that range resolves (the channel lockfile's) and the range's floor (the harness's own pin, which only the desktop app ships), plus which one the install under test is (`DSH_CHANNEL`, else the marker `scripts/use-channel.ts` leaves, else `alpha`). `test/host-versions.test.ts` checks the installed tree against the SELECTED record; `test/harness-channel.test.ts` checks the declarations and holds each committed lockfile (`pnpm-lock.yaml` for alpha, `channels/rc/pnpm-lock.yaml` for rc) to its record, including that `@deepseek-ai/schemastery` resolves to one copy inside the cluster's range. -- `scripts/report-host-drift.ts` — the nightly harness-drift report (CI's `host-drift` job, schedule and dispatch only): reads the registry the way npm resolves each channel and prints one `::warning::` per record field that moved, exiting 0 whatever it finds, because a channel moving is the harness's release, not a regression. Its rules are `scripts/host-drift.ts` (`hostDrift`, pure), held by `test/host-drift.test.ts` with known answers, a loopback registry and the job's evaluated condition. P-T3 turns the report into a proposed bump pull request. -- `scripts/use-channel.ts` — switches the dev install: `rc` or `alpha` installs that channel's committed lockfile (unpatched pi at the newest version the bridge's range admits, what an npm user runs), and `--floor` the same cluster with pi at the range's floor, never committed. It restores every tracked file it touched, so a switch leaves no diff. Its pure half is `scripts/channel-manifest.ts` (`channelManifest`, `pinFor`, `workspaceWithPi`, and `withoutHostPackages`, the lockfile `rc --relock` starts from), held by `test/use-channel.test.ts`. `rc --relock` belongs in the same commit as ANY change to `package.json`'s dependencies or `pnpm-workspace.yaml`'s catalog or overrides, not only a channel bump: the rc lockfile carries every specifier the root one does and CI installs it frozen, and `test/harness-channel.test.ts` fails, naming the command, when they disagree. Dependabot ignores the cluster and pi, which move only as a channel bump. **This repository carries no pi patch** (D14''): an npm install runs pi unpatched, the harness's patch reaches users only through the desktop app and touches nothing the plugin reads, and a version-keyed patch would break every pi bump. `test/architecture.test.ts` keeps it that way by failing if `src` ever reads a streamed tool-call delta, partial arguments or a thought signature. +- `test/support/host-versions.ts` — the hosts the plugin supports, named once: one record per harness channel (`rc` = `dsh@latest`, `alpha` = `dsh@alpha`), each with its `@deepseek-ai` cluster, the cordis family (`cordisFamily`: cordis, the loader, include, group, timer, cosmokit, schemastery, each at the floor of the `~` range the published cluster declares, which is what the harness vendors at the tag and moves only with `dsh`), the bridge's pi RANGE, the newest pi that range resolves (the channel lockfile's) and the range's floor (the harness's own pin, which only the desktop app ships), and `floorOverrides`, the family and the floor pi pair the `e2e-host` floor cell forces, plus which one the install under test is (`DSH_CHANNEL`, else the marker `scripts/use-channel.ts` leaves, else `alpha`). `test/host-versions.test.ts` checks the installed tree against the SELECTED record; `test/harness-channel.test.ts` checks the declarations and holds each committed lockfile (`pnpm-lock.yaml` for alpha, `channels/rc/pnpm-lock.yaml` for rc) to its record, including that `@deepseek-ai/schemastery` resolves to one copy inside the cluster's range and that each family member the cluster peers on is the floor of the declared range. +- `test/support/host.ts` — the real host, in-process, in two tiers: **A** (`mountTierA`: a bare cordis `Context`, `LlmRuntime` and `dsh-llm-pi-ai` mounted with `{ providers }`) for the `internal/config` waterfall and pi-ai's credential paths, and **B** (`bootTierB`: dsh-app-boot's `boot()` with a temporary HOME and a test bundle) for the config editor, `settings.describe`, the volatile commit and the characterization of the plugin as it ships. Its fakes (`fakeCredentials`, `fakeLaunchEnvironment`, `recordingFetch`) keep every case offline and its credentials out of the environment (a boot blanks the two keys the plugin reads directly, should the shell export them, and restores them on dispose); `S0_PROFILE` is the contract fixture, `CHARACTERIZED_PROFILE` (S0 plus a declared catalog-unknown id) the characterization's, and `S0_TODAY_BY_CELL` the measured per-model rows, tagged per facet, one table per host cell (`newest`, either channel's default install, and `rc-floor`/`alpha-floor`, the pis the desktop app ships; `S0_TODAY` is the installed cell's, and `forHost`/`forChannel` pick any other per-cell expectation), that the binary e2e shares (`todayRowsFor` picks them by the cell and by the apply order the probe infers from whether the plugin's decoration wrapped pi-ai's plane adapter). Tier A has no loader, so this plugin mounted there cannot find pi-ai and mounts no hidden route and no Google bridge (whose adapter class comes from the same loader entry, `loadPiAiAdapterHost`); `test/host-harness.test.ts` exercises the builder options no contract or characterization case passes, and tier B's two failure paths (an invalid first row, an exported key). Tier B's rows carry PACKAGE names through a stub module loader, never `cordis:` builtins, because the plugin finds pi-ai's entry by `options.name` (`piAiEntry` in `src/responses-provider.ts`) and a builtin-named row would drop the hidden-route mount from the characterization. It also fixes which of pi-ai and the plugin applies first (`order`), because the loader starts rows in parallel and the published binary applies the plugin first; the held row is released when the first one applies or its fiber settles, so an invalid first row fails instead of hanging the boot. +- `test/support/keyless-matrix.ts` — the keyless 401-vs-404 probe matrix's cell table and classifier. Pure, though it lives in `test/support/` rather than `src/utils/`: a function of its arguments, with no code path that can set a credential header; `test/e2e/keyless-matrix.e2e.ts` sends the cells. +- `test/e2e/host/` — the measurement bundle (`probe-plugin.mjs`, its manifest and patch) the `e2e-host` job links into a throwaway profile under a temporary HOME, through a relative `link:` path. `test/e2e/host-binary.e2e.ts` is its reader, gated on `DSH_HOST_E2E=1`; it installs no host itself and needs `DSH_HOST_PREFIX`, a prefix holding the installed one. +- `scripts/report-host-drift.ts` — the nightly harness-drift report (CI's `host-drift` job, schedule and dispatch only): reads the registry the way the records are defined (`dsh` at its dist-tag, pi's range, floor and newest, and every cordis-family member at the floor of the range the tagged cluster declares) and prints one `::warning::` per compared field that moved, exiting 0 whatever it finds, because a channel moving is the harness's release, not a regression. Its rules are `scripts/host-drift.ts` (`hostDrift`, pure), held by `test/host-drift.test.ts` with known answers, a loopback registry and the job's evaluated condition. P-T3 turns the report into a proposed bump pull request. +- `scripts/use-channel.ts` — switches the dev install: `rc` or `alpha` installs that channel's committed lockfile (unpatched pi at the newest version the bridge's range admits, what an npm user runs), and `--floor` the same cluster with pi at the range's floor, never committed. It restores every tracked file it touched, so a switch leaves no diff. Its pure half is `scripts/channel-manifest.ts` (`channelManifest`, `pinFor`, `workspaceWithPi`, and `withoutHostPackages`, the lockfile `rc --relock` starts from), held by `test/use-channel.test.ts`. A relock keeps the cordis family at the record's floors because `package.json` pins every member the install resolves (include, group and cosmokit too, which arrive transitively and would otherwise resolve to the newest patch inside their `~` ranges) and `pinFor` moves each with the channel. `rc --relock` belongs in the same commit as ANY change to `package.json`'s dependencies or `pnpm-workspace.yaml`'s catalog or overrides, not only a channel bump: the rc lockfile carries every specifier the root one does and CI installs it frozen, and `test/harness-channel.test.ts` fails, naming the command, when they disagree. Dependabot ignores the cluster and pi, which move only as a channel bump. **This repository carries no pi patch** (D14''): an npm install runs pi unpatched, the harness's patch reaches users only through the desktop app and touches nothing the plugin reads, and a version-keyed patch would break every pi bump. `test/architecture.test.ts` keeps it that way by failing if `src` ever reads a streamed tool-call delta, partial arguments or a thought signature. - `src/config.ts` · `src/config-values.ts` · `src/utils/guards.ts` — the schema and its defaults; dependency-free readers and type guards. The client bundle may import these, and it does — along with the contract and presentation modules (`usage-contract.ts`, `settings-copy.ts`, `settings-fields.ts`, `usage-ui.ts`, `go-limits.ts`, `opencode-store-contract.ts`). The rule is not a list of names: **the client may import a module that reaches nothing host-only** — no cordis context, no credential seam, no node built-in — and the bundle gate is what enforces it, by failing the build rather than by anyone remembering. An earlier version of this line named two files as the only ones allowed, which the tree had already outgrown. ## `src/utils/` — pure by construction, enforced by test @@ -113,6 +116,7 @@ Web client bundle (`lib/client.js`): `src/settings-page.tsx` (entry, wiring only vp test run # unit suite (refuses to start below engines.node) pnpm run test:shuffle # the unit suite in random order; CI runs it on Node 24 pnpm run test:mock # gateway mock suite: offline, no key, runs in CI +pnpm run test:e2e:host # opt-in: the published host binary with the probe (CONTRIBUTING, "The e2e-host job") vp test run --coverage # with the coverage ratchet (thresholds in vite.config.ts) vp check # format, lint, types vp fmt --write # rewrite formatting diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 622f70e..21c09f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@ ## Prerequisites - Node.js `>= 24` (CI runs the version in `.node-version`, and the floor in a job of its own), `pnpm` (v12). The suite refuses to start below the floor and names both versions: below it, `node:sqlite`, `Intl.DurationFormat` and the ICU strings the tests pin differ, and the failures read like regressions. -- The plugin supports the latest release of **both** harness channels, `rc` (`dsh@latest`) and `alpha` (`dsh@alpha`). Their hosts (the `@deepseek-ai` cluster, cordis, its loader, and the pi range the bridge declares) are named once, in `test/support/host-versions.ts`. `pnpm install` installs the alpha channel from the root lockfile; `node --experimental-strip-types scripts/use-channel.ts rc` switches to the rc channel's committed lockfile (`channels/rc/pnpm-lock.yaml`), `… alpha` switches back, and `--floor` puts pi at the range's floor (the harness's own pin) for a local run. Each install runs pi **unpatched**, as an npm user does: this repository carries no pi patch. A switch restores every file it touched, so `git status` stays clean, and the suite reads which channel is installed from the marker the switch leaves (`DSH_CHANNEL` overrides it). `scripts/use-channel.ts rc --relock` regenerates the rc lockfile from the root one, and it runs in the same commit as **any** change to `package.json`'s dependencies or to `pnpm-workspace.yaml`'s catalog or overrides, not only a channel bump: the rc lockfile carries every specifier the root one does, CI installs it frozen, and `test/harness-channel.test.ts` fails, naming the command, when the two disagree (a Dependabot pull request included, which cannot relock it). +- The plugin supports the latest release of **both** harness channels, `rc` (`dsh@latest`) and `alpha` (`dsh@alpha`). Their hosts (the `@deepseek-ai` cluster, cordis, its loader, and the pi range the bridge declares) are named once, in `test/support/host-versions.ts`. `pnpm install` installs the alpha channel from the root lockfile; `node --experimental-strip-types scripts/use-channel.ts rc` switches to the rc channel's committed lockfile (`channels/rc/pnpm-lock.yaml`), `… alpha` switches back, and `--floor` puts pi at the range's floor (the harness's own pin) for a local run. Each install runs pi **unpatched**, as an npm user does: this repository carries no pi patch. A switch restores every file it touched, so `git status` stays clean, and the suite reads which channel is installed from the marker the switch leaves (`DSH_CHANNEL` overrides it). `scripts/use-channel.ts rc --relock` regenerates the rc lockfile from the root one, and it runs in the same commit as **any** change to `package.json`'s dependencies or to `pnpm-workspace.yaml`'s catalog or overrides, not only a channel bump: the rc lockfile carries every specifier the root one does, CI installs it frozen, and `test/harness-channel.test.ts` fails, naming the command, when the two disagree (a Dependabot pull request included, which cannot relock it). A relock keeps the cordis family at the record's floors: `package.json` pins every member the install resolves (cordis, the loader, include, group, cosmokit, schemastery; the timer plugin is resolved by nothing here) and `pinFor` moves each with the channel, so pnpm resolves include and group (peers it installs itself) and cosmokit (a dependency of cordis) from those pins rather than taking the newest patch inside their `~` ranges. A relock that fails the family case in `test/harness-channel.test.ts` means a member arrived some other way: pin it the same way, never loosen the case. - A checkout of this repo; for live testing, a DeepSeek Harness profile (see README for the `link:` setup) ## Commands @@ -19,6 +19,7 @@ pnpm run test:shuffle # the same run in a random order; reproduce a failure w pnpm run test:mock # gateway mock suite: offline, loopback only, runs in CI pnpm run build # vp pack + client rename -> lib/index.mjs, lib/index.d.mts, lib/client.js pnpm run test:e2e # opt-in end-to-end suite; talks to the real OpenCode gateway +pnpm run test:e2e:host # opt-in: the published host binary with the probe (see "The e2e-host job") pnpm run catalog:shim # is src/catalog-data.ts current? (-- --write regenerates it from models.dev) pnpm run limits:shim # is src/go-limits-data.ts current? (-- --write regenerates it) pnpm run cli:version # is the pinned OpenCode CLI version the published one? @@ -36,16 +37,70 @@ OPENCODE_E2E=1 OPENCODE_API_KEY=… OPENCODE_GO_API_KEY=… pnpm run test:e2e - `OPENCODE_E2E=1` is required; without it every case skips, so a bare `pnpm run test:e2e` is a safe no-op. - `OPENCODE_API_KEY` (Zen) arms the live `/models` enrichment case and the **paid** protocol-routing cases; `OPENCODE_GO_API_KEY` (Go plan) arms the live `/usage` case. Each block skips when its key is absent, which is why the CI `e2e` job stays green on fork PRs (secrets are not exposed to them) while still checking the endpoints answer. -- The **free-tier and unmetered** routing cases run keyless on purpose: measured 2026-10-06, a paid model answers `401 AuthError` on every endpoint without a credential, so keyless probing cannot tell them apart at all. Keyless discrimination is real only for the free classes. -- `OPENCODE_ZEN_BASE_URL` / `OPENCODE_GO_BASE_URL` retarget the suite at a mirror. +- The **free-tier and unmetered** routing cases run keyless on purpose: measured 2026-10-06, a paid model answers `401 AuthError` on every endpoint without a credential, so keyless probing cannot tell the right PROTOCOL endpoint from a wrong one. Keyless protocol discrimination is real only for the free classes. What a paid model's keyless answer does still tell is whether a PATH exists at all — a removed path answers 404 — which is the one thing the keyless matrix below reads. +- `OPENCODE_ZEN_BASE_URL` / `OPENCODE_GO_BASE_URL` retarget `opencode-live` and `protocol-routing` at a mirror. The keyless matrix measures the vendor's own paths, which a mirror's base cannot name, so it skips while either is set. The e2e files, one job each: - `opencode-live.e2e.ts` — the only place the plugin meets the real API. It catches what a stub cannot: **the vendor changing the payload**. The meter parses `/zen/go/v1/usage` and the picker consumes an enriched `/models` listing, so both shapes are asserted against the live service. The unkeyed cases assert reachability (never a 404) and that our header set does not change the gateway's verdict on a request — compared against the same call without it, rather than hardcoding a status the vendor may tighten. - `patched-fetch-headers.e2e.ts` — a local `node:http` listener, so the _outgoing_ header set crosses a real socket. The live gateway cannot echo a request back; everywhere else in the suite the injected headers are asserted against a captured `fetch`. +- `keyless-matrix.e2e.ts` — the keyless 401-vs-404 matrix: two planes × four protocols × the legacy (`/zen(/go)/v1`) and modern (`/inference(/go)/…`) endpoint families, one PAID model per cell from the live catalog — never a free one, which the gateway serves keyless with a real completion. It **sends no key and reads none**, so the `e2e` job runs it on every pull request, and it retries a cell once on a 429, a 5xx or a transport error before reading it. A 404 means the path is gone, a 401/403 that it exists, a 410 or a 400 "Model is unavailable" that the model is. It asserts that all sixteen cells have a model and were sent, that every answer is readable, and that no cell the plugin dispatches to today with the default endpoint (`auto`, an alias of `modern`; `TODAY_DISPATCH`: completions on the LEGACY family, where the rc floor's pi 1.1.0 files them, Responses, Messages and Google on the modern one) is gone — nor the modern completions cells pi 1.5.0 already sends them to and P-R1 moves every host to (`NEXT_DISPATCH`). The other legacy verdicts are recorded for P-R2 — among them the cells an `endpoint: legacy` user dispatches to (Responses and Messages on both planes, and Zen's Google), which are recorded, not asserted; Go's Google stays on `/inference/go/google/v1beta` under every family (`GO_GOOGLE_BASE_URL`), the modern cell `TODAY_DISPATCH` already asserts. The cell table, both sets and the classifier are a pure module (`test/support/keyless-matrix.ts`), unit-tested offline in `test/keyless-matrix.test.ts`, which also derives `TODAY_DISPATCH` and `NEXT_DISPATCH` from the URLs each host cell's rows measured (alpha's floor, pi 1.3.0, files completions under the root family `/inference(/go)/v1`, which the matrix gains with P-R2). - `protocol-routing.e2e.ts` — one turn per protocol against the live gateway: the vendor-side claim that a model's `provider_npm` names its wire format, which a unit test can only read back from our own table. - `refusal-classification.e2e.ts` — the host's error classifier fed the gateway's measured refusals over a real `node:http` socket, chunked bodies included, after asserting the installed bundle still carries the branches the verdicts rely on. - `dispatch-levels.e2e.ts` — no network: the installed pi's own level rule against both decorators across the shipped catalog, so every reasoning level the picker offers is one dispatch accepts. +- `host-binary.e2e.ts` — the published host binary; see [The real-host harness](#the-real-host-harness). It skips unless `DSH_HOST_E2E=1`. + +## The real-host harness + +`test/support/host.ts` builds the host from the packages this repository installs: the harness channel `test/support/host-versions.ts` selects (the `@deepseek-ai` cluster at that channel's published tag), with pi unpatched at the newest version the bridge's range admits, or at the range's floor after `scripts/use-channel.ts --floor`. The routing work is judged against it: the contract tests pin the host facts the design rests on that the host can answer in-process (`docs/improvement-plan.md`: F1, F2, F4, F5, F8, F10–F16, F18 and F20, each by number; F6 and F19's auth half (constructing a `PiAiAdapter`) are first exercised by P-R1; F19's export half is pinned in `host-shapes` (`recordKeyFor`); F17 is honoured, not pinned — tier B fixes the apply order and the binary e2e infers it; F9 is a fact about opencode's own client, outside this host), and the characterization rows record what today's plugin does. + +**Host cells.** The rows are measured on three cells (`HostCell` in `host.ts`): `newest`, either channel's default install (pi 1.5.0 today, one pi for both channels, so one column; a fixture case fails the day the two records disagree), and `rc-floor` (pi 1.1.0) and `alpha-floor` (pi 1.3.0), the pis the desktop app ships. Where the cells differ, a row carries one expectation per cell (`forHost`, keyed by the cell's role, never by a pi version) or per channel (`forChannel`, for what the `@deepseek-ai` cluster decides, such as pi-ai's own defaults), and every tier file runs on whichever cell is installed. CI runs the unit suite, tiers A and B included, on both channels' `newest` install on every pull request and on both floors nightly and on dispatch; locally, `scripts/use-channel.ts` switches the install and the same run reads the cell from the marker it leaves. The cells differ today in pi's catalog bases (the legacy family on 1.1.0, the root `/inference(/go)/v1` on 1.3.0, per-protocol roots on 1.5.0, whose Zen catalog is net five models smaller than 1.3.0's), in which S0 ids pi can place (`PI_GAPS_BY_CELL`: 1.5.0 places both Go gap ids), in whether pi's Go provider carries Google (F10: not on 1.1.0), and in pi-ai's own defaults (the alpha cluster raised them to 1_000_000 / 128_000). + +Two tiers, both in-process: + +- **Tier A — `mountTierA`.** A bare cordis `Context`, `LlmRuntime`, and `dsh-llm-pi-ai` mounted as a plugin with `{ providers }` (and optionally this plugin). No loader and no files. Use it for the `internal/config` waterfall and pi-ai's credential paths — anything that needs a fiber and the registry and nothing that reads a profile (`host-waterfall`, `host-shapes`, `host-credentials`). Mounted here, this plugin is a **degraded** one: it finds pi-ai through the loader's entries, so with no loader it patches fetch but mounts no hidden route and no Google bridge — the bridge's adapter class comes from the loader's pi-ai entry too (`host-harness` pins both: the stand-down line, and `loadPiAiAdapterHost` answering `undefined`). Characterize the real plugin in tier B. +- **Tier B — `bootTierB`.** dsh-app-boot's `boot()` with a temporary HOME, a test bundle and a profile patch, the way the harness's own composition tests boot one. Use it for the config editor, `settings.describe`, the loader's volatile commit and the characterization of the plugin as it ships (`host-editor-contracts`, `host-characterization`). Its rows carry **package names**, resolved by a stub module loader, not `cordis:` builtins: the plugin finds pi-ai's entry by `options.name === "@deepseek-ai/dsh-llm-pi-ai"` (`src/responses-provider.ts`), so a builtin-named row would silently drop today's hidden-route mount and the Google bridge. The loader starts rows in parallel, so tier B makes the order explicit: `order: "plugin-first"` (the default, and what the published binary does) or `"pi-ai-first"`. + +What every case does: + +- **Install the recording fetch before mounting** (`vi.stubGlobal("fetch", recordingFetch().fetch)`) and restore it with `vi.unstubAllGlobals()` after disposal: the plugin's fetch patch wraps whatever `fetch` is current when it mounts. The recording fetch answers `opencode.ai` and `models.opencode.ai` with a 401, refuses every other host, and keeps credential headers by length only. +- **Dispose in `afterEach`** (or a `finally`), and boot per case: a key the fetch patch captured in one case would change the next one's listing. +- **Credentials come from the fakes, not the environment.** The fakes (`fakeCredentials`, `fakeLaunchEnvironment`) are provided on the root, which is where pi-ai looks first. The plugin reads `OPENCODE_API_KEY`/`OPENCODE_GO_API_KEY` from the environment directly, so `bootTierB` blanks either one your shell exports for the life of the boot and restores it on dispose: the suite measures the keyless deployment whatever the shell holds (`host-harness` pins it). A case never sets them itself. +- **Give tier-B cases 30 s**, and wait with a deadline (`waitForRoutes`, 10 s by default, so a route that never registers fails by name inside the hook's budget), never a fixed sleep: the plugin mounts its hidden routes asynchronously. +- **A first row that fails does not hang the boot.** Tier B holds the second of pi-ai and the plugin until the first applies OR its fiber settles in any state, so a row whose config the schema rejects boots with that fiber FAILED (`fiber.await()` rethrows the real error) and the other row applied. + +**Tags.** Every characterization row is titled `[BASELINE]` (must survive every phase), `[KNOWN_DEFECT]` (wrong today; a phase fixes it), `[INTENDED_CHANGE]` (right today; a phase changes it on purpose) or `[HOST_LIMITATION]` (something one channel's host, as one install runs it, lacks or breaks, its pi or its own code, that no phase of this plugin fixes: the install is a host cell for an in-process row, or an `e2e-host` cell, `npm` or `floor`, for the binary e2e; the title names the channel, the host and that install, and it is reported upstream, D26). `RowTag` in `test/support/host.ts` holds the one definition of the four. A facet's tag is per cell: one that pi 1.5.0 already serves the way P-R1 keeps (completions on the per-protocol family, Go `gemini-4-argon` validating, Go `minimax-m3.1-flash` placed) is `[BASELINE]` on `newest` and keeps its floor tag on the floors. A phase that flips a row names it in its PR body; nothing else may move, except that P-R2's measured endpoint-family flip, if it happens, may move the family part of a `[BASELINE]` wire facet, re-tagging each one it moves and naming it in its PR body. The per-model rows live in `S0_TODAY_BY_CELL` in `host.ts` (`S0_TODAY` is the installed cell's), which the binary e2e reads too, and each model's three facets — model info, the call-config verdict, the wire — carry their own tag there: `host-characterization` generates one test per model and facet titled with that tag, in each apply order (`todayRowsFor(order)` carries the pi-ai-first table's own tags, where Go Gemini's call config is already the behaviour P-R1 keeps), so the tags in `S0_TODAY` are what govern which entries a phase may flip. The one facet that depends on the apply order today (Go `gemini-4-argon`'s call config, on the floors only: pi 1.5.0 places the id in either order) is pinned by its own `[KNOWN_DEFECT]` case, outside the booted blocks; the phase that flips that row also drops `todayRowsFor`'s pi-ai-first override and turns that case into a `[BASELINE]` "no facet depends on the apply order" check, in the same diff. The fixture is S0 plus one declared id no catalog knows (`CHARACTERIZED_PROFILE`); the contract tests keep plain S0. + +**A catalog refresh can touch the rows.** `S0_TODAY`'s context windows and effort lists are copied from the generated `src/catalog-data.ts`, which the nightly refresh rewrites. A fixture-sanity case compares each row's context window and effort ids to the bundled catalog's derivation and names the model that moved; when it fails on a refresh pull request, update the row in `S0_RC_FLOOR` (the context windows and efforts every cell inherits), and in a cell's `withMoved` entry where that cell restates the row. A phase that moves a non-`BASELINE` `info` facet off the catalog on purpose (the user's context window, a toggle model's Off/On) adds that row's key to the case's `INFO_NOT_FROM_CATALOG` set in the same diff; the case refuses to exempt a `BASELINE` facet. The `e2e-host` job's scope includes `test/support/host.ts`, so the binary e2e runs on that pull request too and checks the edited row against the published binary. + +**The apply order is a race on the real host.** Tier B fixes it; the published binary does not. The rows depend on whether the plugin's adapter decoration wrapped pi-ai's plane adapter, so the probe records that and the order it implies (`inferredOrder` — inferred, not observed: a pi-ai restart after the plugin applied would read as plugin-first), and the binary e2e picks its rows with `todayRowsFor(inferredOrder)` instead of pinning one. + +### The `e2e-host` job + +`.github/workflows/ci.yml`'s `e2e-host` job installs each channel's published `@deepseek-ai/dsh` (its record's `dsh`) into a throwaway prefix with npm, the way a user installs it, builds this checkout, and runs `test/e2e/host-binary.e2e.ts`. That file creates a temporary HOME whose profile links this checkout and the probe bundle (`test/e2e/host/`) through relative `link:` paths, stores dummy keys in the temporary home's credentials document (`$DSH_HOME/.credentials.yaml`, shared by every profile under that home), boots `dsh --profile e2e --no-open --port 0`, and reads what `test/e2e/host/probe-plugin.mjs` measured. It asserts today's rows; P-R1 flips them. + +- **Cells, per channel, pi unpatched in both.** `npm` is the published host exactly as npm resolves it, cordis and pi included, what an npm user runs, and asserts the `newest` rows while its pi is the channel record's; a pi published since the record still has to sit in the bridge's range, but the rows that depend on which ids pi describes (the keyed listing and the per-model rows) are skipped with a warning, and the nightly `host-drift` job names the new pi. `floor` is the tree the desktop app ships: npm `overrides` (`floorOverrides` in `host-versions.ts`) force the channel's whole cordis family (cordis, the loader, the include, group and timer plugins, cosmokit, schemastery) at the record's versions, which are the floors of the `~` ranges the published cluster declares (what the harness vendors at the tag), and pi with its telemetry package at the range's floor; the job and the e2e assert that npm applied each one, and the cell asserts every row of the channel's floor cell. In both cells the e2e also holds each cordis-family field to the floor of the range the installed published package declares for it, which no override rewrites, with one rule for cosmokit: its declarer is cordis, so it is checked only where the installed cordis is the record's, and elsewhere only the nightly drift report checks it (today that leaves it out of alpha's `npm` cell, where cordis floats to a newer patch). It is cached by channel, `dsh` and a hash of those overrides, so a cache filled before the record or its overrides moved is never reused; the rest of the tree is what npm resolved when the cache was filled. The installed `dsh` must be the channel record's, so a prefix of the wrong channel fails by name. The save rows run whatever pi resolved (they do not depend on which ids pi describes, though they assume pi lacks at least one of `ZEN_UNDESCRIBED` and still describes every other declared Zen id, so a pi that learns all of them or retires a declared id turns them red by design: triage that as a pi change first); they skip only where the host itself cannot save (next bullet). +- **The host's own failures are recorded, not worked around.** Only the `npm` cell lets cordis float, as the published host's `~` ranges do for a user, and the job reports what it resolved. Where that leaves the host unable to run its own save path, the run records a `[HOST_LIMITATION]` row (titled with the channel and the `dsh`) and skips the save rows. It is detected from what the host answered, never from a version (D26): the plane edit and a control edit no plugin's listener reads (the probe's own entry) both fail with the same `TypeError` thrown by `@deepseek-ai/dsh-app-boot`; where the host saves, the row skips and a `[BASELINE]` row asserts the control was accepted. Today that is alpha's `0.2.2-alpha.2` as npm installs it: cordis `4.0.6-alpha.2` calls `internal/update` in a new shape while `dsh-app-boot` registers its listener in the old one, so every config-editor save throws `next is not a function`. The alpha floor, on the cordis the harness vendors at the tag, saves. +- **Triggers.** Dispatch and the nightly run take both cells of both channels. A pull request takes the `npm` cell of both channels, and only when it touches `src/host/**`, `src/lifecycle.ts`, `src/identity.ts`, the modules that install the host patches and mounts whose behaviour depends on the real loader and apply order, until P-R1 moves them under `src/host/` (`src/responses-provider.ts`, `src/responses-routes.ts`, `src/models-discovery.ts`, `src/google-bridge.ts`, `src/fetch-patch.ts`, `src/pi-provider.ts`, `src/stream-hook.ts`; every other row producer, such as `src/key-capture.ts`, `src/models-catalog.ts` or `src/config.ts`, is covered on each pull request by the in-process characterization of the same `S0_TODAY` rows, and by the nightly run), `cordis.patch.yml`, `test/e2e/host/**`, `test/e2e/host-binary.e2e.ts`, `test/support/host.ts` (the rows the binary e2e asserts), `test/support/host-versions.ts` (what the job installs), `scripts/host-drift.ts` (the family table the binary e2e asserts against the installed manifests), `.github/workflows/ci.yml`, `vitest.e2e.config.ts` or `vite.config.ts` (the job's own steps and runner config; `vite.config.ts` also holds the pack config that builds `lib/index.mjs`, the bundle only this job loads into a real host, and the test settings the e2e config inherits) (scoped by a step: a workflow-level path filter would silence every other job; a diff the step cannot compute fails it rather than skipping the job). `package.json` is left out because every dependency bump touches it, so a change to its `test:e2e:host` script alone is exercised only by the nightly run. It needs no secret. + +To run it locally, install a channel's host into a prefix outside the checkout, then point the suite at it (placeholder paths; `pnpm` must be on `PATH`). The first line reads the channel's record from `test/support/host-versions.ts`, the same way the CI job does, so the snippet never names a version itself: + +```sh +export DSH_CHANNEL=alpha # or rc +eval "$(node --experimental-strip-types --input-type=module -e "import { CHANNELS } from './test/support/host-versions.ts'; const r = CHANNELS[process.env.DSH_CHANNEL]; console.log('DSH=' + r.dsh)")" +mkdir -p /home/you/dsh-host +printf '{ "private": true }\n' > /home/you/dsh-host/package.json +npm install --prefix /home/you/dsh-host "@deepseek-ai/dsh@$DSH" +pnpm run build +DSH_HOST_E2E=1 DSH_HOST_PREFIX=/home/you/dsh-host pnpm run test:e2e:host --run +``` + +For the `floor` cell, use a fresh prefix whose `package.json` carries the channel's `floorOverrides` (the cordis family at the record's versions and pi with its telemetry package at the range's floor) as `"overrides"` before the install, written the same way the CI job writes it, and run the suite with `DSH_HOST_PI_CELL=floor`: + +```sh +mkdir -p /home/you/dsh-host-floor +node --experimental-strip-types --input-type=module -e "import { CHANNELS, floorOverrides } from './test/support/host-versions.ts'; console.log(JSON.stringify({ private: true, overrides: floorOverrides(CHANNELS[process.env.DSH_CHANNEL]) }))" > /home/you/dsh-host-floor/package.json +``` ## Live profile loop @@ -106,7 +161,7 @@ git diff --name-only main... \ 1. Use conventional commits (`feat:`, `fix:`) — release-please opens the version-bump + changelog PR automatically; merging it tags and creates the GitHub Release, which fires OIDC publishing to npm and GitHub Packages. 2. Avoid manual releases: release-please anchors on the latest GitHub **Release**, not the latest tag, so a bare tag is invisible to it (`AGENTS.md`, "Releasing"). If one is unavoidable, bump `version` in `package.json`, commit, tag, push the tag, and create the GitHub Release in the same step (`gh release create vX.Y.Z --generate-notes`). Never touch `CHANGELOG.md`: release-please owns it. -3. On every release, after smoke-testing (registry install resolves, plugin loads in the DSH Web profile, and a free-tier Zen call succeeds), check the **Supported: the latest release of both DeepSeek Harness channels** line and the **Support policy** paragraph under "Compatibility & Verification" in both READMEs. Their "today" versions, pi ranges and floors, and the default install named a paragraph below, are copied from the channel records in `test/support/host-versions.ts`, and `test/harness-channel.test.ts` holds them to the records. A channel bump **inside the prerelease tuples the peer ranges already admit** (`0.2.2-alpha.2` to `0.2.2-alpha.3`, cordis `4.0.6-alpha.1` to `4.0.6-alpha.2`) is version data only, never `src` or test logic, and this is its whole set: for `rc`, the rc record and `channels/rc/pnpm-lock.yaml` (`scripts/use-channel.ts rc --relock`); for `alpha`, the alpha record, `package.json`'s exact `@deepseek-ai/*` pins and the root lockfile (`pnpm install`); for either, those README sentences in both languages, and `minimumReleaseAgeExclude` lines in `pnpm-workspace.yaml` for the exact versions when the cut is younger than pnpm's one-day release-age window. A pi move is the same data: `pnpm install` and `--relock` keep the pi a lockfile already satisfies, so move it with `pnpm update @earendil-works/pi-ai --no-save` (the root lockfile; `package.json` keeps the union range), then `scripts/use-channel.ts rc --relock`, which carries the root's pi into the rc lockfile, and set each record's `pi`. The same update is what leaves one pi when a new bridge raises its floor above the locked one. The F10 characterization (`test/mount-protocols.test.ts`) is keyed by pi line, so a pi inside a line needs no test change; a pi outside every line is a new major, and a reviewed change. A cut that **opens a new prerelease tuple** (dsh `0.2.3-*`, cordis `4.0.7-*`) is not data: semver admits a prerelease only through a comparator in its own tuple, so it is a reviewed range change that also adds the matching `>=X.Y.Z-0 =X.Y.Z-0