Compare commits

..

5 Commits

Author SHA1 Message Date
diegosouzapw
a37c39ef73 fix(cli): setup-opencode respects --api-key/OMNIROUTE_API_KEY over context token (#12783) 2026-09-10 14:15:00 -03:00
Diego Rodrigues de Sa e Souza
fd27ff08c7 chore(deps): drain the Dependabot queue — 10 of 13 alerts (#13213)
* chore(deps): drain the Dependabot queue — 7 of 10 alerts

Lockfile-only bumps; no manifest touched, so nothing changes for consumers.

Root package-lock.json:
  hono      4.13.0 -> 4.13.7  (#215 #216 #217, medium, patched 4.13.5)
  csv-parse 7.0.1  -> 7.0.2   (#213, medium)
  joi       18.2.3 -> 18.2.8  (#211 #212, low, patched 18.2.4/18.2.5)

@omniroute/opencode-plugin:
  toml      4.1.1  -> 4.3.0   (#209, HIGH, patched 4.1.2)

@omniroute/opencode-plugin-v2:
  esbuild   0.28.1 -> 0.28.2  (#210, low) — the direct copy only; see below.

The plugin-v2 diff looks large but is one package: esbuild ships 27 platform
binaries, each carrying version + resolved + integrity.

Three alerts stay open, deliberately:

  #218 extract-zip (HIGH) and #214 adm-zip (medium) have NO published patch.
  Both are dev-scope. Closing them needs an upstream release or a decision to
  replace the dependency — neither belongs in a lockfile bump.

  #210 esbuild is only half-closed. `node_modules/esbuild` is on 0.28.2, but
  `tsup` pins `esbuild: ^0.27.0`, so its nested copy stays at 0.27.7 — inside the
  vulnerable range (>= 0.27.3, < 0.28.1). Updating tsup does not move it (8.5.1
  is already current). Forcing it would take an `overrides` entry pushing a major
  of esbuild inside the bundler, which is exactly the change that breaks a build
  silently, for a LOW dev-only alert. Left for an upstream tsup release.

check:lockfile passes on all three, including the workspace lock/manifest
consistency check. check:tracked-artifacts OK.

* chore(deps): bump js-yaml to 4.3.2 (root + electron)

Two more HIGH alerts arrived after the first sweep:

  #220 js-yaml (root package-lock.json)     >= 4.0.0, < 4.3.2
  #219 js-yaml (electron/package-lock.json) >= 4.0.0, < 4.3.2

The root's own js-yaml was already on 5.4.1; the vulnerable copies were the ones
nested under @yarnpkg/parsers, lockfile-lint, xmlbuilder2 (root) and the direct
dependency in electron. All now 4.3.2. Four version lines, nothing else.

#221 smol-toml (HIGH, <= 1.7.0) is NOT closed here. The root is on 1.8.0; the
vulnerable 1.6.1 sits under @openai/codex-security, which pins it as an EXACT
version rather than a range, so `npm update` cannot move it. Bumping
codex-security itself (0.1.24 -> 0.1.26) does not help — 0.1.26 pins the same
1.6.1 — so that bump was reverted rather than carried along for no benefit.

Closing #221 needs an upstream codex-security release or an `overrides` entry,
the same trade already declined for #210/tsup: forcing a transitive pin from
outside is how a build breaks silently. Note that @openai/codex-security is also
the package carrying the unpatched extract-zip (#218), so one upstream release
would likely clear both.

* chore(deps): override smol-toml to 1.8.0 and raise the js-yaml floor

Closes #221 (smol-toml, HIGH, DoS via malformed TOML, vulnerable <= 1.7.0).

@openai/codex-security pins smol-toml at 1.6.1 as an EXACT version, so no
`npm update` reaches it. This repo already uses `overrides` as its standard tool
for exactly that situation — the block carries 20+ entries, including the
scoped-by-parent form and the `qs`/`fast-uri`/`ip-address` entries that back
earlier security bumps — so a scoped override is the idiomatic fix here, not a
new mechanism:

    "@openai/codex-security": { "smol-toml": "^1.8.0" }

The nested copy deduplicates to the root's existing 1.8.0, which two other
consumers (the root itself and knip) already run, so the version is proven in
this tree. The whole lockfile diff is the 14 lines of the removed 1.6.1 entry.

Also raised the `@yarnpkg/parsers` js-yaml floor from ^4.3.1 to ^4.3.2, so the
override documents the patched version rather than permitting the vulnerable one
it was written against.

Not fixed, and not fixable by version — verified against the npm registry rather
than trusting the advisory metadata:

  #218 extract-zip — latest published IS 2.0.1, the vulnerable version. Dev
       scope, via @openai/codex-security. No release to move to.
  #214 adm-zip — latest published IS 0.6.0, the top of the vulnerable range
       (>= 0.5.9, <= 0.6.0). RUNTIME scope, via onnxruntime-node's ^0.5.16, and
       the repo already overrides adm-zip to ^0.6.0. No release to move to.

Both need an upstream fix or a decision to replace the dependency; neither is a
lockfile change. adm-zip being runtime rather than dev makes it the one worth
tracking.

#210 esbuild stays open too. A flat `overrides: { esbuild: ^0.28.2 }` in
opencode-plugin-v2 does close it — npm then reports 0 vulnerabilities — but it
requires regenerating that lockfile from scratch: 823 lines, 96 packages moved,
for a LOW dev-only alert, and a major esbuild bump inside tsup cannot be
validated here without a real install of that package. Tried, measured,
reverted. Left for an upstream tsup release.

check:lockfile OK on all lockfiles including the workspace consistency check;
check:tracked-artifacts OK; prettier clean.
2026-09-10 13:37:55 -03:00
Diego Rodrigues de Sa e Souza
0549dcfc36 fix(api): scope batch bulk-delete to the calling API key (#13211)
GHSA-wvxc-jp3v-5mg5: `DELETE /api/v1/batches/delete-completed` deleted the
completed batches of EVERY api key on the instance and nulled the contents of
every file those batches referenced. Any ordinary inference key reached it —
including one with `scopes: []` — and no victim key, batch id or file id was
needed.

Two defects stacked in one endpoint:

  - `deleteCompletedBatches()` carried no `api_key_id` predicate. The file
    SELECT, the checkpoint DELETE and the batch DELETE were all instance-wide.
  - The route only checked that SOME key was present (`!scope.apiKeyId` → 401),
    never that the caller owned anything, and called the helper bare.

The helper now takes `apiKeyId` and scopes all three statements to it; the route
passes the caller's key and omits it only for session auth, so the operator's own
dashboard keeps its instance-wide cleanup and an API key clears only its own
completed batches.

None of this is a new pattern. `listBatches(apiKeyId?)` and
`countBatches(apiKeyId?)` in the same module already scope by `api_key_id`, and
`batches/[id]/route.ts` already gates per-record access with `scopeCheck` —
session auth sees everything, a key sees only its own. This one helper was the
one that never got it, which is why the fix reuses the shape instead of
inventing a second convention.

tests/unit/batch-delete-completed-ownership-wvxc.test.ts — 5 tests, 4 red before
the fix, including the two that prove the cross-tenant destruction (another
key's batch survives; another key's file content survives). It also pins the
instance-wide dashboard sweep so the fix cannot be "tightened" into breaking the
operator's own cleanup, and a source guard that the route never calls the helper
bare again.

Reported privately via GHSA-wvxc-jp3v-5mg5.

Closes GHSA-wvxc-jp3v-5mg5
2026-09-10 13:27:10 -03:00
Diego Rodrigues de Sa e Souza
393cfdd660 fix(test): make the ToS heading guard actually require the parentheses (#13228)
CodeQL js/useless-regexp-character-escape (#994-#997) on one line, and it is a
real defect rather than the usual query noise.

The assertion built its pattern in a TEMPLATE literal:

    new RegExp(`\(\s*${String(tos?.actual)}\s*\)`)

JavaScript resolves the escapes before RegExp ever sees the string: `\(` becomes
"(" and `\s` becomes the LETTER "s". The compiled pattern was `(s*16s*)` — a
capture group around optional "s" characters — so it matched any heading merely
CONTAINING the number. The literal parentheses this guard exists to require were
never checked, and it passed on exactly the headings it was written to reject:

    /(s*16s*)/.test("### Caution — clauses worth checking 16")   // true

Doubled the backslashes so they survive the template literal, and routed the
interpolated value through an `escapeRegExp` helper — the count is a number
today, but interpolating an unescaped value into a regex source is the same
class of bug one refactor away.

Added a second test that pins the behaviour rather than the spelling: the
pattern must REJECT a heading carrying the count without parentheses, and accept
it with them (including inner whitespace). Before this fix that test fails.

4/4 green against the real docs/reference/FREE_TIERS.md heading.
2026-09-10 13:27:02 -03:00
Diego Rodrigues de Sa e Souza
d86cf75aef fix(quality): register 4 drifted covering tests in stryker tap.testFiles (#13229)
`check:mutation-test-coverage --strict` has been failing Fast Quality Gates on
every open PR against release/v3.8.51. It grew from 2 missing entries to 5 in
roughly an hour, so it is drifting faster than PRs land.

Four test files cover a mutated module without being listed, so their mutant
kills do not count:

  open-sse/services/accountFallback.ts      <- openai-compatible-per-upstream-402-health
  src/sse/services/auth.ts                  <- openai-compatible-per-upstream-402-health
                                            <- quota-window-label
  src/shared/utils/circuitBreaker.ts        <- combo/execute-target-gates
  open-sse/services/combo/comboStructure.ts <- combo-pin-implicit-allowlist

Registration only — no test or module is touched, and no gate is weakened; the
listing is what makes those kills count in the first place.

Inserted in place, never through a JSON round-trip: re-serializing this file
reorders the ~10 curated entries that are already out of alphabetical order
(learned the hard way in #11438).

check:mutation-test-coverage now reports no drift. check:tracked-artifacts OK,
prettier clean.

Worth noting for whoever adds the next test: this gate fires whenever a NEW test
happens to cover one of the 31 mutated modules, which is easy to do without
realising. Registering it in the same commit is cheaper than a CI round-trip.
2026-09-10 13:26:53 -03:00
22 changed files with 501 additions and 482 deletions

View File

@@ -22,7 +22,7 @@
"node": ">=22.22.3"
},
"peerDependencies": {
"@opencode-ai/plugin": "*"
"@opencode-ai/plugin": ">=1.18.29 <2"
}
},
"node_modules/@ai-sdk/provider": {
@@ -39,9 +39,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
@@ -56,9 +56,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
@@ -73,9 +73,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
@@ -90,9 +90,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
@@ -107,9 +107,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
@@ -124,9 +124,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
@@ -141,9 +141,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
@@ -158,9 +158,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
@@ -175,9 +175,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
@@ -192,9 +192,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
@@ -209,9 +209,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
@@ -226,9 +226,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
@@ -243,9 +243,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
@@ -260,9 +260,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
@@ -277,9 +277,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
@@ -294,9 +294,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
@@ -311,7 +311,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
@@ -326,9 +328,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
@@ -343,9 +345,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
@@ -360,9 +362,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
@@ -377,9 +379,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
@@ -394,9 +396,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
@@ -411,9 +413,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
@@ -428,9 +430,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
@@ -445,9 +447,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
@@ -462,9 +464,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
@@ -1180,7 +1182,9 @@
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1191,32 +1195,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/fast-check": {

View File

@@ -1745,9 +1745,9 @@
}
},
"node_modules/toml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz",
"integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -35,16 +35,24 @@ export function resolveOpencodeTarget(opts = {}) {
baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
}
// Precedence: explicit --api-key flag > OMNIROUTE_API_KEY env var > active
// context's management token. A context's accessToken/apiKey is a CLI
// management credential (oma_live_...) with no /v1/* inference scope — it
// must never silently outrank a real inference key the caller supplied
// either as a flag or via the ambient env var (mirrors the explicit >
// ambient-env > context precedence documented in bin/cli/api.mjs's
// buildHeaders()). Only fall back to the context token when neither an
// explicit flag nor the env var is set.
let apiKey = opts.apiKey ?? opts["api-key"];
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
if (!apiKey) {
try {
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
apiKey = c?.accessToken || c?.apiKey;
apiKey = c?.accessToken || c?.apiKey || "";
} catch {
/* no context auth */
}
}
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey };
}
@@ -177,8 +185,17 @@ export function registerSetupOpencode(program) {
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => {
const code = await runSetupOpencodeCommand(opts);
.action(async (opts, cmd) => {
// Commander parses the ancestor program's own global --api-key option
// (bin/cli/program.mjs, bound to .env("OMNIROUTE_API_KEY")) against any
// occurrence of the flag in argv, so it wins the value even when the
// user typed --api-key AFTER `setup-opencode` — this local option's own
// `opts.apiKey` never sees it. cmd.optsWithGlobals() resolves to the
// correct value either way ("globals overwrite locals" is exactly the
// outcome we want here, since the global option is where the value
// always actually lands).
const resolvedOpts = { ...opts, apiKey: cmd.optsWithGlobals().apiKey ?? opts.apiKey };
const code = await runSetupOpencodeCommand(resolvedOpts);
if (code !== 0) process.exit(code);
});
}

View File

@@ -1 +0,0 @@
- fix(docker): require a per-session token on the VNC browser CDP bridge and isolate it on a dedicated Docker network (#12571)

View File

@@ -0,0 +1 @@
- fix(cli): setup-opencode no longer sends an active context's management token to `/v1/models` when `--api-key`/`OMNIROUTE_API_KEY` is supplied — an explicit flag or the env var now always outranks the context's token, and the flag itself is no longer swallowed by the parent program's global `--api-key` option (#12783)

View File

@@ -8,14 +8,6 @@
# Chrome 150 ignores --remote-debugging-address and binds loopback only.
# The OmniRoute server harvests cookies over the host-mapped 9223.
#
# SECURITY (#12571): 9223 is gated by a per-session shared secret
# (CDP_BRIDGE_TOKEN, injected via `-e` by src/lib/vncSession/service.ts) that
# every caller must present as an `X-Omni-Cdp-Token` header before the bridge
# forwards a single byte to Chromium — see cdp-bridge.py for the check. The
# container also runs on a dedicated Docker network (not the default bridge)
# so sibling containers can't reach 9223 either. Do not remove either control
# or the CDP bridge reverts to an unauthenticated, full-session-takeover proxy.
#
# Alpine/Debian package mirrors are unreachable from the build sandbox, so we
# extend a prebuilt image rather than apt/apk-installing anything.
FROM linuxserver/chromium:latest

View File

@@ -5,64 +5,19 @@ Chrome binds DevTools to 127.0.0.1 only and ignores --remote-debugging-address
on recent versions, so the host can't reach it via `docker -p 9222:9222`. This
tiny TCP bridge (run inside the container) exposes the same CDP on all
interfaces so the OmniRoute server's VNC harvester can connect from the host.
SECURITY (#12571): 9223 is reachable by any sibling container on the same
Docker bridge network, not just the host, and CDP grants full control over a
live, credential-bearing browser session (Runtime.evaluate, cookie theft,
etc). Every connection MUST present the shared secret in CDP_BRIDGE_TOKEN
(env, injected per-session by src/lib/vncSession/service.ts) as an
`X-Omni-Cdp-Token: <token>` header on its first HTTP request/WS-upgrade
before a single byte is forwarded upstream. A missing/invalid token gets the
connection closed immediately with no response, so probing gives no signal.
"""
import os, socket, threading, sys
import socket, threading, sys
SRC_HOST, SRC_PORT = "127.0.0.1", 9222
PUB_HOST, PUB_PORT = "0.0.0.0", 9223
TOKEN = os.environ.get("CDP_BRIDGE_TOKEN", "")
TOKEN_HEADER = f"x-omni-cdp-token: {TOKEN}".lower()
PEEK_TIMEOUT_S = 5
MAX_PEEK_BYTES = 8192
def has_valid_token(initial_chunk: bytes) -> bool:
"""Check whether the client's first bytes carry the configured secret.
A missing/empty TOKEN always fails closed (no caller can present a valid
empty header line the way this check is written).
"""
if not TOKEN:
return False
try:
text = initial_chunk.decode("latin-1", errors="ignore").lower()
except (UnicodeDecodeError, LookupError):
return False
return TOKEN_HEADER in text
def read_initial_chunk(client):
client.settimeout(PEEK_TIMEOUT_S)
try:
return client.recv(MAX_PEEK_BYTES)
except OSError:
return b""
finally:
client.settimeout(None)
def bridge(client, target_addr):
initial = read_initial_chunk(client)
if not has_valid_token(initial):
client.close()
return
try:
upstream = socket.create_connection(target_addr, timeout=10)
upstream.sendall(initial)
except OSError:
client.close()
return
a = threading.Thread(target=pipe, args=(client, upstream), daemon=True)
b = threading.Thread(target=pipe, args=(upstream, client), daemon=True)
a.start(); b.start()

View File

@@ -1440,7 +1440,6 @@ Containerized Chromium+VNC used for interactive browser-login credential capture
| `OMNIROUTE_VNC_READY_MS` | `45000` | `src/lib/vncSession/manifest.ts` | Timeout (ms) waiting for the containerized browser to become CDP-ready. |
| `OMNIROUTE_VNC_HARVEST_MS` | `20000` | `src/lib/vncSession/manifest.ts` | Timeout (ms) for harvesting the captured session/cookies after login completes. |
| `OMNIROUTE_VNC_CHROMIUM_ARGS` | `--remote-debugging-port=9222 --no-first-run --no-default-browser-check` | `src/lib/vncSession/manifest.ts` | Extra command-line flags passed to the containerized Chromium. |
| `OMNIROUTE_VNC_NETWORK` | `omniroute-vnc-browser-login` | `src/lib/vncSession/manifest.ts` | Dedicated Docker network the VNC login container joins (#12571) instead of the default bridge, so sibling containers can't reach its CDP bridge port. |
| `VIBEPROXY_DATA_DIR` | _(unset)_ | `open-sse/services/notionThreadSessions.ts` | **Legacy alias** for `DATA_DIR`, checked only after both `DATA_DIR` and `OMNIROUTE_DATA_DIR` are unset. Locates the Notion web-thread session cache (`<dir>/notion-web-thread-sessions.json`). |
---
@@ -1579,7 +1578,6 @@ Used by `src/lib/vncSession/manifest.ts` to configure Docker-based headless Chro
| `OMNIROUTE_VNC_MAX_SESSIONS` | `4` | `src/lib/vncSession/manifest.ts` | Maximum concurrent VNC sessions. |
| `OMNIROUTE_VNC_READY_MS` | `45000` | `src/lib/vncSession/manifest.ts` | Browser readiness timeout (ms). |
| `OMNIROUTE_VNC_HARVEST_MS` | `20000` | `src/lib/vncSession/manifest.ts` | Harvest/cleanup timeout (ms). |
| `OMNIROUTE_VNC_NETWORK` | `omniroute-vnc-browser-login` | `src/lib/vncSession/manifest.ts` | Dedicated Docker network the container joins (#12571), off the default bridge. |
| `VIBEPROXY_DATA_DIR` | _(unset)_ | `open-sse/services/notionThreadSessions.ts` | Directory for Notion thread session persistence. |
### Internal service auth

View File

@@ -2113,9 +2113,9 @@
}
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"funding": [
{
"type": "github",

50
package-lock.json generated
View File

@@ -8687,20 +8687,6 @@
"license": "ISC",
"optional": true
},
"node_modules/@openai/codex-security/node_modules/smol-toml": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
"integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/@openai/codex-security/node_modules/type-fest": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz",
@@ -15217,9 +15203,9 @@
}
},
"node_modules/@yarnpkg/parsers/node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@@ -18535,9 +18521,9 @@
"license": "MIT"
},
"node_modules/csv-parse": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.1.tgz",
"integrity": "sha512-+2z7Ar0APQ7Uu6fX4cn+pitRmxjZ1WPBcGmZFKmA74FCyi7Et/XZx8cjNQ5CjbZ4HCOxXCOpRBYvYH08Qa003A==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz",
"integrity": "sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA==",
"dev": true,
"license": "MIT"
},
@@ -23603,9 +23589,9 @@
"license": "MIT"
},
"node_modules/hono": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -26116,9 +26102,9 @@
}
},
"node_modules/joi": {
"version": "18.2.3",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz",
"integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==",
"version": "18.2.8",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.8.tgz",
"integrity": "sha512-G2TX62h58ZHuwqetJgP2F4ualakqAmZtBYe3jWen7gxQRw5xApX6crnFtuB91WC0c3ESBnva+kGSnb3+6pIQDQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -27946,9 +27932,9 @@
}
},
"node_modules/lockfile-lint/node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@@ -39620,9 +39606,9 @@
}
},
"node_modules/xmlbuilder2/node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{

View File

@@ -486,7 +486,10 @@
"fast-uri": "^3.1.7",
"body-parser": "^2.3.0",
"@yarnpkg/parsers": {
"js-yaml": "^4.3.1"
"js-yaml": "^4.3.2"
},
"@openai/codex-security": {
"smol-toml": "^1.8.0"
},
"jsdom": {
"undici": "^7.29.0"

View File

@@ -19,7 +19,10 @@ export async function DELETE(request: Request) {
);
}
const result = deleteCompletedBatches();
// Scope the sweep to the caller. Only the operator's own dashboard (session
// auth) may clear the whole instance; an API key clears only its own
// completed batches (GHSA-wvxc-jp3v-5mg5).
const result = deleteCompletedBatches(scope.isSessionAuth ? undefined : scope.apiKeyId);
return NextResponse.json(
{ deleted: true, deletedBatches: result.deletedBatches, deletedFiles: result.deletedFiles },

View File

@@ -411,15 +411,37 @@ export function deleteBatch(id: string): boolean {
return result.changes > 0;
}
export function deleteCompletedBatches(): { deletedBatches: number; deletedFiles: number } {
/**
* Bulk-delete completed batches and the files they reference.
*
* `apiKeyId` scopes EVERY statement to that owner. Omitting it keeps the
* instance-wide sweep, which is legitimate for the operator's own dashboard
* (session auth) and for nothing else: without the predicate, an ordinary
* inference key could wipe every tenant's completed batches and null out their
* file contents (GHSA-wvxc-jp3v-5mg5). Same ownership shape as `listBatches`
* and `countBatches` above.
*/
export function deleteCompletedBatches(apiKeyId?: string | null): {
deletedBatches: number;
deletedFiles: number;
} {
const db = getDbInstance();
const scoped = typeof apiKeyId === "string" && apiKeyId.length > 0;
// Collect unique file IDs from all completed batches
const rows = db
.prepare(
"SELECT input_file_id, output_file_id, error_file_id FROM batches WHERE status = 'completed'"
)
.all() as Array<{
// Collect unique file IDs from the completed batches in scope
const rows = (
scoped
? db
.prepare(
"SELECT input_file_id, output_file_id, error_file_id FROM batches WHERE status = 'completed' AND api_key_id = ?"
)
.all(apiKeyId)
: db
.prepare(
"SELECT input_file_id, output_file_id, error_file_id FROM batches WHERE status = 'completed'"
)
.all()
) as Array<{
input_file_id: string | null;
output_file_id: string | null;
error_file_id: string | null;
@@ -441,6 +463,16 @@ export function deleteCompletedBatches(): { deletedBatches: number; deletedFiles
}
}
if (scoped) {
db.prepare(
"DELETE FROM batch_item_checkpoints WHERE batch_id IN (SELECT id FROM batches WHERE status = 'completed' AND api_key_id = ?)"
).run(apiKeyId);
const result = db
.prepare("DELETE FROM batches WHERE status = 'completed' AND api_key_id = ?")
.run(apiKeyId);
return { deletedBatches: result.changes, deletedFiles };
}
db.prepare(
"DELETE FROM batch_item_checkpoints WHERE batch_id IN (SELECT id FROM batches WHERE status = 'completed')"
).run();

View File

@@ -17,9 +17,6 @@ export interface HarvestResult {
hasCredential: boolean;
}
/** Header name the CDP bridge (docker/vnc-browser/chromium/cdp-bridge.py) requires (#12571). */
const CDP_TOKEN_HEADER = "X-Omni-Cdp-Token";
interface Pending {
resolve: (value: any) => void;
reject: (error: Error) => void;
@@ -39,8 +36,8 @@ class CdpClient {
private sessionId: string | null = null;
private closed = false;
constructor(wsUrl: string, cdpToken: string) {
this.ws = new WebSocket(wsUrl, { headers: { [CDP_TOKEN_HEADER]: cdpToken } });
constructor(wsUrl: string) {
this.ws = new WebSocket(wsUrl);
this.ws.on("message", (data) => this.onMessage(data));
this.ws.on("close", () => this.rejectAll(new Error("CDP websocket closed")));
this.ws.on("error", (error) => this.rejectAll(toError(error, "CDP websocket error")));
@@ -255,11 +252,7 @@ class CdpClient {
}
}
export async function waitForCdpReady(
cdpPort: number,
timeoutMs: number,
cdpToken: string
): Promise<void> {
export async function waitForCdpReady(cdpPort: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: Error | null = null;
@@ -267,11 +260,7 @@ export async function waitForCdpReady(
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2_000);
try {
const version = await fetchJson(
`http://127.0.0.1:${cdpPort}/json/version`,
controller.signal,
cdpToken
);
const version = await fetchJson(`http://127.0.0.1:${cdpPort}/json/version`, controller.signal);
if (version?.webSocketDebuggerUrl) return;
lastError = new Error("CDP endpoint did not return a websocket URL");
} catch (error) {
@@ -288,8 +277,7 @@ export async function waitForCdpReady(
export async function harvestFromContainer(
cdpPort: number,
provider: VncProviderEntry,
timeoutMs = 20_000,
cdpToken = ""
timeoutMs = 20_000
): Promise<HarvestResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -298,15 +286,14 @@ export async function harvestFromContainer(
try {
const version = await fetchJson(
`http://127.0.0.1:${cdpPort}/json/version`,
controller.signal,
cdpToken
controller.signal
);
const debuggerUrl = version?.webSocketDebuggerUrl;
if (typeof debuggerUrl !== "string" || !debuggerUrl) {
throw new Error("No CDP websocket endpoint from browser container");
}
client = new CdpClient(rewriteDebuggerUrl(debuggerUrl, cdpPort), cdpToken);
client = new CdpClient(rewriteDebuggerUrl(debuggerUrl, cdpPort));
await client.ready(Math.min(timeoutMs, 15_000), controller.signal);
const origin = new URL(provider.url).origin;
@@ -416,8 +403,8 @@ function safeOrigin(value: string | undefined): string | null {
}
}
async function fetchJson(url: string, signal: AbortSignal, cdpToken = ""): Promise<any> {
const response = await fetch(url, { signal, headers: { [CDP_TOKEN_HEADER]: cdpToken } });
async function fetchJson(url: string, signal: AbortSignal): Promise<any> {
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`CDP endpoint returned HTTP ${response.status}`);
return response.json();
}

View File

@@ -86,12 +86,6 @@ export const VNC_CONFIG = {
maxSessionMs: Number(process.env.OMNIROUTE_VNC_MAX_MS || 30 * 60 * 1000),
maxSessions: Number(process.env.OMNIROUTE_VNC_MAX_SESSIONS || 4),
dockerBin: process.env.OMNIROUTE_DOCKER_BIN || "docker",
/**
* Dedicated bridge network for browser-login containers (#12571): keeps
* them off Docker's default bridge network so sibling containers can't
* reach the CDP bridge port over the container-to-container path.
*/
network: process.env.OMNIROUTE_VNC_NETWORK || "omniroute-vnc-browser-login",
browserReadyTimeoutMs: Number(process.env.OMNIROUTE_VNC_READY_MS || 45_000),
harvestTimeoutMs: Number(process.env.OMNIROUTE_VNC_HARVEST_MS || 20_000),
chromiumArgs:

View File

@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import { randomBytes, randomUUID } from "node:crypto";
import { randomUUID } from "node:crypto";
import { chmodSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
@@ -17,8 +17,6 @@ export interface VncSession {
containerName: string;
profileDir: string;
cdpPort: number;
/** Shared secret the CDP bridge (docker/vnc-browser/chromium/cdp-bridge.py) requires (#12571). */
cdpToken: string;
vncPort: number;
url: string;
status: VncSessionStatus;
@@ -140,67 +138,6 @@ function createProfileDir(connectionId: string, sessionId: string): string {
return profileDir;
}
let networkEnsured = false;
/**
* Creates the dedicated browser-login bridge network (#12571) if it does not
* already exist. Idempotent: `docker network create` failing because the
* network is already there is not an error.
*/
async function ensureNetwork(): Promise<void> {
if (networkEnsured) return;
const result = await docker(["network", "create", VNC_CONFIG.network], { timeoutMs: 15_000 });
if (result.code !== 0 && !/already exists/i.test(result.err)) {
throw new Error(result.err.trim() || `Could not create Docker network ${VNC_CONFIG.network}`);
}
networkEnsured = true;
}
/**
* Builds the `docker run` argument array for a browser-login container.
* Pulled out as a pure function so the security-relevant shape (dedicated
* network + CDP_BRIDGE_TOKEN, #12571) is directly testable without spawning
* Docker or touching the DB.
*/
export function buildRunArgs(params: {
containerName: string;
sessionId: string;
connectionId: string;
profileDir: string;
chromeCli: string;
cdpToken: string;
}): string[] {
return [
"run",
"-d",
"--name",
params.containerName,
"--restart",
"no",
"--network",
VNC_CONFIG.network,
"--label",
`${LABEL}=true`,
"--label",
`${LABEL}.session-id=${params.sessionId}`,
"--label",
`${LABEL}.connection-id=${params.connectionId}`,
"--shm-size",
"1gb",
"-p",
`127.0.0.1::${VNC_CONFIG.containerVncPort}`,
"-p",
`127.0.0.1::${VNC_CONFIG.containerCdpPort}`,
"-v",
`${params.profileDir}:${VNC_CONFIG.containerProfileDir}`,
"-e",
`CHROME_CLI=${params.chromeCli}`,
"-e",
`CDP_BRIDGE_TOKEN=${params.cdpToken}`,
VNC_CONFIG.image,
];
}
async function publishedPort(containerName: string, containerPort: number): Promise<number> {
const result = await docker(["port", containerName, `${containerPort}/tcp`], {
timeoutMs: 10_000,
@@ -239,7 +176,6 @@ export async function startSession(connectionId: string): Promise<VncSession> {
const sessionId = randomUUID();
const containerName = sessionKey(sessionId);
const profileDir = createProfileDir(connectionId, sessionId);
const cdpToken = randomBytes(24).toString("hex");
const state: VncSession = {
sessionId,
connectionId,
@@ -247,7 +183,6 @@ export async function startSession(connectionId: string): Promise<VncSession> {
containerName,
profileDir,
cdpPort: 0,
cdpToken,
vncPort: 0,
url: provider.url,
status: "starting",
@@ -258,10 +193,33 @@ export async function startSession(connectionId: string): Promise<VncSession> {
SESSIONS.set(sessionId, state);
try {
await ensureNetwork();
const chromeCli = `${VNC_CONFIG.chromiumArgs} ${provider.url}`;
const result = await docker(
buildRunArgs({ containerName, sessionId, connectionId, profileDir, chromeCli, cdpToken }),
[
"run",
"-d",
"--name",
containerName,
"--restart",
"no",
"--label",
`${LABEL}=true`,
"--label",
`${LABEL}.session-id=${sessionId}`,
"--label",
`${LABEL}.connection-id=${connectionId}`,
"--shm-size",
"1gb",
"-p",
`127.0.0.1::${VNC_CONFIG.containerVncPort}`,
"-p",
`127.0.0.1::${VNC_CONFIG.containerCdpPort}`,
"-v",
`${profileDir}:${VNC_CONFIG.containerProfileDir}`,
"-e",
`CHROME_CLI=${chromeCli}`,
VNC_CONFIG.image,
],
{ timeoutMs: 120_000 }
);
if (result.code !== 0) {
@@ -276,7 +234,7 @@ export async function startSession(connectionId: string): Promise<VncSession> {
state.vncPort = await publishedPort(containerName, VNC_CONFIG.containerVncPort);
state.cdpPort = await publishedPort(containerName, VNC_CONFIG.containerCdpPort);
await waitForCdpReady(state.cdpPort, VNC_CONFIG.browserReadyTimeoutMs, state.cdpToken);
await waitForCdpReady(state.cdpPort, VNC_CONFIG.browserReadyTimeoutMs);
state.status = "running";
scheduleIdleSweep();
@@ -317,8 +275,7 @@ export async function harvestSession(
const harvest = await harvestFromContainer(
session.cdpPort,
provider,
VNC_CONFIG.harvestTimeoutMs,
session.cdpToken
VNC_CONFIG.harvestTimeoutMs
);
session.lastHarvestAt = Date.now();
if (!harvest.hasCredential) {

View File

@@ -58,6 +58,8 @@
"tests/unit/account-fallback-retry-after-json.test.ts",
"tests/unit/account-fallback-route-restriction-403.test.ts",
"tests/unit/account-fallback-service.test.ts",
"tests/unit/combo-pin-implicit-allowlist.test.ts",
"tests/unit/combo/execute-target-gates.test.ts",
"tests/unit/moonshot-quota-writeback.test.ts",
"tests/unit/accountfallback-ratelimit-400-4976.test.ts",
"tests/unit/adaptive-admission-route-matrix.test.ts",
@@ -75,6 +77,8 @@
"tests/unit/api-key-policy-noauth-allowed-connections.test.ts",
"tests/unit/api-key-rotator-health.test.ts",
"tests/unit/chat-routing-synced-inventory-11089.test.ts",
"tests/unit/openai-compatible-per-upstream-402-health.test.ts",
"tests/unit/quota-window-label.test.ts",
"tests/unit/repro-combo-persisted-cooldown-preskip.test.ts",
"tests/unit/repro-glm-iso-reset-24h-cap.test.ts",
"tests/unit/security-route-guard-tiers.test.ts",

View File

@@ -0,0 +1,114 @@
/**
* GHSA-wvxc-jp3v-5mg5 — `DELETE /api/v1/batches/delete-completed` deleted the
* completed batches of EVERY api key on the instance, and nulled the contents of
* every file those batches referenced.
*
* Two defects in one endpoint:
* 1. `deleteCompletedBatches()` carried no `api_key_id` predicate — the file
* SELECT, the checkpoint DELETE and the batch DELETE were all instance-wide.
* 2. The route only checked that SOME key was present (`!scope.apiKeyId` →
* 401), never that the caller owned anything. A key with `scopes: []` —
* an ordinary inference key — could wipe another tenant's batches.
*
* The ownership pattern this restores is not new: `listBatches(apiKeyId?)` and
* `countBatches(apiKeyId?)` in the same module already scope by `api_key_id`,
* and `batches/[id]/route.ts` already gates per-record access with `scopeCheck`
* (session auth sees everything, a key sees only its own). This helper was the
* one that never got it.
*
* Run with:
* node --import tsx/esm --test tests/unit/batch-delete-completed-ownership-wvxc.test.ts
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createFile, getFile } from "@/lib/db/files";
import { createBatch, getBatch, deleteCompletedBatches } from "@/lib/db/batches";
const KEY_A = "key-wvxc-aaaa";
const KEY_B = "key-wvxc-bbbb";
function seedCompletedBatch(apiKeyId: string | null, tag: string) {
const file = createFile({
bytes: 10,
filename: `wvxc-${tag}.jsonl`,
purpose: "batch",
content: Buffer.from("{}"),
});
const batch = createBatch({
endpoint: "/v1/chat/completions",
completionWindow: "24h",
inputFileId: file.id,
status: "completed",
apiKeyId,
});
return { file, batch };
}
describe("deleteCompletedBatches — ownership scoping (GHSA-wvxc-jp3v-5mg5)", () => {
it("scoped to one key deletes ONLY that key's completed batches", () => {
const a = seedCompletedBatch(KEY_A, "a1");
const b = seedCompletedBatch(KEY_B, "b1");
const result = deleteCompletedBatches(KEY_A);
assert.equal(getBatch(a.batch.id), null, "the caller's own batch should be gone");
assert.ok(getBatch(b.batch.id), "another key's batch must survive");
assert.equal(result.deletedBatches, 1, "must report only what it actually deleted");
});
it("scoped deletion does not touch another key's file contents", () => {
const a = seedCompletedBatch(KEY_A, "a2");
const b = seedCompletedBatch(KEY_B, "b2");
deleteCompletedBatches(KEY_A);
assert.equal(getFile(a.file.id), null, "the caller's own file should be gone");
assert.ok(getFile(b.file.id), "another key's file must survive with its content intact");
});
it("a key with no completed batches deletes nothing at all", () => {
const b = seedCompletedBatch(KEY_B, "b3");
const result = deleteCompletedBatches("key-wvxc-with-nothing");
assert.equal(result.deletedBatches, 0);
assert.equal(result.deletedFiles, 0);
assert.ok(getBatch(b.batch.id), "an unrelated key's batch must survive");
});
it("unscoped (dashboard session) still clears the whole instance", () => {
// The operator's own dashboard legitimately cleans up everything; that is
// the ONLY caller allowed to omit the key. Preserved deliberately.
seedCompletedBatch(KEY_A, "a4");
seedCompletedBatch(KEY_B, "b4");
const result = deleteCompletedBatches();
assert.ok(
result.deletedBatches >= 2,
`expected an instance-wide sweep, got ${result.deletedBatches}`
);
});
});
describe("the route passes the caller's key through", () => {
it("delete-completed scopes by api key instead of calling the helper bare", async () => {
const { readFileSync } = await import("node:fs");
const { fileURLToPath } = await import("node:url");
const src = readFileSync(
fileURLToPath(
new URL("../../src/app/api/v1/batches/delete-completed/route.ts", import.meta.url)
),
"utf8"
);
assert.ok(
!/deleteCompletedBatches\(\s*\)/.test(src),
"the route still calls deleteCompletedBatches() with no owner — every tenant's batches go"
);
assert.ok(
/deleteCompletedBatches\(\s*scope\./.test(src),
"the route must pass the caller's scope into the helper"
);
});
});

View File

@@ -13,6 +13,11 @@ type Check = {
validate?: (content: string, claim?: string) => { ok: boolean; detail: string };
};
/** Escape a value that is interpolated into a RegExp source. */
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
describe("ToS caution heading count", () => {
it("the Caution heading carries the count the gate checks", () => {
const txt = readFileSync(join(process.cwd(), "docs/reference/FREE_TIERS.md"), "utf8");
@@ -21,7 +26,24 @@ describe("ToS caution heading count", () => {
const tos = (buildChecks() as Check[]).find((c) =>
String(c.docKey ?? "").includes("ToS caution")
);
assert.match(heading, new RegExp(`\(\s*${String(tos?.actual)}\s*\)`));
// The backslashes must survive the TEMPLATE LITERAL to reach the regex.
// Written as `\(\s*…` they did not: JS resolves `\(` to "(" and `\s` to the
// LETTER "s" before RegExp ever sees them, so the pattern compiled to
// `(s*16s*)` — a capture group around optional "s" characters. That matched
// any heading merely containing the number, with no literal parentheses
// required at all, so this guard passed on headings it was written to reject
// (CodeQL js/useless-regexp-character-escape #994-#997).
const count = escapeRegExp(String(tos?.actual));
assert.match(heading, new RegExp(`\\(\\s*${count}\\s*\\)`));
});
it("the heading guard actually requires the parentheses", () => {
// Pins the defect above: the pattern this test builds must REJECT a heading
// that carries the count without parentheses. Before the fix it accepted it.
const pattern = new RegExp(`\\(\\s*${escapeRegExp("16")}\\s*\\)`);
assert.equal(pattern.test("### Caution — clauses worth checking 16"), false);
assert.equal(pattern.test("### Caution — clauses worth checking (16)"), true);
assert.equal(pattern.test("### Caution — clauses worth checking ( 16 )"), true);
});
it("buildChecks exposes a soft ToS entry on FREE_TIERS.md with requireClaim", () => {
const checks = buildChecks() as Check[];

View File

@@ -0,0 +1,121 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { resolveOpencodeTarget } from "../../bin/cli/commands/setup-opencode.mjs";
/** Point OMNIROUTE_CONTEXT config resolution at an isolated, throwaway DATA_DIR. */
function withIsolatedContext(contextConfig, fn) {
const dir = mkdtempSync(join(tmpdir(), "omniroute-setup-opencode-test-"));
const originalDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = dir;
writeFileSync(
join(dir, "config.json"),
JSON.stringify({
version: 1,
currentContext: "remote",
contexts: { remote: contextConfig },
})
);
try {
return fn();
} finally {
if (originalDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = originalDataDir;
rmSync(dir, { recursive: true, force: true });
}
}
function withEnvApiKey(value, fn) {
const original = process.env.OMNIROUTE_API_KEY;
if (value === undefined) delete process.env.OMNIROUTE_API_KEY;
else process.env.OMNIROUTE_API_KEY = value;
try {
return fn();
} finally {
if (original === undefined) delete process.env.OMNIROUTE_API_KEY;
else process.env.OMNIROUTE_API_KEY = original;
}
}
test("setup-opencode: --api-key typed AFTER the subcommand name is not stolen by the parent program's global option", async () => {
const { createProgram } = await import("../../bin/cli/program.mjs");
const program = createProgram();
const setupOpencode = program.commands.find((c) => c.name() === "setup-opencode");
assert.ok(setupOpencode, "setup-opencode subcommand must be registered");
let capturedApiKey;
setupOpencode._actionHandler = null; // avoid the real network-calling action
setupOpencode.action((opts, cmd) => {
capturedApiKey = cmd.optsWithGlobals().apiKey ?? opts.apiKey;
});
await program.parseAsync(
[
"node",
"omniroute",
"setup-opencode",
"--remote",
"http://100.64.0.1:20128",
"--api-key",
"sk-TESTKEY123",
],
{ from: "node" }
);
assert.equal(
capturedApiKey,
"sk-TESTKEY123",
"the CLI-supplied --api-key value must reach the setup-opencode action handler"
);
});
test("resolveOpencodeTarget: (a) explicit --api-key flag wins over an active context's management token", () => {
withEnvApiKey(undefined, () => {
withIsolatedContext(
{ baseUrl: "http://100.64.0.1:20128", accessToken: "oma_live_CONTEXT_TOKEN" },
() => {
const { apiKey } = resolveOpencodeTarget({ apiKey: "sk-FLAG", context: "remote" });
assert.equal(apiKey, "sk-FLAG");
}
);
});
});
test("resolveOpencodeTarget: (b) OMNIROUTE_API_KEY env wins over an active context's management token when no flag is passed", () => {
withEnvApiKey("sk-ENVKEY", () => {
withIsolatedContext(
{ baseUrl: "http://100.64.0.1:20128", accessToken: "oma_live_CONTEXT_TOKEN" },
() => {
const { apiKey } = resolveOpencodeTarget({ context: "remote" });
assert.equal(apiKey, "sk-ENVKEY");
}
);
});
});
test("resolveOpencodeTarget: (c) the context's token is used only when neither a flag nor the env var is set", () => {
withEnvApiKey(undefined, () => {
withIsolatedContext(
{ baseUrl: "http://100.64.0.1:20128", accessToken: "oma_live_CONTEXT_TOKEN" },
() => {
const { apiKey } = resolveOpencodeTarget({ context: "remote" });
assert.equal(apiKey, "oma_live_CONTEXT_TOKEN");
}
);
});
});
test("resolveOpencodeTarget: falls back to '' when neither a flag, env var, nor a resolvable context is present", () => {
withEnvApiKey(undefined, () => {
withIsolatedContext({ baseUrl: "http://100.64.0.1:20128" }, () => {
const { apiKey } = resolveOpencodeTarget({
remote: "http://100.64.0.1:20128",
context: "__no-such-context__",
});
assert.equal(apiKey, "");
});
});
});

View File

@@ -1,124 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import net from "node:net";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BRIDGE_SCRIPT = path.resolve(__dirname, "../../docker/vnc-browser/chromium/cdp-bridge.py");
const UPSTREAM_PORT = 9222; // SRC_PORT in cdp-bridge.py
const BRIDGE_PORT = 9223; // PUB_PORT in cdp-bridge.py
const TOKEN = "test-secret-token-12571";
function waitForListening(server: net.Server): Promise<void> {
return new Promise((resolve, reject) => {
server.once("listening", () => resolve());
server.once("error", reject);
});
}
function waitForBridgeReady(child: ChildProcessWithoutNullStreams): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error("cdp-bridge.py did not report ready in time")),
5000
);
child.stderr.on("data", (chunk: Buffer) => {
if (chunk.toString("utf8").includes("forwarding")) {
clearTimeout(timer);
resolve();
}
});
child.once("error", (err) => {
clearTimeout(timer);
reject(err);
});
child.once("exit", (code) => {
clearTimeout(timer);
reject(new Error(`cdp-bridge.py exited early with code ${code}`));
});
});
}
function startUpstream(): Promise<{ server: net.Server; receivedAnyBytes: () => boolean }> {
let received = false;
const server = net.createServer((socket) => {
socket.on("data", () => {
received = true;
});
});
return waitForListening(server.listen(UPSTREAM_PORT, "127.0.0.1")).then(() => ({
server,
receivedAnyBytes: () => received,
}));
}
function startBridge(): ChildProcessWithoutNullStreams {
return spawn("python3", [BRIDGE_SCRIPT], {
stdio: ["ignore", "ignore", "pipe"],
env: { ...process.env, CDP_BRIDGE_TOKEN: TOKEN },
});
}
test("cdp-bridge.py must not forward bytes from an unauthenticated peer (#12571)", async () => {
const upstream = await startUpstream();
const bridge = startBridge();
try {
await waitForBridgeReady(bridge);
await new Promise<void>((resolve, reject) => {
const client = net.createConnection({ host: "127.0.0.1", port: BRIDGE_PORT }, () => {
client.write("GET /json/version HTTP/1.1\r\nHost: x\r\n\r\n");
});
client.once("error", reject);
setTimeout(() => {
client.destroy();
resolve();
}, 500);
});
assert.equal(
upstream.receivedAnyBytes(),
false,
"cdp-bridge.py forwarded traffic from an unauthenticated peer straight to Chromium's CDP " +
"port — the bridge has no auth/token check (see docker/vnc-browser/chromium/cdp-bridge.py)"
);
} finally {
bridge.kill("SIGKILL");
await new Promise<void>((resolve) => upstream.server.close(() => resolve()));
}
});
test("cdp-bridge.py forwards bytes once the caller presents the configured token (#12571)", async () => {
const upstream = await startUpstream();
const bridge = startBridge();
try {
await waitForBridgeReady(bridge);
await new Promise<void>((resolve, reject) => {
const client = net.createConnection({ host: "127.0.0.1", port: BRIDGE_PORT }, () => {
client.write(
`GET /json/version HTTP/1.1\r\nHost: x\r\nX-Omni-Cdp-Token: ${TOKEN}\r\n\r\n`
);
});
client.once("error", reject);
setTimeout(() => {
client.destroy();
resolve();
}, 500);
});
assert.equal(
upstream.receivedAnyBytes(),
true,
"cdp-bridge.py should forward traffic once the caller presents the correct " +
"CDP_BRIDGE_TOKEN"
);
} finally {
bridge.kill("SIGKILL");
await new Promise<void>((resolve) => upstream.server.close(() => resolve()));
}
});

View File

@@ -1,46 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildRunArgs, sessionKey } from "@/lib/vncSession/service";
import { VNC_CONFIG } from "@/lib/vncSession/manifest";
test("buildRunArgs (#12571) injects the CDP bridge token and a non-default network", () => {
const args = buildRunArgs({
containerName: sessionKey("session-abc"),
sessionId: "session-abc",
connectionId: "connection-xyz",
profileDir: "/tmp/profile",
chromeCli: "--remote-debugging-port=9222 https://example.com",
cdpToken: "super-secret-token",
});
const networkIndex = args.indexOf("--network");
assert.ok(networkIndex >= 0, "docker run args must include --network");
assert.equal(args[networkIndex + 1], VNC_CONFIG.network);
assert.notEqual(
args[networkIndex + 1],
"bridge",
"must not join Docker's default bridge network (#12571)"
);
const envFlags = args.filter((_value, index) => args[index - 1] === "-e");
assert.ok(
envFlags.some((flag) => flag === "CDP_BRIDGE_TOKEN=super-secret-token"),
"docker run args must inject CDP_BRIDGE_TOKEN for the container's cdp-bridge.py"
);
});
test("buildRunArgs (#12571) generates a distinct token per call so sessions cannot reuse each other's secret", () => {
const base = {
containerName: "c",
sessionId: "s",
connectionId: "conn",
profileDir: "/tmp/p",
chromeCli: "--x",
};
const argsA = buildRunArgs({ ...base, cdpToken: "token-a" });
const argsB = buildRunArgs({ ...base, cdpToken: "token-b" });
assert.ok(argsA.includes("CDP_BRIDGE_TOKEN=token-a"));
assert.ok(argsB.includes("CDP_BRIDGE_TOKEN=token-b"));
assert.notDeepEqual(argsA, argsB);
});