chore(guards): add durable markers so fixes aren't silently dropped

Introduce AGENTS.md (root, primary agent instruction file) documenting six
hard-won fixes with explicit DO NOT / WHY, plus executable enforcement so a
future AI cannot delete or reintroduce them:

1. package-lock.json must be generated with npm 10 (Docker's npm 10.9.8).
   npm 11 drops the top-level @emnapi/core + @emnapi/runtime entries npm 10
   needs, breaking the tag-triggered Docker build at `npm ci` (happened on
   v1.0.14). Add scripts/verify-lockfile-npm10.mjs + .npmrc + a Dockerfile
   fail-fast check + a CI step + tests/unit/lockfile-npm10-guard.test.js.
   Also re-fix the lockfile itself (regenerated with npm 10.9.8).
2. Tests must never write to the real ~/.9router DB (isolateDataDir).
3. Hidden providers must not leak into Usage (usageProviders !p.hidden).
4. codebuddy-intl connection test + OAuth identity.
5. Fork-only features that must survive upstream syncs.
6. Upstream sync procedure.

Each marker cross-references AGENTS.md and the covering test. CLAUDE.md now
points to AGENTS.md at the top. Verified: build ok, guard script passes,
full suite leaves the real DB count unchanged (38), 0 new regressions.
This commit is contained in:
MUH. IQRAM BAHRING
2026-09-19 12:52:19 +08:00
parent 9c37af90a9
commit 25df5e6a9d
13 changed files with 365 additions and 28 deletions
+6
View File
@@ -24,6 +24,12 @@ jobs:
steps: steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
# Guard: the committed package-lock.json must be npm-10-compatible (the
# Docker image ships npm 10.9.8). Fails fast with a fix hint instead of a
# cryptic `npm ci` EUSAGE error. See AGENTS.md §1.
- name: Verify lockfile is npm-10-compatible
run: node scripts/verify-lockfile-npm10.mjs
- uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca # v3.9.0 - uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca # v3.9.0
- name: Log in to GHCR - name: Log in to GHCR
+17
View File
@@ -0,0 +1,17 @@
# MIBP fork — npm behavior pin.
#
# DO NOT DELETE. See AGENTS.md §1.
#
# The Docker image (node:22-alpine, pinned by digest) ships npm 10.9.8 and runs
# `npm ci` against the committed package-lock.json. Regenerating the lockfile
# with npm 11+ drops the top-level @emnapi/core + @emnapi/runtime entries npm 10
# needs, which breaks the tag-triggered Docker build ("Build and Push Docker
# Image") at `npm ci`.
#
# Regenerate the lockfile with npm 10 only:
# npx -y npm@10.9.8 install --package-lock-only
# node scripts/verify-lockfile-npm10.mjs
#
# Keep install output quiet; never let audit/fund noise hide a lockfile error.
audit=false
fund=false
+160
View File
@@ -0,0 +1,160 @@
# AGENTS.md — MIBP fork guardrails
> **READ THIS BEFORE EDITING.** This is the **MIBP fork** of `decolua/9router`
> (`github.com/mhiqrambg/9router-mibp-version`). Upstream is tracked as the
> `upstream` git remote. This file records hard-won fixes that are easy to
> silently delete or reintroduce. Each entry has a **DO NOT** and a **WHY**.
>
> If you are about to change something listed here, stop and read the whole
> entry first. If you believe an entry is obsolete, say so explicitly and get
> confirmation before removing it — do **not** quietly drop it.
---
## 1. `package-lock.json` MUST be generated with npm 10 (Docker's npm)
**DO NOT** regenerate `package-lock.json` with your global `npm` (npm 11+).
**DO NOT** delete `package-lock.json` or add it to `.gitignore`.
**WHY:** The Docker image pins `node:22-alpine` by digest (see `Dockerfile`
`ARG NODE_IMAGE`), which ships **npm 10.9.8**. The `Dockerfile` runs `npm ci`
against the committed lockfile. npm 11 **drops the top-level optional entries**
`@emnapi/core` and `@emnapi/runtime` that npm 10's platform-complete resolve
requires. The tag-triggered **"Build and Push Docker Image"** workflow then
fails at `npm ci`:
```
npm error `npm ci` can only install packages when your package.json and package-lock.json are in sync.
npm error Missing: @emnapi/runtime@1.11.3 from lock file
npm error Missing: @emnapi/core@1.11.3 from lock file
```
This **already happened on tag `v1.0.14`** (run 35422006940) and on earlier
releases (`a2c6187a`, `470c8ca7`, `79294644`, `76b139b3`).
**Correct way to (re)generate the lockfile:**
```bash
npx -y npm@10.9.8 install --package-lock-only
node scripts/verify-lockfile-npm10.mjs # must print ✅
```
**Enforcement:** `scripts/verify-lockfile-npm10.mjs` fails (exit 1) if the
top-level `@emnapi/core` / `@emnapi/runtime` entries are missing. Run it after
any dependency change; CI runs it before the Docker build.
---
## 2. Tests must NEVER write to the real database (`~/.9router`)
**DO NOT** remove `tests/setup/isolateDataDir.js` from `tests/vitest.config.js`
`setupFiles`. **DO NOT** delete `tests/unit/test-data-dir-isolation.test.js`.
**WHY:** Route-level tests (e.g. `zed-live-models.test.js`,
`zed-native-auth.test.js`) call the real `createProviderConnection`, which
persists to `$DATA_DIR/db/data.sqlite`. With `DATA_DIR` unset — the default for
`npx vitest run` — that resolved to the developer's **real** `~/.9router` DB and
appended fake connections on every run: `zed-live-*@example.com`,
`guard-*@example.com`, `zed "Account N"` (token `decrypted-token-xyz`), and the
fake provider `kimchi-nope`. These then showed up in the Usage dashboard. 42
such rows had to be cleaned out of the live DB once.
**Isolation contract:**
- Default: `DATA_DIR` → throwaway temp dir (auto-removed on exit).
- `RUN_REAL=1` or an explicit `DATA_DIR` → opt out (used by `*.real.test.js`
suites that intentionally read live credentials).
**Verify a change is safe:** run the suite, then confirm the real DB is
untouched:
```bash
node -e 'const D=require("better-sqlite3");const db=new D(process.env.HOME+"/.9router/db/data.sqlite",{readonly:true});console.log(db.prepare("SELECT COUNT(*) c FROM providerConnections").get().c)'
```
The count must be identical before and after `npx vitest run`.
---
## 3. Hidden providers must not leak into the Usage page
**DO NOT** remove the `!p.hidden` filter in `src/shared/utils/usageProviders.js`
(`buildUsageProviderList`). **DO NOT** inline the old unfiltered
`Object.values(FREE_PROVIDERS).filter(p => p.noAuth && ...)` logic back into
`src/shared/components/UsageStats.js`.
**WHY:** The Usage page auto-adds every `noAuth` free provider so connectionless
providers (e.g. `opencode`) still appear. It must also honor the registry
`hidden` flag, matching the Providers page (`providers/page.js` filters
`!info.hidden`). Without it, `devin-cli` and `mimo-free` (both
`category:"free"`, `noAuth:true`, `hidden:true`) appear in Usage with zero
connections and zero traffic.
**Covered by:** `tests/unit/usage-provider-list.test.js`.
---
## 4. `codebuddy-intl` connection test + OAuth identity
**DO NOT** remove the `"codebuddy-intl"` entry from `OAUTH_TEST_CONFIG` in
`src/app/api/providers/[id]/test/testUtils.js`.
**DO NOT** remove `email` / `displayName` from `codebuddy-intl`'s `mapTokens`
(`src/lib/oauth/providers/codebuddy-intl.js`).
**DO NOT** remove `backfillCodeBuddyIntlIdentity` or its calls in
`GET /api/providers` and `/api/providers/client`.
**WHY (two bugs, both fixed 2026-09-19):**
1. **Test Connection** returned `"Provider test not supported"` because
`codebuddy-intl` was missing from `OAUTH_TEST_CONFIG`, so
`testOAuthConnection` bailed before probing. It now probes the Keycloak
realm's `userinfo` endpoint (URL derived from the token's `iss` claim).
2. **OAuth logins** were named `"Account N"` with no email. The access token is
a Keycloak JWT carrying `email`/`name`; `mapTokens` now extracts them, and a
run-once backfill self-heals pre-existing rows.
**Covered by:** `tests/unit/codebuddy-intl-connection.test.js`,
`tests/unit/codebuddy-intl-backfill.test.js`.
---
## 5. Fork-only features — NEVER drop during an upstream sync
When merging `upstream/master`, these fork additions must survive conflict
resolution. If a merge conflict touches them, **resolve fork-priority** and
re-verify after.
| Area | Key files / markers |
|---|---|
| **Freebuff provider** | `open-sse/executors/freebuff.js`, `open-sse/providers/registry/freebuff.js`, `open-sse/services/usage/freebuff.js`, `src/lib/oauth/providers/freebuff.js`, `public/providers/freebuff.png`, and its entries in `open-sse/executors/index.js` + `open-sse/providers/registry/index.js` |
| **Proxy-pool fitness** | `open-sse/services/proxyPoolFitness.js`, `open-sse/services/poolGeo.js`, `src/lib/network/poolEgressProbe.js`, `src/lib/network/stateSweeper.js`, `src/app/(dashboard)/dashboard/proxy-fitness/`, `src/app/api/proxy-pools/**` |
| **Docker hardening** | `Dockerfile`: digest-pinned `NODE_IMAGE`, tracked `package-lock.json`, `npm ci`, `HEALTHCHECK`. `.github/workflows/docker-publish.yml`. |
| **dompurify security override** | `package.json` `overrides.dompurify` + the direct `dompurify` dependency |
| **MIBP branding** | `README.md`, `docker-compose.yml`, `.env.example`, the `MIBP Edition` link in `src/app/(dashboard)/dashboard/profile/page.js` |
| **Cline free-tier models** | `open-sse/providers/registry/cline.js` `authModes: ["oauth","apikey"]` + `cline-free/*` models; `open-sse/shared/clineAuth.js` product headers |
---
## 6. Upstream sync procedure
- Remote layout: `origin` = this fork, `upstream` = `decolua/9router`.
- **Merge, do not rebase** — the fork already has merge-based history; rebasing
rewrites public history.
- Work on a branch (`sync/upstream-<ver>`), tag a rollback point
(`backup/pre-sync-<ver>`), then fast-forward `master`.
- After resolving conflicts: `npm run build`, `npx vitest run`, and the
baseline scripts (`tests/__baseline__/verify-providers.mjs`,
`verify-oauth-urls.mjs`, `verify-alias.mjs`).
- The suite is **not** expected to be all-green (see `CLAUDE.md`). Judge only
for **new** regressions vs. a pre-merge run; a handful of upstream test-drift
failures are known.
- Bump `package.json` version (fork cadence `1.0.x`); `CHANGELOG.md` mirrors
upstream and is **not** edited by the fork.
- Regenerate the lockfile with npm 10 (see §1) after any dependency change.
---
## 7. Do not re-add `package-lock.json` to `.gitignore`
**WHY:** Upstream does not track a lockfile, but this fork **must** — the
Docker build depends on `npm ci` against a committed, npm-10-compatible
lockfile. The fork explicitly removed `package-lock.json` from `.gitignore`
(commit `76b139b3`).
+6
View File
@@ -2,6 +2,12 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> ⚠️ **MIBP fork — read [`AGENTS.md`](AGENTS.md) first.** It lists fixes that are
> easy to silently delete or reintroduce (npm-10 lockfile rule, test DB
> isolation, hidden-provider filtering, codebuddy-intl behavior, fork-only
> features that must survive upstream syncs). Do not remove anything listed
> there without explicit confirmation.
## What this is ## What this is
9Router (`9router-app`) — a local AI routing gateway + Next.js dashboard. It exposes one OpenAI-compatible endpoint (`/v1/*`) and routes traffic across 40+ upstream providers with format translation, model-combo fallback, multi-account fallback, OAuth/API-key credential management, token refresh, quota/usage tracking, and optional cloud sync. 9Router (`9router-app`) — a local AI routing gateway + Next.js dashboard. It exposes one OpenAI-compatible endpoint (`/v1/*`) and routes traffic across 40+ upstream providers with format translation, model-combo fallback, multi-account fallback, OAuth/API-key credential management, token refresh, quota/usage tracking, and optional cloud sync.
+7
View File
@@ -1,6 +1,9 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
# Pinned by digest so a base-image refresh cannot silently bump npm and break # Pinned by digest so a base-image refresh cannot silently bump npm and break
# `npm ci` against the committed lockfile (see v1.0.9 npm ci EUSAGE failure). # `npm ci` against the committed lockfile (see v1.0.9 npm ci EUSAGE failure).
# DO NOT unpin. This image ships npm 10.9.8 — the lockfile MUST be regenerated
# with npm 10 (`npx -y npm@10.9.8 install --package-lock-only`), never npm 11+.
# See AGENTS.md §1 and scripts/verify-lockfile-npm10.mjs.
ARG NODE_IMAGE=node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 ARG NODE_IMAGE=node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32
FROM ${NODE_IMAGE} AS base FROM ${NODE_IMAGE} AS base
WORKDIR /app WORKDIR /app
@@ -10,6 +13,10 @@ FROM base AS builder
RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
# Fail fast with an actionable message if the lockfile was regenerated with
# npm 11+ (drops the top-level @emnapi entries npm 10 requires). Without this,
# `npm ci` still fails but with a cryptic "Missing: @emnapi/..." EUSAGE error.
RUN node -e "const l=require('./package-lock.json');const p=l.packages||{};const miss=['node_modules/@emnapi/core','node_modules/@emnapi/runtime'].filter(k=>!p[k]);if(miss.length){console.error('LOCKFILE NOT npm-10-COMPATIBLE — missing: '+miss.join(', '));console.error('Regenerate with: npx -y npm@10.9.8 install --package-lock-only');console.error('See AGENTS.md §1.');process.exit(1)}"
RUN --mount=type=cache,target=/root/.npm \ RUN --mount=type=cache,target=/root/.npm \
npm ci npm ci
+47 -20
View File
@@ -98,7 +98,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7", "@babel/generator": "^7.29.7",
@@ -325,7 +324,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -376,6 +374,29 @@
"react": ">=16.8.0" "react": ">=16.8.0"
} }
}, },
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
@@ -383,6 +404,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
@@ -1867,6 +1889,27 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "1.2.2",
"dev": true, "dev": true,
@@ -2204,7 +2247,6 @@
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/types": "8.67.0", "@typescript-eslint/types": "8.67.0",
@@ -2909,7 +2951,6 @@
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3391,7 +3432,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.11.12", "baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809", "caniuse-lite": "^1.0.30001809",
@@ -3807,7 +3847,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@@ -4435,7 +4474,6 @@
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -4621,7 +4659,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -5559,7 +5596,6 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.16.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.16.tgz",
"integrity": "sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==", "integrity": "sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/immer" "url": "https://opencollective.com/immer"
@@ -6853,7 +6889,6 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz",
"integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==", "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"dompurify": "3.4.8", "dompurify": "3.4.8",
"marked": "14.0.0" "marked": "14.0.0"
@@ -6939,7 +6974,6 @@
"resolved": "https://registry.npmjs.org/next/-/next-16.3.1.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.3.1.tgz",
"integrity": "sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==", "integrity": "sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@next/env": "16.3.1", "@next/env": "16.3.1",
"@swc/helpers": "0.5.23", "@swc/helpers": "0.5.23",
@@ -7696,7 +7730,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -7706,7 +7739,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -7718,15 +7750,13 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/react-redux": { "node_modules/react-redux": {
"version": "9.3.0", "version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@@ -7800,8 +7830,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/redux-thunk": { "node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",
@@ -8866,7 +8895,6 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -9584,7 +9612,6 @@
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
+3 -1
View File
@@ -14,7 +14,9 @@
"build:bun": "bun --bun next build --webpack", "build:bun": "bun --bun next build --webpack",
"start:bun": "bun ./.next/standalone/custom-server.js", "start:bun": "bun ./.next/standalone/custom-server.js",
"cli:pack": "npm --prefix cli run pack:cli", "cli:pack": "npm --prefix cli run pack:cli",
"cli:publish": "npm --prefix cli run publish:cli" "cli:publish": "npm --prefix cli run publish:cli",
"verify:lockfile": "node scripts/verify-lockfile-npm10.mjs",
"lockfile:regen": "npx -y npm@10.9.8 install --package-lock-only"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* verify-lockfile-npm10.mjs
*
* GUARD — DO NOT DELETE.
*
* Why this exists (root cause, 2026-09-19):
* The Docker image pins `node:22-alpine` by digest, which ships npm 10.9.8.
* The repo's `package-lock.json` is tracked (upstream does NOT track it) and
* the Dockerfile runs `npm ci`. If the lockfile is regenerated with npm 11+,
* npm 11 DROPS the top-level optional entries `@emnapi/core` and
* `@emnapi/runtime` that npm 10 requires for its platform-complete resolve.
* The tag-triggered "Build and Push Docker Image" workflow then fails at
* `npm ci` with:
* Missing: @emnapi/runtime@<v> from lock file
* Missing: @emnapi/core@<v> from lock file
* This already happened once on tag v1.0.14.
*
* Rule: ALWAYS regenerate package-lock.json with npm 10.x (the version Docker
* uses), never with the developer's global npm 11+.
*
* Correct: npx -y npm@10.9.8 install --package-lock-only
* Wrong: npm install # global npm 11+ silently breaks Docker
*
* This script asserts the lockfile still carries the entries npm 10 needs.
* Wire it into CI / pre-commit; exit 1 = lockfile will break the Docker build.
*
* Usage: node scripts/verify-lockfile-npm10.mjs
*/
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const lockPath = join(here, "..", "package-lock.json");
let lock;
try {
lock = JSON.parse(readFileSync(lockPath, "utf8"));
} catch (err) {
console.error(`❌ Cannot read ${lockPath}: ${err.message}`);
process.exit(1);
}
const packages = lock.packages || {};
const required = ["node_modules/@emnapi/core", "node_modules/@emnapi/runtime"];
const missing = required.filter((key) => !packages[key]);
if (missing.length > 0) {
console.error(
[
"❌ package-lock.json is NOT npm-10-compatible — the Docker build will fail at `npm ci`.",
"",
` Missing top-level entries: ${missing.join(", ")}`,
"",
" Cause: the lockfile was regenerated with npm 11+, which drops the",
" top-level @emnapi entries that npm 10 (node:22-alpine in the Docker image) requires.",
"",
" Fix (run from repo root):",
" npx -y npm@10.9.8 install --package-lock-only",
" Then re-run: node scripts/verify-lockfile-npm10.mjs",
"",
" Do NOT use your global `npm install` to regenerate the lockfile.",
].join("\n"),
);
process.exit(1);
}
console.log(
`✅ package-lock.json is npm-10-compatible (found ${required.join(", ")}). Docker \`npm ci\` will succeed.`,
);
+7 -4
View File
@@ -93,10 +93,13 @@ const OAUTH_TEST_CONFIG = {
authPrefix: "Bearer ", authPrefix: "Bearer ",
}, },
"codebuddy-cn": { tokenExists: true }, "codebuddy-cn": { tokenExists: true },
// CodeBuddy Intl access tokens are Keycloak JWTs (iss .../auth/realms/copilot); // GUARD — DO NOT REMOVE. See AGENTS.md §4. Without this entry, Test Connection
// probe the realm's userinfo endpoint so a revoked/expired token is caught. // returns "Provider test not supported". codebuddy-intl access tokens are
// Derive the realm URL from the token's `iss` claim, falling back to the // Keycloak JWTs (iss .../auth/realms/copilot); probe the realm's userinfo
// known copilot realm. 200 = valid, 401 = invalid/revoked. // endpoint so a revoked/expired token is caught. Derive the realm URL from the
// token's `iss` claim, falling back to the known copilot realm.
// 200 = valid, 401 = invalid/revoked.
// Covered by tests/unit/codebuddy-intl-connection.test.js.
"codebuddy-intl": { "codebuddy-intl": {
buildUrl: (token) => { buildUrl: (token) => {
const iss = decodeJwtPayload(token)?.iss; const iss = decodeJwtPayload(token)?.iss;
+4 -3
View File
@@ -68,9 +68,10 @@ const codebuddyIntl = {
accessToken: tokens.access_token, accessToken: tokens.access_token,
refreshToken: tokens.refresh_token, refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in || 86400, expiresIn: tokens.expires_in || 86400,
// The CodeBuddy access token is a Keycloak JWT carrying email/name claims; // GUARD — DO NOT REMOVE. See AGENTS.md §4. The CodeBuddy access token is a
// surface them so a fresh OAuth login is named by identity (and deduped on // Keycloak JWT carrying email/name claims; surface them so a fresh OAuth
// re-login) instead of falling back to "Account N". // login is named by identity (and deduped on re-login) instead of falling
// back to "Account N". Covered by tests/unit/codebuddy-intl-connection.test.js.
email: extractEmailFromAccessToken(tokens.access_token) || null, email: extractEmailFromAccessToken(tokens.access_token) || null,
displayName: extractDisplayNameFromAccessToken(tokens.access_token) || null, displayName: extractDisplayNameFromAccessToken(tokens.access_token) || null,
providerSpecificData: {}, providerSpecificData: {},
+3
View File
@@ -1,5 +1,7 @@
// Provider list for the Usage page. // Provider list for the Usage page.
// //
// GUARD — DO NOT DELETE the `!p.hidden` filter below. See AGENTS.md §3.
//
// Two sources, deduped by provider id: // Two sources, deduped by provider id:
// 1. Active LLM connections (one entry per provider). // 1. Active LLM connections (one entry per provider).
// 2. noAuth free providers that need no connection (e.g. opencode). // 2. noAuth free providers that need no connection (e.g. opencode).
@@ -7,6 +9,7 @@
// Hidden providers are excluded — the Providers page filters `hidden`, so a // Hidden providers are excluded — the Providers page filters `hidden`, so a
// hidden noAuth provider (devin-cli, mimo-free) must not leak into Usage with // hidden noAuth provider (devin-cli, mimo-free) must not leak into Usage with
// zero connections and zero traffic. // zero connections and zero traffic.
// Covered by tests/unit/usage-provider-list.test.js.
export function buildUsageProviderList({ export function buildUsageProviderList({
connections = [], connections = [],
freeProviders = {}, freeProviders = {},
+3
View File
@@ -4,6 +4,9 @@
// they append test rows ("zed-live-*@example.com", "guard-*@example.com", // they append test rows ("zed-live-*@example.com", "guard-*@example.com",
// "Account N") straight into the live DB. // "Account N") straight into the live DB.
// //
// GUARD — DO NOT DELETE this file or remove it from vitest.config.js setupFiles.
// See AGENTS.md §2. Covered by tests/unit/test-data-dir-isolation.test.js.
//
// DATA_DIR must be set before src/lib/dataDir.js is imported (it reads the env // DATA_DIR must be set before src/lib/dataDir.js is imported (it reads the env
// at module-eval time), which is exactly what a vitest setupFile guarantees. // at module-eval time), which is exactly what a vitest setupFile guarantees.
// //
+31
View File
@@ -0,0 +1,31 @@
// GUARD — DO NOT DELETE. See AGENTS.md §1.
//
// The Docker image (node:22-alpine, pinned by digest) ships npm 10.9.8 and runs
// `npm ci` against the committed package-lock.json. Regenerating the lockfile
// with npm 11+ drops the top-level @emnapi/core + @emnapi/runtime entries npm 10
// requires, breaking the tag-triggered Docker build at `npm ci` (already
// happened on tag v1.0.14).
//
// Regenerate the lockfile with npm 10 only:
// npx -y npm@10.9.8 install --package-lock-only
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const lock = JSON.parse(readFileSync(join(here, "..", "..", "package-lock.json"), "utf8"));
describe("package-lock.json is npm-10-compatible (Docker npm ci)", () => {
it("keeps the top-level @emnapi/core entry npm 10 requires", () => {
expect(lock.packages?.["node_modules/@emnapi/core"]).toBeTruthy();
});
it("keeps the top-level @emnapi/runtime entry npm 10 requires", () => {
expect(lock.packages?.["node_modules/@emnapi/runtime"]).toBeTruthy();
});
it("stays on lockfileVersion 3", () => {
expect(lock.lockfileVersion).toBe(3);
});
});