Compare commits

..

7 Commits

Author SHA1 Message Date
Markus Hartung
7b13a70a71 fix(sse): exact-domain cookie match and origin-equality URL assertions
Clears CodeQL js/incomplete-url-substring-sanitization alerts #860-#865:

- volcengineConsoleAutoLogin: cookie domain filter now uses an exact/
  dot-suffix helper (isVolcengineCookieDomain) instead of substring
  includes(), rejecting look-alike hosts like volcengine.com.evil.test
- security-s1-s2-s4 tests: agent-card/agent.json URL assertions compare
  parsed origin equality instead of startsWith prefix
2026-08-24 23:35:45 -03:00
Dizzle
943b9aaa84 fix(cli): flag a .env that lives inside the installed package (#11437)
Retargetado para release/v3.8.51 (release/v3.8.50 está congelada — freeze issue #11439). Resolvido o mesmo conflito não-relacionado em src/shared/utils/wsPath.ts (mesma causa do #11436 — refactor já mergeado na branch depois do fork deste PR; o diff real deste PR — bin/cli/utils/volatileEnvPath.mjs + bin/omniroute.mjs — ficou intacto) e revalidado: typecheck:core limpo, 12/12 testes focados passando.

Companion do #11436, decisão pura testável isoladamente, sem mudança de comportamento fora do caso volátil. Obrigado pela contribuição!
2026-08-24 20:10:08 -03:00
Dizzle
6e8fc94732 fix(cli): stop pre-filling the secrets the server owns (#11436)
Retargetado para release/v3.8.51 (release/v3.8.50 está congelada — freeze issue #11439). Resolvido um conflito de merge não-relacionado em src/shared/utils/wsPath.ts (originado de um refactor já mergeado nessa branch depois do fork deste PR; o diff real deste PR — scripts/dev/sync-env.mjs + tests/unit/sync-env.test.ts — ficou intacto) e revalidado: typecheck:core limpo, 13/13 testes focados passando.

Segue o precedente correto do #1622 (STORAGE_ENCRYPTION_KEY) para os dois secrets restantes que a postinstall preenchia por engano, defeituando o mecanismo de ensureSecrets(). Obrigado pela contribuição!
2026-08-24 20:04:11 -03:00
Xiangzhe
66ecc09050 chore(release): restore the living [3.8.51] CHANGELOG section
The cycle-open commit's root CHANGELOG hunk was lost in the rebase onto the
branch tip (the 42 i18n mirrors kept theirs, so the root and the mirrors had
drifted apart). Re-inserts the section with the three canonical headings.
2026-08-24 20:00:10 -03:00
Xiangzhe
65a1808f84 chore(release): open v3.8.51 development cycle
Completes the 0a.0b cut for the parallel-cycle model: the branch already existed
(cut from the v3.8.50 tip) but had never been bumped. Bumps package.json x3,
openapi.yaml and the lockfile, adds the living [3.8.51] CHANGELOG section with
the three canonical headings so aggregate-changelog.mjs cannot mis-target an
older published section, and syncs the 42 i18n mirrors.
2026-08-24 19:59:24 -03:00
Dizzle
a166752138 fix(radar): keep the feed's build date in the catalog cache (#11435)
Retargetado para release/v3.8.51 (release/v3.8.50 está congelada — freeze issue #11439). Validado em lote combinado (batch-0824h2) contra o tip de release/v3.8.51: typecheck:core limpo, gates estáticos + migration-numbering OK, 127/127 testes focados passando (8/8 do PR entre migration-163 e radar-feed-cache-generated-at).

Migração limpa (ADD COLUMN nullable, sem backfill necessário), aditiva na API, mantém "unknown" honesto para linhas antigas. Obrigado pela contribuição!
2026-08-24 19:57:24 -03:00
Markus Hartung
04dba0460e fix(responses-continuation): recover a real id/output for passthrough and translate-mode replies (#11434)
Retargetado para release/v3.8.51 (release/v3.8.50 está congelada — freeze issue #11439). Validado em lote combinado (batch-0824h2, junto de #11435/#11436/#11437) contra o tip de release/v3.8.51: typecheck:core limpo, gates estáticos OK, 127/127 testes focados passando.

Investigação sólida com repro real via container isolado, três causas independentes identificadas e corrigidas com testes de regressão dedicados para cada uma. Obrigado pela contribuição!
2026-08-24 19:57:12 -03:00
81 changed files with 1725 additions and 251 deletions

View File

@@ -16,6 +16,18 @@
---
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -0,0 +1,37 @@
import { sep } from "node:path";
/**
* A `.env` inside the installed package directory does not survive an update:
* `npm i -g` replaces that directory wholesale, and postinstall recreates the
* file from `.env.example`. The CLI announces every env file it loads without
* distinguishing the ones that last from the one that doesn't.
*
* Returns the warning to print, or null when there is nothing worth saying.
*
* Two conditions, both required, so a development checkout never sees this:
* - the file sits inside the package root, and that root is inside a
* `node_modules` directory — i.e. an installed package, not a checkout,
* where the same path is stable and documented in SETUP_GUIDE.md;
* - the file actually supplied at least one value. First writer wins, so a
* file entirely shadowed by a durable one supplied nothing, and losing it
* costs nothing.
*
* @param {{ envPath: string, packageRoot: string, durableEnvPath: string, suppliedKeys: boolean }} args
* @returns {string | null}
*/
export function describeVolatileEnvWarning({ envPath, packageRoot, durableEnvPath, suppliedKeys }) {
if (!suppliedKeys) return null;
if (envPath === durableEnvPath) return null;
if (!isInsideInstalledPackage(packageRoot)) return null;
if (!envPath.startsWith(packageRoot + sep)) return null;
return (
`${envPath} lives inside the installed package: updating OmniRoute replaces it. ` +
`Move the values you set to ${durableEnvPath}, which updates leave alone.`
);
}
/** True when the path sits under a `node_modules` directory. */
function isInsideInstalledPackage(dir) {
return typeof dir === "string" && dir.split(sep).includes("node_modules");
}

View File

@@ -29,6 +29,7 @@ import { getDefaultDataDir } from "./cli/data-dir.mjs";
import { shouldProvisionStorageKey } from "./cli/utils/storageKeyProvision.mjs";
import { isVersionFastPath } from "./cli/utils/versionFastPath.mjs";
import { parseEnvValue } from "./cli/utils/parseEnvValue.mjs";
import { describeVolatileEnvWarning } from "./cli/utils/volatileEnvPath.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -91,9 +92,7 @@ function migrateElectronServerEnv(dataDir) {
const serverEnvPath = join(dataDir, "server.env");
if (existsSync(envPath) || !existsSync(serverEnvPath)) return;
writeFileSync(envPath, readFileSync(serverEnvPath, "utf-8"), "utf-8");
console.log(
` \x1b[2m♻ Migrated Electron secrets from ${serverEnvPath} to ${envPath}\x1b[0m`
);
console.log(` \x1b[2m♻ Migrated Electron secrets from ${serverEnvPath} to ${envPath}\x1b[0m`);
} catch {
// Ignore errors migrating server.env — fall back to normal env loading below.
}
@@ -164,6 +163,21 @@ function loadEnvFile() {
const setter = winner ? winner : "the environment";
console.warn(` \x1b[33m⚠ ${key} in ${loser} is ignored, ${setter} set it first\x1b[0m`);
}
// The package directory is replaced by the next `npm i -g`, so a .env kept
// there is silently lost. Say so once, and only when that file actually
// supplied something.
const durableEnvPath = join(process.env.DATA_DIR || getDefaultDataDir(), ".env");
const suppliedKeys = [...keyOrigin.values()].some((origin) => origin === join(ROOT, ".env"));
const volatileWarning = describeVolatileEnvWarning({
envPath: join(ROOT, ".env"),
packageRoot: ROOT,
durableEnvPath,
suppliedKeys,
});
if (volatileWarning && loadedEnvPaths.includes(join(ROOT, ".env"))) {
console.warn(` \x1b[33m⚠ ${volatileWarning}\x1b[0m`);
}
}
loadEnvFile();
@@ -247,16 +261,16 @@ if (shouldProvisionStorageKey(process.argv)) {
const langEnv = process.env.OMNIROUTE_LANG;
const chosen = langArg || langEnv;
if (chosen) {
const { setLocale } = await import(
pathToFileURL(join(ROOT, "bin", "cli", "i18n.mjs")).href
);
const { setLocale } = await import(pathToFileURL(join(ROOT, "bin", "cli", "i18n.mjs")).href);
setLocale(chosen);
}
}
// Register update notifier — checks npm once per 24h, notifies on exit via stderr.
const _pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
const _notifier = updateNotifier ? updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 }) : null;
const _notifier = updateNotifier
? updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 })
: null;
process.on("exit", () => {
if (!_notifier || !_notifier.update) return;
if (process.env.OMNIROUTE_NO_UPDATE_NOTIFIER) return;
@@ -265,7 +279,15 @@ process.on("exit", () => {
const outputIdx = process.argv.indexOf("--output");
const outputVal = outputIdx >= 0 ? process.argv[outputIdx + 1] : null;
if (outputVal === "json" || outputVal === "jsonl" || outputVal === "csv") return;
if (process.argv.some((a) => a.startsWith("--output=json") || a.startsWith("--output=jsonl") || a.startsWith("--output=csv"))) return;
if (
process.argv.some(
(a) =>
a.startsWith("--output=json") ||
a.startsWith("--output=jsonl") ||
a.startsWith("--output=csv")
)
)
return;
if (_notifier.update) {
_notifier.notify({
defer: false,

View File

@@ -0,0 +1 @@
- **fix(radar):** the catalog feed cache now keeps `generatedAt`, the date the feed's data was built, next to `fetchedAt`, the date this install downloaded it (#11435). The feed schema requires that date and the sync path validates it, but the cache dropped it — so a feed fetched minutes ago and one carrying weeks-old figures looked identical to everything downstream, including the dashboard's "Last fetched" line. `getRadarCatalog().meta` and `GET /api/radar/status` now report both dates, the latter as its own field rather than folded into `version` — and omitted entirely for the offers and intel caches, which keep no build date, where a `null` would read as "unknown" rather than "never stored". The dashboard still shows only the fetch time; surfacing the build date there needs a new translated label and is left to a follow-up. Rows cached before migration 163 read back as `null`: unknown stays unknown instead of borrowing the fetch time. The referrals cache has persisted the same date since migration 142.

View File

@@ -0,0 +1 @@
- **fix(cli):** postinstall no longer fills `JWT_SECRET` and `API_KEY_SECRET` in the installed package's `.env` (#11436). `.env.example` ships both blank on purpose: the server restores them from its durable store, or generates and persists them there, in `ensureSecrets()`. Pre-filling them defeated that — the file lives inside the package directory, so `npm i -g` replaced it and postinstall wrote _different_ values, while `ensureSecrets()` (which only acts on an empty variable) never got to restore the real ones. Both secrets rotated silently on every update, invalidating dashboard sessions and API-key CRCs. `STORAGE_ENCRYPTION_KEY` left the same list for the same reason in #1622; its comment pointed at a function that no longer exists and now names the real provisioning path.

View File

@@ -0,0 +1 @@
- **fix(cli):** the CLI now says when a loaded `.env` lives inside the installed package directory (#11437). It already announces every env file it reads, without distinguishing the ones that survive an update from the one that does not: `npm i -g` replaces the package directory wholesale, so values set there are gone at the next update, silently. The warning names the durable path to move them to, and fires only when that file actually supplied a value — a file entirely shadowed by a durable one supplied nothing. A development checkout stays silent: there the same path is stable and documented in `SETUP_GUIDE.md`.

View File

@@ -283,6 +283,32 @@ currently cached version (`compareVersions()`, dotted `YYYY.MM.DD.n` comparison)
`{ status: "stale" }`. This prevents a compromised or misconfigured feed endpoint from
rolling a client back to an older, differently-signed payload.
### Two dates, and why both are kept
A cached feed carries two distinct dates, and confusing them is the whole point of
keeping both:
| Field | Comes from | Answers |
| ------------- | -------------------- | ----------------------------------- |
| `generatedAt` | the signed feed body | how old the **data** is |
| `fetchedAt` | this install's clock | when this install **downloaded** it |
A feed fetched minutes ago can carry weeks-old figures, so `fetchedAt` alone cannot
tell an operator whether the overlay is fresher than the baseline it sits on. Both are
persisted in `radar_feed_cache`, returned by `getRadarCatalog().meta`, and reported
separately by `GET /api/radar/status`. A row cached before the `generated_at` column
existed (migration 163) reads back as `null` — unknown stays unknown rather than
borrowing the fetch time. `radar_referrals_cache` has kept its own `generated_at` since
migration 142.
The version floor above compares `version`, not either date.
Two gaps remain, both deliberate: the dashboard still shows only `Last fetched`, so reading
the build date there needs a new label (and its 42 locale entries); and the offers and intel
caches keep no build date at all, even though their feed schemas carry one — `GET
/api/radar/status` therefore omits the field for those two rather than reporting a `null`
that would read as "unknown".
### Schema validation
The downloaded bytes are parsed and validated against `RadarFeedSchema`

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -8,6 +8,19 @@
---
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,6 +6,19 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.8.50
version: 3.8.51
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "3.8.50",
"version": "3.8.51",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {

View File

@@ -26,12 +26,24 @@ import { attachLogMeta } from "./cacheUsageMeta.ts";
* (see src/lib/db/responsesContinuationStore.ts). Only meaningful when the
* client actually used the Responses endpoint -- a Chat Completions
* `chatcmpl-*` id must never be mistaken for a Responses response id.
*
* A non-streaming clientResponse carries `id` directly. A streaming one goes
* through clientPayloadCollector.build(), which always nests the caller's
* summary under `.summary` (see createStructuredSSECollector in
* streamPayloadCollector.ts) -- check both shapes rather than assuming one.
*/
function extractResponsesId(sourceFormat: unknown, clientResponse: unknown): string | null {
export function extractResponsesId(sourceFormat: unknown, clientResponse: unknown): string | null {
if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return null;
if (!clientResponse || typeof clientResponse !== "object") return null;
const id = (clientResponse as { id?: unknown }).id;
return typeof id === "string" && id.length > 0 ? id : null;
const record = clientResponse as { id?: unknown; summary?: unknown };
const directId = record.id;
if (typeof directId === "string" && directId.length > 0) return directId;
const summary = record.summary;
if (summary && typeof summary === "object") {
const summaryId = (summary as { id?: unknown }).id;
if (typeof summaryId === "string" && summaryId.length > 0) return summaryId;
}
return null;
}
export type PersistAttemptLogsArgs = {

View File

@@ -1,6 +1,6 @@
{
"name": "@omniroute/open-sse",
"version": "3.8.50",
"version": "3.8.51",
"description": "OmniRoute streaming engine — handles provider dispatch, protocol translation, and SSE streaming",
"type": "module",
"private": true

View File

@@ -18,7 +18,6 @@ import {
TokenExtractionConfig,
type TokenSource,
} from "./tokenExtractionConfig";
import { matchesCookieDomain } from "../utils/cookieDomain";
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -197,14 +196,9 @@ export class InAppLoginService extends EventEmitter {
for (const source of tokenSources) {
if (source.type === "cookie") {
const domain = source.domain || undefined;
// Exact host or dot-boundary suffix, never `includes()`: a cookie
// from `<domain>.attacker.tld` would otherwise be captured and
// persisted as the operator's credential. Same class CodeQL flagged
// in volcengineConsoleAutoLogin (#860/#861); this callsite was not
// flagged because the expected domain is config-supplied.
const matched = cookies.find(
(c: any) =>
c.name === source.name && (!domain || matchesCookieDomain(c.domain, domain))
c.name === source.name && (!domain || c.domain.includes(domain.replace(/^\./, "")))
);
if (matched && !credentials[source.name]) {
credentials[source.name] = matched.value;

View File

@@ -28,7 +28,6 @@
*/
import { randomUUID } from "crypto";
import { matchesCookieDomain } from "../utils/cookieDomain";
// ─── Public types ───────────────────────────────────────────────────────────
@@ -97,6 +96,13 @@ const ARK_CONSOLE_URL =
/** Cookie names required for a valid console session (mirrors tokenExtractionConfig) */
const REQUIRED_COOKIES = ["digest", "AccountID", "csrfToken", "userInfo"] as const;
/** Exact-domain match for session cookies — substring checks would also accept
* look-alike hosts (e.g. `volcengine.com.evil.test`). Playwright may report the
* domain with or without a leading dot. */
function isVolcengineCookieDomain(domain: string): boolean {
return domain === "volcengine.com" || domain.endsWith(".volcengine.com");
}
const DEFAULT_SESSION_TIMEOUT = 300_000;
const SUBMIT_COOKIE_TIMEOUT = 90_000;
const CAPTURE_POLL_INTERVAL = 1_000;
@@ -232,21 +238,6 @@ export function normalizePhone(raw: string): string | null {
return /^1\d{10}$/.test(bare) ? bare : null;
}
/**
* Whether a cookie's `domain` belongs to the Volcengine console.
*
* Cookie domains must be matched by exact host or dot-boundary suffix, never by
* substring: `domain.includes("volcengine.com")` also accepted
* `volcengine.com.attacker.tld` and `notvolcengine.com`, so a cookie named
* `digest`/`AccountID`/`csrfToken`/`userInfo` set by a look-alike host was
* harvested as an operator credential and persisted as a provider connection
* (CodeQL js/incomplete-url-substring-sanitization #860/#861). Mirrors
* `isAdobeCookieDomain` in adobeFireflyBrowserLogin.ts.
*/
export function isVolcengineCookieDomain(domain: string | undefined): boolean {
return matchesCookieDomain(domain, "volcengine.com");
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -1,34 +0,0 @@
/**
* Cookie-domain matching for browser-driven credential capture.
*
* Every in-app / console login flow harvests cookies out of a Playwright
* context and persists them as operator credentials, so "is this cookie from
* the site I sent the browser to?" is an authorization decision. A substring
* test is not one: `domain.includes("example.com")` also accepts
* `example.com.attacker.tld` and `notexample.com`, which lets a look-alike host
* hand us cookies we then store as the operator's real credentials
* (CodeQL js/incomplete-url-substring-sanitization).
*
* A cookie domain is matched by exact host or dot-boundary suffix — nothing
* else. Leading dots (the RFC 6265 "domain-matches any subdomain" spelling) and
* case are normalized away on both sides.
*/
export function matchesCookieDomain(
cookieDomain: string | undefined,
expectedDomain: string | undefined
): boolean {
const expected = normalizeCookieDomain(expectedDomain);
if (!expected) return false;
const actual = normalizeCookieDomain(cookieDomain);
if (!actual) return false;
return actual === expected || actual.endsWith(`.${expected}`);
}
function normalizeCookieDomain(domain: string | undefined): string {
return String(domain || "")
.trim()
.replace(/^\.+/, "")
.toLowerCase();
}

View File

@@ -1657,6 +1657,21 @@ export function createSSEStream(options: StreamOptions = {}) {
output = `data: ${JSON.stringify(parsed)}\n\n`;
injectedUsage = true;
}
// Passthrough mode never pushes a Responses SSE event into
// clientPayloadCollector on the common (non-tool-call, non-
// commentary) path -- only the textual-tool-call conversion
// branch above pushes its own synthesized events. Push just
// the fully-processed terminal `response.completed` (after
// the backfill/strip/tool-call-merge above, so it matches
// exactly what the client receives): that alone is enough
// for buildStreamSummaryFromEvents' reducer to recover a
// real Responses `id` + `output` for previous_response_id
// continuation storage (src/lib/db/responsesContinuationStore.ts).
// Pushing every delta here would double-count events the
// tool-call branch already pushes its own synthesized copy of.
if (parsed.type === "response.completed") {
clientPayloadCollector.push(parsed);
}
} else if (isClaudeSSE) {
// Claude SSE: extract usage, track content, forward as-is
const thinkingSignatureInjected = injectThinkingSignature(parsed, provider);
@@ -2589,9 +2604,24 @@ export function createSSEStream(options: StreamOptions = {}) {
: { object: "chat.completion", ...responseBody },
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(responseBody, {
includeEvents: false,
}),
// Same OPENAI_RESPONSES carve-out as providerPayload above, but keyed on
// clientResponseFormat (what the client actually receives) rather than
// sourceFormat (what the upstream sent) -- they're equal in passthrough
// mode but conceptually distinct. Without this, `entry.responseId` in
// src/lib/usage/callLogs.ts is always null for a Responses-API client
// (extractResponsesId reads `clientResponse.id`, which the chat-shaped
// responseBody never has), so previous_response_id continuation lookups
// in src/lib/db/responsesContinuationStore.ts always miss.
clientPayload: clientPayloadCollector.build(
clientResponseFormat === FORMATS.OPENAI_RESPONSES
? buildStreamSummaryFromEvents(
clientPayloadCollector.getEvents(),
clientResponseFormat,
model
)
: responseBody,
{ includeEvents: false }
),
});
} catch (e) {
console.debug(`[STREAM] onComplete callback error (${model || "unknown"}):`, e);
@@ -2893,9 +2923,24 @@ export function createSSEStream(options: StreamOptions = {}) {
: { object: "chat.completion", ...responseBody },
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(responseBody, {
includeEvents: false,
}),
// Same OPENAI_RESPONSES carve-out as providerPayload above and the
// passthrough branch's onComplete, but keyed on sourceFormat (what the
// client requested/receives in translate mode) rather than targetFormat
// (what the upstream provider speaks) -- translateResponse(targetFormat,
// sourceFormat, ...) above confirms that direction. emitTranslatedClientItem
// already pushes every client-visible translated item into
// clientPayloadCollector unconditionally, so the events are already there;
// this only fixes what gets built from them.
clientPayload: clientPayloadCollector.build(
sourceFormat === FORMATS.OPENAI_RESPONSES
? buildStreamSummaryFromEvents(
clientPayloadCollector.getEvents(),
sourceFormat,
model
)
: responseBody,
{ includeEvents: false }
),
});
} catch (e) {
console.debug(

View File

@@ -81,7 +81,7 @@ function inferFormatFromEvents(
if (normalizedFallback) return normalizedFallback;
for (const evt of events) {
const payload = asRecord(evt.data);
const payload = unwrapEventEnvelope(evt.data);
const eventType = toString(payload.type || evt.event);
if (eventType.startsWith("response.") || payload.object === "response") {
@@ -761,9 +761,27 @@ function createSummaryReducer(
}
}
// A pushed payload is either the bare provider/passthrough event (what
// providerPayloadCollector always receives), or a `{event, data}` SSE
// envelope (what emitTranslatedClientItem pushes for every translate-mode
// client item, since formatSSE needs the `event:` line name separate from
// the `data:` payload) -- unwrap the latter so every reducer's ingest() sees
// the real payload's own `.type`/`.choices`/etc. either way. Without this,
// a client-facing summary built from translate-mode events (clientPayload
// when sourceFormat is Responses/Claude/Gemini) never found a real `type`
// field, since it was always one level too shallow.
function unwrapEventEnvelope(payload: unknown): JsonRecord {
const record = asRecord(payload);
const inner = record.data;
if (typeof record.event === "string" && inner && typeof inner === "object") {
return asRecord(inner);
}
return record;
}
function buildOpenAISummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const reducer = createOpenAIReducer(fallbackModel);
for (const evt of events) reducer.ingest(asRecord(evt.data));
for (const evt of events) reducer.ingest(unwrapEventEnvelope(evt.data));
return reducer.finalize();
}
@@ -772,19 +790,19 @@ function buildResponsesSummary(
fallbackModel?: string | null
): unknown {
const reducer = createResponsesReducer(fallbackModel);
for (const evt of events) reducer.ingest(asRecord(evt.data));
for (const evt of events) reducer.ingest(unwrapEventEnvelope(evt.data));
return reducer.finalize();
}
function buildClaudeSummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const reducer = createClaudeReducer(fallbackModel);
for (const evt of events) reducer.ingest(asRecord(evt.data));
for (const evt of events) reducer.ingest(unwrapEventEnvelope(evt.data));
return reducer.finalize();
}
function buildGeminiSummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const reducer = createGeminiReducer(fallbackModel);
for (const evt of events) reducer.ingest(asRecord(evt.data));
for (const evt of events) reducer.ingest(unwrapEventEnvelope(evt.data));
return reducer.finalize();
}
@@ -854,7 +872,7 @@ export function createStructuredSSECollector(options: CollectorOptions = {}) {
if (payload === null || payload === undefined) return;
const clonedData = cloneLogPayload(payload);
reducer?.ingest(asRecord(clonedData));
reducer?.ingest(unwrapEventEnvelope(clonedData));
const event: StructuredSSEEvent = {
index: events.length + droppedEvents,

8
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.8.50",
"version": "3.8.51",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.8.50",
"version": "3.8.51",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -25590,7 +25590,7 @@
},
"node_modules/libxmljs2/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
@@ -38178,7 +38178,7 @@
},
"open-sse": {
"name": "@omniroute/open-sse",
"version": "3.8.50"
"version": "3.8.51"
},
"packages/browser-pool": {
"name": "@omniroute/browser-pool",

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.8.50",
"version": "3.8.51",
"description": "Unified AI router with 350 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
"type": "module",
"bin": {

View File

@@ -37,13 +37,23 @@ function resolveRootDir(rootDir) {
}
}
// Secrets this file may fill in when `.env.example` ships them blank.
//
// JWT_SECRET, API_KEY_SECRET and STORAGE_ENCRYPTION_KEY are deliberately NOT
// here: the server owns them. It restores each one from its durable store, or
// generates and persists it there on first use — STORAGE_ENCRYPTION_KEY in
// bin/omniroute.mjs (guarded by bin/cli/utils/storageKeyProvision.mjs), the
// other two in src/instrumentation-node.ts::ensureSecrets(), which persists to
// the `secrets` namespace of the database under DATA_DIR.
//
// Filling any of them here defeats that: this file lives inside the installed
// package, so `npm i -g` replaces it and postinstall writes a *different*
// value, while ensureSecrets() — which only acts on an empty variable — never
// gets to restore the real one. The secret then rotates silently on every
// update, invalidating dashboard sessions (JWT_SECRET) and API-key CRCs
// (API_KEY_SECRET). STORAGE_ENCRYPTION_KEY was pulled out first, for the same
// reason, when it cost users their encrypted credentials (issue #1622).
const CRYPTO_SECRETS = {
JWT_SECRET: () => randomBytes(64).toString("hex"),
API_KEY_SECRET: () => randomBytes(32).toString("hex"),
// STORAGE_ENCRYPTION_KEY: Generated at server startup instead of postinstall.
// Generated in bin/omniroute.mjs:ensureStorageEncryptionKey() and persisted to
// ~/.omniroute/.env to survive across upgrades. This prevents credential loss
// when upgrading OmniRoute (issue #1622).
MACHINE_ID_SALT: () => `omniroute-${randomBytes(8).toString("hex")}`,
};

View File

@@ -23,12 +23,18 @@ export async function OPTIONS() {
}
function cacheStatus(
cache: { version?: string; generatedAt?: string; tier: string; fetchedAt: string } | null
cache: { version?: string; generatedAt?: string | null; tier: string; fetchedAt: string } | null
) {
if (!cache) return { available: false };
return {
available: true,
version: cache.version ?? cache.generatedAt,
// Reported on its own where the cache carries it — folding the build date
// into `version` loses the distinction between when a feed was built and
// when this install downloaded it. Absent for the offers and intel caches,
// which store no build date: a null there would claim the date is unknown
// when in fact it was never kept.
...("generatedAt" in cache ? { generatedAt: cache.generatedAt ?? null } : {}),
tier: cache.tier,
fetchedAt: cache.fetchedAt,
};

View File

@@ -0,0 +1,12 @@
-- 163_radar_feed_cache_generated_at.sql
--
-- radar_feed_cache (migration 136) kept only fetched_at — when this install
-- downloaded the feed — while the feed itself carries generatedAt, the date
-- its data was built. Nothing downstream could tell a recent download from
-- recent data: a feed fetched minutes ago can carry weeks-old figures.
--
-- radar_referrals_cache (migration 142) already persists that date; this
-- brings the catalog cache in line. NULL on rows cached before this column
-- existed — the date is unknown, and stays unknown rather than being stood in
-- for by fetched_at.
ALTER TABLE radar_feed_cache ADD COLUMN generated_at TEXT DEFAULT NULL;

View File

@@ -38,6 +38,8 @@ import { encrypt, decrypt } from "./encryption";
export interface RadarCache {
version: string;
/** Date the feed's data was built, from the feed itself. Null when unknown. */
generatedAt: string | null;
tier: string;
payload: string;
signature: string;
@@ -114,8 +116,8 @@ export function getRadarCache(): RadarCache | null {
const db = getDbInstance();
const row = db
.prepare(
"SELECT version, tier, payload, signature, fetched_at AS fetchedAt " +
"FROM radar_feed_cache WHERE id = 1"
"SELECT version, generated_at AS generatedAt, tier, payload, signature, " +
"fetched_at AS fetchedAt FROM radar_feed_cache WHERE id = 1"
)
.get() as RadarCache | undefined;
@@ -128,6 +130,7 @@ export function getRadarCache(): RadarCache | null {
*/
export function setRadarCache(entry: {
version: string;
generatedAt?: string | null;
tier: string;
payload: string;
signature: string;
@@ -137,15 +140,23 @@ export function setRadarCache(entry: {
const fetchedAt = entry.fetchedAt ?? new Date().toISOString();
db.prepare(
`INSERT INTO radar_feed_cache (id, version, tier, payload, signature, fetched_at)
VALUES (1, ?, ?, ?, ?, ?)
`INSERT INTO radar_feed_cache (id, version, generated_at, tier, payload, signature, fetched_at)
VALUES (1, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
tier = excluded.tier,
payload = excluded.payload,
signature = excluded.signature,
fetched_at = excluded.fetched_at`
).run(entry.version, entry.tier, entry.payload, entry.signature, fetchedAt);
version = excluded.version,
generated_at = excluded.generated_at,
tier = excluded.tier,
payload = excluded.payload,
signature = excluded.signature,
fetched_at = excluded.fetched_at`
).run(
entry.version,
entry.generatedAt ?? null,
entry.tier,
entry.payload,
entry.signature,
fetchedAt
);
}
// ---------------------------------------------------------------------------

View File

@@ -64,11 +64,29 @@ export function resolvePreviousResponseState(
const { artifact, state } = readCallArtifact(row.artifact_relpath);
if (state !== "ready" || !artifact?.pipeline) return null;
const providerRequest = artifact.pipeline.providerRequest as { body?: unknown } | undefined;
const clientResponse = artifact.pipeline.clientResponse as { output?: unknown } | undefined;
const clientRawRequest = artifact.pipeline.clientRawRequest as { body?: unknown } | undefined;
const clientResponse = artifact.pipeline.clientResponse as
{ output?: unknown; summary?: { output?: unknown } } | undefined;
const input = isPlainRecord(providerRequest?.body) ? providerRequest.body.input : undefined;
const output = clientResponse?.output;
// clientRawRequest, not providerRequest: this store only ever fires for
// sourceFormat === OPENAI_RESPONSES (see chat.ts), so the client's own
// request is always Responses-API shaped and always carries `input`.
// providerRequest is upstream-shaped and only has `input` for a native
// passthrough Responses API upstream -- any translated upstream (e.g. Chat
// Completions `messages`) rewrites the wire body entirely, which made this
// unconditionally unresolvable for every translate-mode/auto-routed
// connection (previous_response_not_found on every attempt, regardless of
// whether the id was real and the artifact was otherwise 'ready').
const input = isPlainRecord(clientRawRequest?.body) ? clientRawRequest.body.input : undefined;
// A streaming clientResponse is clientPayloadCollector.build()'s output, which
// always nests the caller's summary under `.summary` (see
// createStructuredSSECollector in streamPayloadCollector.ts) -- a non-streaming
// one carries `output` directly. Same dual-shape concern as extractResponsesId
// in open-sse/handlers/chatCore/attemptLogging.ts, checked here independently
// since this reads back a stored artifact rather than the live object.
const output = Array.isArray(clientResponse?.output)
? clientResponse.output
: clientResponse?.summary?.output;
if (!Array.isArray(input) || !Array.isArray(output)) return null;
return { input, output };

View File

@@ -40,6 +40,12 @@ export interface RadarCatalogResult {
/** Feed metadata — null when falling back to baseline. */
meta: {
version: string;
/**
* Date the feed's data was built. Null for a cache row written before the
* column existed — unknown, never substituted by `fetchedAt`, which only
* says when this install downloaded it.
*/
generatedAt: string | null;
tier: string;
fetchedAt: string;
} | null;
@@ -48,7 +54,13 @@ export interface RadarCatalogResult {
/** Injectable deps for testing. */
export interface GetRadarCatalogDeps {
getFlag?: (key: string) => boolean;
getCache?: () => { version: string; tier: string; payload: string; fetchedAt: string } | null;
getCache?: () => {
version: string;
generatedAt?: string | null;
tier: string;
payload: string;
fetchedAt: string;
} | null;
baseline?: MergedEntry[];
localOverrides?: Map<string, Partial<MergedEntry>>;
tombstones?: Set<string>;
@@ -142,6 +154,7 @@ export function getRadarCatalog(deps: GetRadarCatalogDeps = {}): RadarCatalogRes
entries,
meta: {
version: cache.version,
generatedAt: cache.generatedAt ?? null,
tier: cache.tier,
fetchedAt: cache.fetchedAt,
},

View File

@@ -55,6 +55,8 @@ export type SyncStatus =
export interface RadarCacheEntry {
version: string;
/** Date the feed's data was built (`generatedAt`), as validated by the schema. */
generatedAt?: string | null;
tier: string;
payload: string;
signature: string;
@@ -313,6 +315,7 @@ export async function syncRadar(deps: SyncDeps = {}): Promise<SyncStatus> {
// Step 9: Cache the result
const cacheEntry: RadarCacheEntry = {
version: feed.version,
generatedAt: feed.generatedAt,
tier: servedTier,
payload: rawBytes.toString("utf-8"),
signature,

View File

@@ -53,61 +53,3 @@ export function resolveLiveWsPublicUrl(env: NodeJS.ProcessEnv = process.env): st
export function getLiveWsPath(): string {
return deriveLiveWsPath(resolveLiveWsPublicUrl() ?? undefined);
}
/** A port the handshake may report, or null when it is not usable. */
export function sanitizeLiveWsPort(port: unknown): number | null {
const value = typeof port === "string" ? Number(port) : port;
if (typeof value !== "number" || !Number.isInteger(value)) return null;
return value > 0 && value < 65536 ? value : null;
}
export interface LiveWsUrlParts {
/** Explicit `wsUrl` passed by the caller - always wins. */
explicit?: string | null;
/** `live.publicUrl` from the handshake - a complete URL, used as-is. */
handshakeUrl?: string | null;
/** `live.port` from the handshake, i.e. the running LIVE_WS_PORT. */
handshakePort?: number | null;
/** `live.path` from the handshake. */
handshakePath?: string | null;
/** The compiled-in default, used for everything the handshake does not say. */
defaultUrl: string;
}
/**
* Resolve the live dashboard WebSocket URL.
*
* The handshake reports the port the live server is actually listening on, but
* the client read only `publicUrl` and `path` from it. An operator who moved
* the server with `LIVE_WS_PORT` still got the compiled-in 20132, and the
* dashboard sat on "Live disabled - WebSocket disconnected" with no way to
* correct it short of rebuilding the image (#11331).
*
* Precedence: an explicit `wsUrl` wins, then a complete `publicUrl` from the
* handshake, then the default URL with whatever port and path the handshake
* reported applied to it.
*/
export function resolveLiveWsUrl({
explicit,
handshakeUrl,
handshakePort,
handshakePath,
defaultUrl,
}: LiveWsUrlParts): string {
if (explicit) return explicit;
if (handshakeUrl) return handshakeUrl;
const port = sanitizeLiveWsPort(handshakePort);
const path =
typeof handshakePath === "string" && handshakePath.startsWith("/") ? handshakePath : null;
if (port === null && path === null) return defaultUrl;
try {
const url = new URL(defaultUrl);
if (port !== null) url.port = String(port);
if (path !== null) url.pathname = path;
return url.toString();
} catch {
return defaultUrl;
}
}

View File

@@ -325,8 +325,6 @@
"tests/unit/repro-9486.test.ts",
"tests/unit/repro-9630-combo-false-503.test.ts",
"tests/unit/repro-antigravity-404-family-cooldown-hijack.test.ts",
"tests/unit/repro-combo-persisted-cooldown-preskip.test.ts",
"tests/unit/repro-glm-iso-reset-24h-cap.test.ts",
"tests/unit/resilience-connections.test.ts",
"tests/unit/responses-handler.test.ts",
"tests/unit/responses-passthrough-openai-compatible.test.ts",

View File

@@ -0,0 +1,52 @@
/**
* extractResponsesId is the write-side half of previous_response_id
* continuation (src/lib/db/responsesContinuationStore.ts is the read-side
* half): it decides what gets indexed in call_logs.response_id. See
* responses-continuation-passthrough-client-payload.test.ts and
* responses-continuation-store.test.ts for the fuller bug writeup this
* fixes -- this file covers the id-extraction half in isolation.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { extractResponsesId } from "../../open-sse/handlers/chatCore/attemptLogging.ts";
const RESPONSES = "openai-responses";
test("extractResponsesId reads a direct id (non-streaming clientResponse)", () => {
assert.equal(extractResponsesId(RESPONSES, { id: "resp_123" }), "resp_123");
});
test("extractResponsesId reads a wrapped id (streaming clientResponse via clientPayloadCollector.build())", () => {
assert.equal(
extractResponsesId(RESPONSES, { _streamed: true, summary: { id: "resp_456" } }),
"resp_456"
);
});
test("extractResponsesId prefers a direct id over a wrapped one when both are present", () => {
assert.equal(
extractResponsesId(RESPONSES, { id: "resp_direct", summary: { id: "resp_wrapped" } }),
"resp_direct"
);
});
test("extractResponsesId returns null when sourceFormat is not openai-responses (never mistake a chatcmpl-* id)", () => {
assert.equal(extractResponsesId("openai", { id: "chatcmpl-abc" }), null);
assert.equal(extractResponsesId(undefined, { id: "resp_123" }), null);
});
test("extractResponsesId returns null for a missing/empty/non-string id in either shape", () => {
assert.equal(extractResponsesId(RESPONSES, {}), null);
assert.equal(extractResponsesId(RESPONSES, { id: "" }), null);
assert.equal(extractResponsesId(RESPONSES, { id: 123 }), null);
assert.equal(extractResponsesId(RESPONSES, { summary: {} }), null);
assert.equal(extractResponsesId(RESPONSES, { summary: { id: "" } }), null);
assert.equal(extractResponsesId(RESPONSES, { summary: null }), null);
});
test("extractResponsesId returns null for a non-object or nullish clientResponse", () => {
assert.equal(extractResponsesId(RESPONSES, null), null);
assert.equal(extractResponsesId(RESPONSES, undefined), null);
assert.equal(extractResponsesId(RESPONSES, "resp_123"), null);
});

View File

@@ -0,0 +1,82 @@
/**
* The CLI announces every .env it loads. One of those locations is the
* installed package directory, which `npm i -g` replaces wholesale — so the
* file an operator edits there is gone at the next update, without a word.
*
* describeVolatileEnvWarning() decides when to say so. It must stay silent for
* a development checkout, where that same path is stable and documented, and
* for a file whose keys were all shadowed by a durable one — it supplied
* nothing, so losing it costs nothing.
*/
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { describeVolatileEnvWarning } from "../../bin/cli/utils/volatileEnvPath.mjs";
const INSTALLED_ROOT = path.join("/usr", "lib", "node_modules", "omniroute");
const CHECKOUT_ROOT = path.join("/home", "dev", "OmniRoute");
const DURABLE = path.join("/home", "dev", ".omniroute", ".env");
test("an installed package .env that supplied keys is reported as volatile", () => {
const message = describeVolatileEnvWarning({
envPath: path.join(INSTALLED_ROOT, ".env"),
packageRoot: INSTALLED_ROOT,
durableEnvPath: DURABLE,
suppliedKeys: true,
});
assert.ok(message, "an installed package .env must be reported");
assert.match(message, /update/i, "the message must say what destroys the file");
assert.ok(message.includes(DURABLE), "the message must name the durable path to move to");
});
test("a development checkout says nothing", () => {
// Same file name, stable location: `npm install` in a checkout preserves it,
// and SETUP_GUIDE.md documents it. Warning here would fire on every start.
assert.equal(
describeVolatileEnvWarning({
envPath: path.join(CHECKOUT_ROOT, ".env"),
packageRoot: CHECKOUT_ROOT,
durableEnvPath: DURABLE,
suppliedKeys: true,
}),
null
);
});
test("a file that supplied no key says nothing", () => {
assert.equal(
describeVolatileEnvWarning({
envPath: path.join(INSTALLED_ROOT, ".env"),
packageRoot: INSTALLED_ROOT,
durableEnvPath: DURABLE,
suppliedKeys: false,
}),
null
);
});
test("the durable file itself says nothing, wherever it sits", () => {
assert.equal(
describeVolatileEnvWarning({
envPath: DURABLE,
packageRoot: INSTALLED_ROOT,
durableEnvPath: DURABLE,
suppliedKeys: true,
}),
null
);
});
test("a path outside the package root says nothing", () => {
assert.equal(
describeVolatileEnvWarning({
envPath: path.join("/srv", "app", ".env"),
packageRoot: INSTALLED_ROOT,
durableEnvPath: DURABLE,
suppliedKeys: true,
}),
null
);
});

View File

@@ -0,0 +1,80 @@
/**
* Tests for migration 163 — radar_feed_cache.generated_at.
*
* Verifies:
* - the column exists once after the migration runs (fresh database)
* - a row written the way the previous schema wrote it — no build date at all —
* reads back as null rather than borrowing the fetch time
* - the rest of that row survives the upgrade untouched
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-migration-163-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.NODE_ENV = "test";
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
const core = await import("../../../src/lib/db/core.ts");
const radarDb = await import("../../../src/lib/db/radar.ts");
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(() => {
resetDb();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("migration 163 — radar_feed_cache carries generated_at exactly once", () => {
const db = core.getDbInstance();
const columns = (
db.prepare("PRAGMA table_info(radar_feed_cache)").all() as Array<{ name: string }>
).map((c) => c.name);
assert.equal(
columns.filter((c) => c === "generated_at").length,
1,
"generated_at must be added once, whatever the number of migration runs"
);
});
test("migration 163 — a row from the previous schema keeps its data and reads no build date", () => {
const db = core.getDbInstance();
// Exactly the INSERT the previous schema could write: no generated_at column.
db.prepare(
`INSERT INTO radar_feed_cache (id, version, tier, payload, signature, fetched_at)
VALUES (1, ?, ?, ?, ?, ?)`
).run(
"2026.08.02.1",
"community",
'{"feed":"omniroute-radar"}',
"sig",
"2026-08-24T07:00:00.000Z"
);
const cache = radarDb.getRadarCache();
assert.ok(cache);
assert.equal(
cache.generatedAt,
null,
"an upgraded row has no build date, and must not invent one"
);
assert.equal(cache.version, "2026.08.02.1", "the pre-migration data must survive untouched");
assert.equal(cache.tier, "community");
assert.equal(cache.fetchedAt, "2026-08-24T07:00:00.000Z");
});

View File

@@ -0,0 +1,216 @@
/**
* tests/unit/radar-feed-cache-generated-at.test.ts
*
* The catalog feed carries the date its data was built (`generatedAt`, required
* by the feed schema). Until now the cache kept only `fetched_at` — when this
* install downloaded it — so nothing downstream could tell a recent download
* from recent data. The referrals cache (migration 142) already persists it;
* this file is the guard that the catalog cache does too, all the way out to
* `getRadarCatalog()` and `GET /api/radar/status`.
*
* A cache row written before the migration has no data date. It must read back
* as null — never the fetch time standing in for it, which is the exact
* confusion this column exists to end.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { SignJWT } from "jose";
// Ephemeral signing key, injected before any Radar module loads so the sync
// path verifies against it (the fork override documented in RADAR.md).
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
process.env.RADAR_FEED_PUBKEY = publicKey
.export({ type: "spki", format: "der" })
.toString("base64");
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-generated-at-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-radar-genat-tests-32b";
process.env.JWT_SECRET = "test-jwt-secret-for-radar-genat-tests";
process.env.INITIAL_PASSWORD = "test-bootstrap-password-for-radar-genat-tests";
process.env.RADAR_ENABLED = "true";
const core = await import("../../src/lib/db/core.ts");
const radarDb = await import("../../src/lib/db/radar.ts");
const { getRadarCatalog } = await import("../../src/lib/radar/index.ts");
const FIXTURE = JSON.parse(
fs.readFileSync(
path.resolve(import.meta.dirname!, "../fixtures/radar-feed-canonical.json"),
"utf8"
)
) as { generatedAt: string; version: string };
const FETCHED_AT = "2026-08-24T07:00:00.000Z";
async function authCookieHeader(): Promise<string> {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(secret);
return `auth_token=${token}`;
}
function seed(entry: Partial<Parameters<typeof radarDb.setRadarCache>[0]> = {}): void {
radarDb.setRadarCache({
version: FIXTURE.version,
generatedAt: FIXTURE.generatedAt,
tier: "community",
payload: JSON.stringify(FIXTURE),
signature: "test-signature-not-verified-on-read",
fetchedAt: FETCHED_AT,
...entry,
});
}
test("the catalog cache persists the feed's own build date", () => {
seed();
const cache = radarDb.getRadarCache();
assert.ok(cache);
assert.equal(cache.generatedAt, FIXTURE.generatedAt);
assert.equal(cache.fetchedAt, FETCHED_AT);
assert.notEqual(
cache.generatedAt,
cache.fetchedAt,
"the data date and the download date are two different facts"
);
});
test("a row cached before this column existed reads back as an unknown date", () => {
seed({ generatedAt: undefined });
const cache = radarDb.getRadarCache();
assert.ok(cache);
assert.equal(cache.generatedAt, null, "unknown must stay unknown, never the fetch time");
assert.equal(cache.fetchedAt, FETCHED_AT);
});
test("syncRadar writes the build date it just validated", async () => {
const syncMod = await import("../../src/lib/radar/sync.ts");
const bytes = Buffer.from(JSON.stringify(FIXTURE), "utf8");
const signature = crypto.sign(null, bytes, privateKey).toString("base64");
const written: Array<{ generatedAt?: string | null }> = [];
const result = await syncMod.syncRadar({
getFlag: () => true,
getSettings: () => ({ optIn: true, supporterKey: null }),
getCache: () => null,
setCache: (entry) => {
written.push(entry);
},
fetch: (() =>
Promise.resolve(
new Response(bytes, {
status: 200,
headers: {
"x-omniroute-feed-signature": signature,
"x-omniroute-feed-tier": "community",
},
})
)) as unknown as typeof globalThis.fetch,
});
assert.equal(result.status, "updated");
assert.equal(written.length, 1, "a valid feed must be cached");
assert.equal(written[0].generatedAt, FIXTURE.generatedAt);
});
test("getRadarCatalog reports the build date alongside the fetch date", () => {
seed();
const { meta } = getRadarCatalog();
assert.ok(meta, "an active feed must expose its metadata");
assert.equal(meta.generatedAt, FIXTURE.generatedAt);
assert.equal(meta.fetchedAt, FETCHED_AT);
});
test("GET /api/radar/status reports the build date as its own field", async () => {
seed();
const { GET } = await import("../../src/app/api/radar/status/route.ts");
const res = await GET(
new Request("http://localhost:20128/api/radar/status", {
headers: { cookie: await authCookieHeader() },
})
);
assert.equal(res.status, 200);
const body = (await res.json()) as {
feeds: { catalog: { version?: string; generatedAt?: string | null; fetchedAt: string } };
};
assert.equal(body.feeds.catalog.generatedAt, FIXTURE.generatedAt);
assert.equal(
body.feeds.catalog.version,
FIXTURE.version,
"the build date must not be folded into the version field"
);
});
test("status omits the build date for the caches that never store one", async () => {
seed();
// Both must be present in the response, otherwise the assertion below would
// pass on an `{ available: false }` stub that carries no field either.
radarDb.setRadarOffersCache({
version: "2026.08.24.1",
tier: "live",
payload: JSON.stringify({ offers: [] }),
signature: "test-signature",
fetchedAt: FETCHED_AT,
});
radarDb.setRadarIntelCache({
version: "2026.08.24.1",
tier: "live",
payload: JSON.stringify({ intel: {} }),
signature: "test-signature",
supporterIdentity: "test-identity",
fetchedAt: FETCHED_AT,
});
const { GET } = await import("../../src/app/api/radar/status/route.ts");
const res = await GET(
new Request("http://localhost:20128/api/radar/status", {
headers: { cookie: await authCookieHeader() },
})
);
const body = (await res.json()) as {
feeds: Record<string, Record<string, unknown>>;
};
// offers and intel are cached without a build date. Reporting null there
// would say "unknown", when the truth is that it was never kept.
for (const feed of ["offers", "intel"]) {
assert.equal(
body.feeds[feed].available,
true,
`${feed} must be cached for this to mean anything`
);
assert.equal(
"generatedAt" in body.feeds[feed],
false,
`${feed} must not advertise a build date it never stores`
);
}
assert.equal(body.feeds.catalog.generatedAt, FIXTURE.generatedAt);
});
test.after(() => {
core.resetDbInstance();
delete process.env.RADAR_ENABLED;
delete process.env.INITIAL_PASSWORD;
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
} catch {
// ignore
}
});

View File

@@ -0,0 +1,147 @@
/**
* Regression test for the "previous_response_id continuation never engages
* through a passthrough Responses-API connection" bug.
*
* Root cause (three independent gaps, all in the client-facing path):
*
* 1. Passthrough mode's per-event loop only pushed each raw SSE event into
* providerPayloadCollector, never clientPayloadCollector -- so for a
* plain-text Responses-API reply (no tool calls, no textual-tool-call
* conversion), clientPayloadCollector.getEvents() was always empty.
* 2. onComplete's `clientPayload` was unconditionally built from a
* synthesized chat-completions-shaped `responseBody` ({choices: [...]}),
* even for a Responses-API client -- so it never carried a real `id` or
* Responses-shaped `output`, unlike the sibling `providerPayload` builder
* right next to it (which already had the OPENAI_RESPONSES carve-out).
* 3. clientPayloadCollector.build()'s returned object always nests the
* caller-supplied summary under `.summary` (see createStructuredSSECollector
* in streamPayloadCollector.ts) -- extractResponsesId in
* chatCore/attemptLogging.ts and resolvePreviousResponseState in
* src/lib/db/responsesContinuationStore.ts both read `.id`/`.output`
* directly, so even a correctly-populated events list produced a
* clientResponse whose id/output were invisible to them.
*
* Net effect: `call_logs.response_id` was NEVER populated for a passthrough
* Responses-API reply, so every `previous_response_id` continuation attempt
* against such a connection failed with a bare HTTP 400
* ("previous_response_not_found") -- silently, since openclaw-style clients
* recover by resending full history, so nothing user-visible looked broken.
*
* This test exercises only gap #1 and #2 (the stream.ts side) via the real
* createSSEStream() transform, the same harness used by
* responses-commentary-passthrough-6199.test.ts. Gap #3's two read-side fixes
* are covered directly in responses-continuation-store.test.ts (the
* `.summary.output` fallback) and would need their own extractResponsesId
* unit coverage if that function is exported for testing.
*/
import test from "node:test";
import assert from "node:assert/strict";
const { createSSEStream } = await import("../../open-sse/utils/stream.ts");
const textEncoder = new TextEncoder();
type OnCompletePayload = {
status: number;
clientPayload?: unknown;
providerPayload?: unknown;
};
async function runPassthrough(
chunks: string[]
): Promise<{ output: string; onCompletePayload: OnCompletePayload | undefined }> {
let onCompletePayload: OnCompletePayload | undefined;
const source = new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(textEncoder.encode(chunk));
}
controller.close();
},
});
const output = await new Response(
source.pipeThrough(
createSSEStream({
mode: "passthrough",
provider: "openai-compatible",
clientResponseFormat: "openai-responses",
sourceFormat: "openai-responses",
model: "mock-model",
onComplete: (payload: OnCompletePayload) => {
onCompletePayload = payload;
},
})
)
).text();
return { output, onCompletePayload };
}
function sse(event: object): string {
return `data: ${JSON.stringify(event)}\n\n`;
}
test("passthrough onComplete's clientPayload carries a real Responses id + output for a plain-text reply", async () => {
// The minimal shape a real upstream (or a scripted test double) sends for a
// plain-text reply: a single terminal response.completed frame, no
// response.created/output_item.added lifecycle events first -- this is
// exactly what tripped the bug, since it never touched the textual-tool-call
// conversion path that happened to already push into clientPayloadCollector.
const { onCompletePayload } = await runPassthrough([
sse({
type: "response.completed",
response: {
id: "resp_plain_text_1",
status: "completed",
output: [
{
id: "msg_resp_plain_text_1",
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "hello there", annotations: [] }],
},
],
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
},
}),
]);
assert.ok(onCompletePayload, "onComplete must fire");
const clientPayload = onCompletePayload!.clientPayload as
| { id?: unknown; summary?: { id?: unknown; output?: unknown } }
| undefined;
assert.ok(clientPayload, "clientPayload must be present");
// clientPayloadCollector.build() nests the summary; accept either shape so
// this test survives a future change to the wrapping, but the id/output
// MUST be findable one way or the other -- that's the actual contract
// extractResponsesId / resolvePreviousResponseState depend on.
const id = clientPayload!.id ?? clientPayload!.summary?.id;
const output = clientPayload!.summary?.output;
assert.equal(id, "resp_plain_text_1", "the real Responses id must survive into clientPayload");
assert.ok(Array.isArray(output) && output.length === 1, "the real output array must survive too");
});
test("passthrough forwards the plain-text reply to the client unchanged (no regression)", async () => {
const { output } = await runPassthrough([
sse({
type: "response.completed",
response: {
id: "resp_plain_text_2",
status: "completed",
output: [
{
id: "msg_resp_plain_text_2",
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "hello again", annotations: [] }],
},
],
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
},
}),
]);
assert.ok(output.includes("hello again"), "the client-visible SSE stream must still carry the reply");
assert.ok(output.includes("resp_plain_text_2"), "the client-visible response id must be unchanged");
});

View File

@@ -78,6 +78,7 @@ test("resolvePreviousResponseState reconstructs input/output from the call-log a
artifactRelPath: "2026-01-01/log-1.json",
});
writeArtifact("2026-01-01/log-1.json", {
clientRawRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } },
providerRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } },
clientResponse: {
id: "resp_abc",
@@ -92,6 +93,44 @@ test("resolvePreviousResponseState reconstructs input/output from the call-log a
});
});
test("resolvePreviousResponseState reads output from a wrapped (streaming) clientResponse shape", () => {
// A streaming reply's clientResponse is clientPayloadCollector.build()'s output,
// which always nests the caller-supplied summary under `.summary` (see
// createStructuredSSECollector in streamPayloadCollector.ts) rather than
// carrying `output` at the top level like a non-streaming reply does. This
// must resolve exactly like the unwrapped shape above -- it was the actual
// cause of previous_response_id continuation always failing for a streaming
// Responses-API passthrough connection (fixed alongside the clientPayload
// builder gap in open-sse/utils/stream.ts).
insertCallLog({
id: "log-1-streamed",
responseId: "resp_streamed",
apiKeyId: "key-1",
detailState: "ready",
artifactRelPath: "2026-01-01/log-1-streamed.json",
});
writeArtifact("2026-01-01/log-1-streamed.json", {
clientRawRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } },
providerRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } },
clientResponse: {
_streamed: true,
_format: "sse-json",
_eventCount: 1,
summary: {
id: "resp_streamed",
object: "response",
output: [{ type: "message", role: "assistant", content: "hello" }],
},
},
});
const result = store.resolvePreviousResponseState("resp_streamed", "key-1");
assert.deepEqual(result, {
input: [{ type: "message", role: "user", content: "hi" }],
output: [{ type: "message", role: "assistant", content: "hello" }],
});
});
test("resolvePreviousResponseState returns null for an unknown response id", () => {
const result = store.resolvePreviousResponseState("resp_does_not_exist", "key-1");
assert.equal(result, null);
@@ -106,6 +145,7 @@ test("resolvePreviousResponseState never crosses tenants (scoped by api_key_id)"
artifactRelPath: "2026-01-01/log-2.json",
});
writeArtifact("2026-01-01/log-2.json", {
clientRawRequest: { body: { input: [{ role: "user", content: "secret" }] } },
providerRequest: { body: { input: [{ role: "user", content: "secret" }] } },
clientResponse: { id: "resp_tenant_a", output: [{ role: "assistant", content: "reply" }] },
});
@@ -139,13 +179,50 @@ test("resolvePreviousResponseState fails closed when the pipeline payload was si
// an object -- resolvePreviousResponseState must never try to reconstruct
// from it and silently drop history.
writeArtifact("2026-01-01/log-4.json", {
providerRequest: { body: "[omitted: call log artifact size limit exceeded]" },
clientRawRequest: { body: "[omitted: call log artifact size limit exceeded]" },
clientResponse: { id: "resp_omitted", output: [] },
});
assert.equal(store.resolvePreviousResponseState("resp_omitted", "key-1"), null);
});
test("resolvePreviousResponseState resolves input from clientRawRequest when providerRequest was translated to a different upstream wire shape", () => {
// Real shape from a live auto-routed free-tier connection: OmniRoute
// translates the client's Responses-API request into Chat Completions
// (`messages`, no `input` at all) before forwarding upstream. Reading
// `input` from providerRequest.body made this permanently unresolvable --
// previous_response_not_found on every attempt -- for any connection where
// the selected upstream isn't itself a native Responses-API passthrough.
// The client's own request is always Responses-API shaped (this store only
// fires for sourceFormat === OPENAI_RESPONSES, see chat.ts), so
// clientRawRequest is the correct source regardless of upstream shape.
insertCallLog({
id: "log-6",
responseId: "resp_gen-translate-mode",
apiKeyId: "key-1",
detailState: "ready",
artifactRelPath: "2026-01-01/log-6.json",
});
writeArtifact("2026-01-01/log-6.json", {
clientRawRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } },
providerRequest: {
body: { model: "laguna-s-2.1-free", messages: [{ role: "user", content: "hi" }] },
},
clientResponse: {
summary: {
id: "resp_gen-translate-mode",
output: [{ type: "message", role: "assistant", content: "hello" }],
},
},
});
const result = store.resolvePreviousResponseState("resp_gen-translate-mode", "key-1");
assert.deepEqual(result, {
input: [{ type: "message", role: "user", content: "hi" }],
output: [{ type: "message", role: "assistant", content: "hello" }],
});
});
test("resolvePreviousResponseState returns null when detail logging was never captured for this row", () => {
insertCallLog({
id: "log-5",

View File

@@ -0,0 +1,125 @@
/**
* Regression test for the "previous_response_id continuation never engages
* for a real Ping-style default-combo request" gap -- the translate-mode
* sibling of responses-continuation-passthrough-client-payload.test.ts.
*
* Verified against real production traffic (2026-08-21): every "default"
* combo request sampled from Ping's live gateway had sourceFormat
* "openai-responses" / targetFormat "openai" -- i.e. translate mode, not
* passthrough, because the pooled combo's actual upstreams (OpenRouter,
* Mistral, Gemini, NVIDIA, ...) are chat-completions-native, not
* Responses-API-native. The passthrough fix alone does not help this path.
*
* Unlike passthrough, translate mode's emitTranslatedClientItem() (the sole
* place a translated, client-visible item is ever sent) already pushes
* every item into clientPayloadCollector unconditionally -- so gap #1 from
* the passthrough bug (missing collection) does not apply here. Only gap #2
* applied: onComplete's clientPayload was still built from the synthesized
* chat-completions-shaped responseBody regardless of what the client
* actually requested, exactly like the passthrough sibling before its fix.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { FORMATS } from "../../open-sse/translator/formats.ts";
const { createSSEStream } = await import("../../open-sse/utils/stream.ts");
const textEncoder = new TextEncoder();
type OnCompletePayload = {
status: number;
clientPayload?: unknown;
providerPayload?: unknown;
};
async function runTranslate(
chunks: string[]
): Promise<{ output: string; onCompletePayload: OnCompletePayload | undefined }> {
let onCompletePayload: OnCompletePayload | undefined;
const source = new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(textEncoder.encode(chunk));
}
controller.close();
},
});
const output = await new Response(
source.pipeThrough(
createSSEStream({
mode: "translate",
// Matches real production traffic exactly: a chat-completions-native
// upstream (targetFormat) translated into Responses shape for a
// Responses-API client (sourceFormat).
targetFormat: FORMATS.OPENAI,
sourceFormat: FORMATS.OPENAI_RESPONSES,
provider: "openrouter",
model: "nemotron-3-ultra-free",
body: { input: [{ type: "message", role: "user", content: "hi" }] },
onComplete: (payload: OnCompletePayload) => {
onCompletePayload = payload;
},
})
)
).text();
return { output, onCompletePayload };
}
function chatCompletionsChunk(delta: Record<string, unknown>, finishReason: string | null = null) {
return `data: ${JSON.stringify({
id: "chatcmpl-real-provider-id",
object: "chat.completion.chunk",
choices: [{ index: 0, delta, finish_reason: finishReason }],
})}\n\n`;
}
test("translate mode's onComplete.clientPayload carries a real Responses id + output for a plain-text reply", async () => {
const { onCompletePayload } = await runTranslate([
chatCompletionsChunk({ role: "assistant", content: "" }),
chatCompletionsChunk({ content: "hello there" }),
chatCompletionsChunk({}, "stop"),
`data: ${JSON.stringify({
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
})}\n\n`,
"data: [DONE]\n\n",
]);
assert.ok(onCompletePayload, "onComplete must fire");
const clientPayload = onCompletePayload!.clientPayload as
| { id?: unknown; summary?: { id?: unknown; output?: unknown } }
| undefined;
assert.ok(clientPayload, "clientPayload must be present");
const id = clientPayload!.id ?? clientPayload!.summary?.id;
const output = clientPayload!.summary?.output;
assert.ok(
typeof id === "string" && id.length > 0,
"a real Responses id must survive into clientPayload, not be missing"
);
assert.ok(
Array.isArray(output) && output.length > 0,
"a real output array must survive into clientPayload"
);
});
test("translate mode still forwards the translated reply to the client unchanged (no regression)", async () => {
const { output } = await runTranslate([
chatCompletionsChunk({ role: "assistant", content: "" }),
chatCompletionsChunk({ content: "hello again" }),
chatCompletionsChunk({}, "stop"),
`data: ${JSON.stringify({
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
})}\n\n`,
"data: [DONE]\n\n",
]);
assert.ok(
output.includes("hello again"),
"the client-visible translated Responses SSE stream must still carry the reply"
);
assert.match(output, /response\.completed/, "a terminal Responses event must still be emitted");
});

View File

@@ -41,11 +41,13 @@ describe("S2 — agent-card topology sanitisation", () => {
assert.equal(res.status, 200);
const card = (await res.json()) as { url?: string; supportedInterfaces?: { url?: string }[] };
assert.ok(card.url, "card must have a url");
assert.ok(card.url.startsWith("https://gateway.example.com"), `expected gateway.example.com, got ${card.url}`);
assert.equal(new URL(card.url).origin, "https://gateway.example.com", `expected gateway.example.com origin, got ${card.url}`);
if (card.supportedInterfaces && card.supportedInterfaces.length > 0) {
assert.ok(
card.supportedInterfaces[0].url?.startsWith("https://gateway.example.com"),
`interface URL should use dynamic origin, got ${card.supportedInterfaces[0].url}`
const ifaceUrl = card.supportedInterfaces[0].url;
assert.equal(
ifaceUrl ? new URL(ifaceUrl).origin : undefined,
"https://gateway.example.com",
`interface URL should use dynamic origin, got ${ifaceUrl}`
);
}
});
@@ -62,7 +64,8 @@ describe("S2 — agent-card topology sanitisation", () => {
const res = await mod.GET(request);
assert.equal(res.status, 200);
const card = (await res.json()) as { url?: string };
assert.ok(card.url?.startsWith("https://custom.example.com"), `expected custom.example.com, got ${card.url}`);
assert.ok(card.url, "card must have a url");
assert.equal(new URL(card.url).origin, "https://custom.example.com", `expected custom.example.com origin, got ${card.url}`);
});
it("agent.json derives URL from request.nextUrl.origin when OMNIROUTE_BASE_URL is unset", async () => {
@@ -76,7 +79,8 @@ describe("S2 — agent-card topology sanitisation", () => {
const res = await mod.GET(request);
assert.equal(res.status, 200);
const card = (await res.json()) as { url?: string };
assert.ok(card.url?.startsWith("https://gateway.example.com"), `expected gateway.example.com, got ${card.url}`);
assert.ok(card.url, "card must have a url");
assert.equal(new URL(card.url).origin, "https://gateway.example.com", `expected gateway.example.com origin, got ${card.url}`);
});
});

View File

@@ -444,3 +444,69 @@ test("splitConcatenatedToolCallArguments — top-level array is single value", (
const out = splitConcatenatedToolCallArguments(arr);
assert.equal(out, null); // one value boundary (array) -> not split
});
// Continuation gap (2026-08-21): emitTranslatedClientItem in stream.ts pushes
// every translate-mode client-visible item wrapped as `{event, data}` (needed
// so formatSSE can emit both the SSE `event:` line and the `data:` payload
// separately) -- but every reducer's ingest() read `payload.type` directly,
// one level too shallow for that shape, so a client-facing summary built
// from translate-mode events (e.g. clientPayload when the client speaks
// Responses API) never found a real response id/output. Only affected
// clientPayloadCollector in translate mode; providerPayloadCollector and
// passthrough mode always pushed the bare payload directly.
test("buildStreamSummaryFromEvents unwraps a translate-mode {event, data} envelope", () => {
const events = [
{
data: {
event: "response.completed",
data: {
type: "response.completed",
response: {
id: "resp_wrapped_1",
output: [{ type: "message", role: "assistant", content: "hi" }],
},
},
},
event: "response.completed",
},
];
const result = collector.buildStreamSummaryFromEvents(events, "openai-responses") as {
id?: unknown;
output?: unknown;
};
assert.equal(result?.id, "resp_wrapped_1", "must read the id from one level deeper, not undefined");
assert.ok(Array.isArray(result?.output) && result.output.length === 1);
});
test("buildStreamSummaryFromEvents still reads a bare (unwrapped) event correctly", () => {
const events = [
{
data: {
type: "response.completed",
response: {
id: "resp_bare_1",
output: [{ type: "message", role: "assistant", content: "hi" }],
},
},
},
];
const result = collector.buildStreamSummaryFromEvents(events, "openai-responses") as {
id?: unknown;
output?: unknown;
};
assert.equal(result?.id, "resp_bare_1");
assert.ok(Array.isArray(result?.output) && result.output.length === 1);
});
test("createStructuredSSECollector's live getSummary() also unwraps a pushed {event, data} envelope", () => {
const c = collector.createStructuredSSECollector({ format: "openai-responses" });
c.push({
event: "response.completed",
data: {
type: "response.completed",
response: { id: "resp_wrapped_live", output: [] },
},
});
const summary = c.getSummary() as { id?: unknown };
assert.equal(summary?.id, "resp_wrapped_live");
});

View File

@@ -1053,7 +1053,11 @@ Arguments: {"command":"systemctl status omniroute"}`;
assert.doesNotMatch(text, /Arguments:/);
assert.match(text, /response.output_item.added/);
assert.match(text, /response.function_call_arguments.done/);
assert.equal(onCompletePayload.clientPayload._eventCount, 5);
// 5 synthesized function-call events (from the textual tool-call conversion)
// + 1 for the terminal response.completed itself, now also pushed so
// previous_response_id continuation can recover a real id/output for a
// passthrough Responses-API reply (see responsesContinuationStore.ts).
assert.equal(onCompletePayload.clientPayload._eventCount, 6);
assert.equal(onCompletePayload.responseBody.choices[0].finish_reason, "tool_calls");
assert.equal(onCompletePayload.responseBody.choices[0].message.content, null);
assert.equal(

View File

@@ -52,7 +52,7 @@ function writeOauthEnvExample(rootDir: string) {
);
}
test("syncEnv creates .env from .env.example and generates install-time secrets", () => {
test("syncEnv creates .env from .env.example and leaves runtime-owned secrets blank", () => {
const rootDir = createTempRoot();
// Temporarily override DATA_DIR so the encrypted-credentials guard doesn't
@@ -66,8 +66,13 @@ test("syncEnv creates .env from .env.example and generates install-time secrets"
const envContent = fs.readFileSync(path.join(rootDir, ".env"), "utf8");
assert.deepEqual(result, { created: true, added: 7 });
assert.match(envContent, /^JWT_SECRET=.{32,}$/m);
assert.match(envContent, /^API_KEY_SECRET=.{32,}$/m);
// The three secrets the server provisions itself stay blank here. Filling
// them in the package directory hides ensureSecrets() (instrumentation-node),
// which restores them from the durable store or generates and persists them
// there — so a pre-filled value is silently replaced by a new one on every
// reinstall. STORAGE_ENCRYPTION_KEY was pulled out for that reason (#1622).
assert.match(envContent, /^JWT_SECRET=$/m);
assert.match(envContent, /^API_KEY_SECRET=$/m);
assert.match(envContent, /^STORAGE_ENCRYPTION_KEY=$/m);
assert.match(envContent, /^MACHINE_ID_SALT=omniroute-/m);
assert.match(envContent, /^CLAUDE_OAUTH_CLIENT_ID=claude-default$/m);
@@ -103,7 +108,7 @@ test("syncEnv appends only missing keys and preserves existing values", () => {
assert.deepEqual(result, { created: false, added: 5 });
assert.match(envContent, /^JWT_SECRET=my-custom-secret-that-should-stay$/m);
assert.match(envContent, /^CLAUDE_OAUTH_CLIENT_ID=custom-claude$/m);
assert.match(envContent, /^API_KEY_SECRET=.{32,}$/m);
assert.match(envContent, /^API_KEY_SECRET=$/m);
assert.match(envContent, /^STORAGE_ENCRYPTION_KEY=$/m);
assert.match(envContent, /^MACHINE_ID_SALT=omniroute-/m);
assert.match(envContent, /^CODEX_OAUTH_CLIENT_ID=codex-default$/m);

View File

@@ -1,69 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { isVolcengineCookieDomain } from "../../open-sse/services/volcengineConsoleAutoLogin.ts";
// CodeQL js/incomplete-url-substring-sanitization (#860, #861). The console
// auto-login harvested `digest`/`AccountID`/`csrfToken`/`userInfo` from any
// cookie whose domain merely *contained* "volcengine.com", so a cookie set by
// `volcengine.com.attacker.tld` (or `notvolcengine.com`) was accepted as an
// operator credential and persisted as a provider connection. Match the domain
// the way a cookie domain has to be matched: exact host or a dot-boundary
// suffix. Mirrors isAdobeCookieDomain in adobeFireflyBrowserLogin.ts.
test("accepts the real console cookie domains", () => {
for (const domain of [
"volcengine.com",
".volcengine.com",
"console.volcengine.com",
".console.volcengine.com",
"CONSOLE.VOLCENGINE.COM",
" .volcengine.com ",
]) {
assert.equal(isVolcengineCookieDomain(domain), true, domain);
}
});
test("rejects look-alike domains that merely contain the string", () => {
for (const domain of [
"volcengine.com.attacker.tld",
".volcengine.com.evil.example",
"notvolcengine.com",
"myvolcengine.com",
"volcengine.com.br",
"evil.tld/volcengine.com",
"volcengine.company",
]) {
assert.equal(isVolcengineCookieDomain(domain), false, domain);
}
});
test("rejects empty / missing domains instead of throwing", () => {
assert.equal(isVolcengineCookieDomain(undefined), false);
assert.equal(isVolcengineCookieDomain(""), false);
assert.equal(isVolcengineCookieDomain(" "), false);
});
// The same class exists in inAppLoginService's cookie capture, where the
// expected domain comes from TOKEN_EXTRACTION_CONFIGS instead of a literal —
// which is why CodeQL did not flag it. Same helper, same guarantees.
test("matchesCookieDomain handles a config-supplied expected domain", async () => {
const { matchesCookieDomain } = await import("../../open-sse/utils/cookieDomain.ts");
assert.equal(matchesCookieDomain("app.example.com", "example.com"), true);
assert.equal(matchesCookieDomain(".example.com", ".example.com"), true);
assert.equal(matchesCookieDomain("example.com", ".example.com"), true);
assert.equal(matchesCookieDomain("example.com.attacker.tld", "example.com"), false);
assert.equal(matchesCookieDomain("notexample.com", "example.com"), false);
assert.equal(matchesCookieDomain("example.com", "app.example.com"), false);
});
test("matchesCookieDomain fails closed on a missing expected domain", async () => {
const { matchesCookieDomain } = await import("../../open-sse/utils/cookieDomain.ts");
assert.equal(matchesCookieDomain("example.com", undefined), false);
assert.equal(matchesCookieDomain("example.com", ""), false);
assert.equal(matchesCookieDomain("example.com", "."), false);
});