diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 96c1e369..815c188b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -24,6 +24,12 @@ jobs: steps: - 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 - name: Log in to GHCR diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..40aef747 --- /dev/null +++ b/.npmrc @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..27217bf6 --- /dev/null +++ b/AGENTS.md @@ -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-`), tag a rollback point + (`backup/pre-sync-`), 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`). diff --git a/CLAUDE.md b/CLAUDE.md index d7c21345..0121a47c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,12 @@ 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 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. diff --git a/Dockerfile b/Dockerfile index 5094ed2f..be1211d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,9 @@ # syntax=docker/dockerfile:1.7 # 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). +# 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 FROM ${NODE_IMAGE} AS base WORKDIR /app @@ -10,6 +13,10 @@ FROM base AS builder RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers 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 \ npm ci diff --git a/package-lock.json b/package-lock.json index 70de0508..d73b75ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,7 +98,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^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", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -376,6 +374,29 @@ "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": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", @@ -383,6 +404,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1867,6 +1889,27 @@ "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": { "version": "1.2.2", "dev": true, @@ -2204,7 +2247,6 @@ "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", @@ -2909,7 +2951,6 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3391,7 +3432,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -3807,7 +3847,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -4435,7 +4474,6 @@ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4621,7 +4659,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5559,7 +5596,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.16.tgz", "integrity": "sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -6853,7 +6889,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz", "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.4.8", "marked": "14.0.0" @@ -6939,7 +6974,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.3.1.tgz", "integrity": "sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "16.3.1", "@swc/helpers": "0.5.23", @@ -7696,7 +7730,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7706,7 +7739,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7718,15 +7750,13 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -7800,8 +7830,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -8866,7 +8895,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9584,7 +9612,6 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 49e45fdc..6db5efae 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "build:bun": "bun --bun next build --webpack", "start:bun": "bun ./.next/standalone/custom-server.js", "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": { "@dnd-kit/core": "^6.3.1", diff --git a/scripts/verify-lockfile-npm10.mjs b/scripts/verify-lockfile-npm10.mjs new file mode 100755 index 00000000..10ddcd18 --- /dev/null +++ b/scripts/verify-lockfile-npm10.mjs @@ -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@ from lock file + * Missing: @emnapi/core@ 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.`, +); diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index 246e9983..1ccd2c0b 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -93,10 +93,13 @@ const OAUTH_TEST_CONFIG = { authPrefix: "Bearer ", }, "codebuddy-cn": { tokenExists: true }, - // CodeBuddy Intl access tokens are Keycloak JWTs (iss .../auth/realms/copilot); - // probe the realm's userinfo 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. + // GUARD — DO NOT REMOVE. See AGENTS.md §4. Without this entry, Test Connection + // returns "Provider test not supported". codebuddy-intl access tokens are + // Keycloak JWTs (iss .../auth/realms/copilot); probe the realm's userinfo + // 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": { buildUrl: (token) => { const iss = decodeJwtPayload(token)?.iss; diff --git a/src/lib/oauth/providers/codebuddy-intl.js b/src/lib/oauth/providers/codebuddy-intl.js index 7bb114be..66bd3905 100644 --- a/src/lib/oauth/providers/codebuddy-intl.js +++ b/src/lib/oauth/providers/codebuddy-intl.js @@ -68,9 +68,10 @@ const codebuddyIntl = { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresIn: tokens.expires_in || 86400, - // The CodeBuddy access token is a Keycloak JWT carrying email/name claims; - // surface them so a fresh OAuth login is named by identity (and deduped on - // re-login) instead of falling back to "Account N". + // GUARD — DO NOT REMOVE. See AGENTS.md §4. The CodeBuddy access token is a + // Keycloak JWT carrying email/name claims; surface them so a fresh OAuth + // 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, displayName: extractDisplayNameFromAccessToken(tokens.access_token) || null, providerSpecificData: {}, diff --git a/src/shared/utils/usageProviders.js b/src/shared/utils/usageProviders.js index 2432df70..9c166289 100644 --- a/src/shared/utils/usageProviders.js +++ b/src/shared/utils/usageProviders.js @@ -1,5 +1,7 @@ // 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: // 1. Active LLM connections (one entry per provider). // 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 noAuth provider (devin-cli, mimo-free) must not leak into Usage with // zero connections and zero traffic. +// Covered by tests/unit/usage-provider-list.test.js. export function buildUsageProviderList({ connections = [], freeProviders = {}, diff --git a/tests/setup/isolateDataDir.js b/tests/setup/isolateDataDir.js index 06e11bbd..fa207952 100644 --- a/tests/setup/isolateDataDir.js +++ b/tests/setup/isolateDataDir.js @@ -4,6 +4,9 @@ // they append test rows ("zed-live-*@example.com", "guard-*@example.com", // "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 // at module-eval time), which is exactly what a vitest setupFile guarantees. // diff --git a/tests/unit/lockfile-npm10-guard.test.js b/tests/unit/lockfile-npm10-guard.test.js new file mode 100644 index 00000000..c05a1106 --- /dev/null +++ b/tests/unit/lockfile-npm10-guard.test.js @@ -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); + }); +});