mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 08:12:20 +03:00
Compare commits
79 Commits
fix/9568-g
...
compressio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e52842b926 | ||
|
|
3d444c2209 | ||
|
|
944178475c | ||
|
|
2a3adca1cb | ||
|
|
52775bc958 | ||
|
|
a076376490 | ||
|
|
1c3f6dcc90 | ||
|
|
9d495f7c44 | ||
|
|
904d45e011 | ||
|
|
3eca8d9c0c | ||
|
|
aefab44503 | ||
|
|
c605cabf08 | ||
|
|
9f67e5cc74 | ||
|
|
f579f9b096 | ||
|
|
17c76cf5d6 | ||
|
|
0975bc8ca9 | ||
|
|
2baf2f4820 | ||
|
|
0be4243d2e | ||
|
|
4826385f19 | ||
|
|
988f76c9bf | ||
|
|
439f13b210 | ||
|
|
0515e69a3f | ||
|
|
96b31e3142 | ||
|
|
00059f7bdd | ||
|
|
71750799eb | ||
|
|
b4f0d97f10 | ||
|
|
bf83381794 | ||
|
|
71a8f6f98e | ||
|
|
64dba26398 | ||
|
|
19b58a99ab | ||
|
|
26b058207b | ||
|
|
ea801bbca2 | ||
|
|
77eb184f9d | ||
|
|
869f07b3b0 | ||
|
|
3780d45d62 | ||
|
|
da3c3c9f67 | ||
|
|
a48f256f51 | ||
|
|
b2ce078c92 | ||
|
|
8a3888e510 | ||
|
|
368d1c0e87 | ||
|
|
b21f2d91e7 | ||
|
|
c2c622ad82 | ||
|
|
796b4fefd1 | ||
|
|
cf2055ce3e | ||
|
|
4ef44a53a7 | ||
|
|
2a1c946aa6 | ||
|
|
0d2678360f | ||
|
|
a8fb526e9c | ||
|
|
dcddbe11cd | ||
|
|
0e66f7e566 | ||
|
|
4f97f84dea | ||
|
|
8aa9c6f710 | ||
|
|
5ead198eb4 | ||
|
|
a0f9ad852d | ||
|
|
8941eafcb9 | ||
|
|
b02c586cb4 | ||
|
|
e26fc0a774 | ||
|
|
ecd1114894 | ||
|
|
53066a592a | ||
|
|
6c309c5e5f | ||
|
|
59d5f43d7b | ||
|
|
152a3e13f7 | ||
|
|
ae3d026ad0 | ||
|
|
347bfe257c | ||
|
|
0dcac8bfc3 | ||
|
|
71af74bee4 | ||
|
|
ccbe6bc288 | ||
|
|
98c98856b8 | ||
|
|
3899495f60 | ||
|
|
35797deeab | ||
|
|
c3a024d3be | ||
|
|
b0d4d64e6c | ||
|
|
aa5856376e | ||
|
|
a52d5fb31f | ||
|
|
9ca5a1ad42 | ||
|
|
44ba571521 | ||
|
|
cc8d790262 | ||
|
|
ad94ec491e | ||
|
|
1e629721b9 |
@@ -18,6 +18,7 @@ coverage
|
||||
# Runtime data and logs
|
||||
data
|
||||
logs
|
||||
.sandbox
|
||||
|
||||
# Local env files (inject at runtime via --env-file or -e)
|
||||
.env
|
||||
|
||||
6
.env.devin-bridge.example
Normal file
6
.env.devin-bridge.example
Normal file
@@ -0,0 +1,6 @@
|
||||
ENABLE_LIVE_DEVIN_TESTS=0
|
||||
DEVIN_BRIDGE_MODEL=devin-cli-agentic/swe-1-7
|
||||
DEVIN_BRIDGE_SONNET_MODEL=devin-cli-agentic/swe-1-7
|
||||
DEVIN_BRIDGE_OPUS_MODEL=devin-cli-agentic/swe-1-7
|
||||
DEVIN_BRIDGE_HAIKU_MODEL=devin-cli-agentic/swe-1-7
|
||||
DEVIN_BRIDGE_SUBAGENT_MODEL=devin-cli-agentic/swe-1-7
|
||||
24
.env.example
24
.env.example
@@ -1839,6 +1839,18 @@ APP_LOG_TO_FILE=true
|
||||
# ── Devin CLI binary path ──
|
||||
# Used by: open-sse/executors/devin-cli.ts. Default: looked up via PATH.
|
||||
# CLI_DEVIN_BIN=devin
|
||||
# Agentic bridge-only binary override. The bridge still executes ACP stdio only.
|
||||
# CLI_DEVIN_AGENTIC_BIN=devin
|
||||
# Required isolated HOME for the agentic Devin child process.
|
||||
# DEVIN_AGENTIC_HOME=/home/bridge
|
||||
# Bounded ACP turn timeout in milliseconds. Default: 120000.
|
||||
# DEVIN_AGENTIC_ACP_TIMEOUT_MS=120000
|
||||
# Agentic bridge model aliases. Values must keep the devin-cli-agentic/ prefix.
|
||||
# DEVIN_BRIDGE_MODEL=devin-cli-agentic/swe-1-7
|
||||
# DEVIN_BRIDGE_SONNET_MODEL=devin-cli-agentic/swe-1-7
|
||||
# DEVIN_BRIDGE_OPUS_MODEL=devin-cli-agentic/swe-1-7
|
||||
# DEVIN_BRIDGE_HAIKU_MODEL=devin-cli-agentic/swe-1-7
|
||||
# DEVIN_BRIDGE_SUBAGENT_MODEL=devin-cli-agentic/swe-1-7
|
||||
|
||||
# ── Command Code (custom CLI) callback ──
|
||||
# Local port used for OAuth-style callbacks from the Command Code CLI helper.
|
||||
@@ -2300,6 +2312,18 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# HYPERAGENT_USAGE_URL=https://hyperagent.com/api/settings/billing/usage
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ChatGPT Web (Codex) headless browser and outbound tool tunnel
|
||||
# Used by: open-sse/executors/chatgpt-web-codex.ts
|
||||
# Connection values entered in the dashboard override these global defaults.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# CHATGPT_WEB_CODEX_CHROME_PATH=/usr/bin/chromium
|
||||
# CHROME_PATH=/usr/bin/chromium
|
||||
# CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223
|
||||
# CHATGPT_WEB_CODEX_TUNNEL_ID=tunnel_0123456789abcdef0123456789abcdef
|
||||
# CHATGPT_WEB_CODEX_RUNTIME_KEY=
|
||||
# CHATGPT_WEB_CODEX_CONNECTOR_NAME=OmniRoute Codex
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Browser-login VNC sessions (optional — src/lib/vncSession/manifest.ts)
|
||||
# Containerized Chromium+VNC used for interactive browser-login credential
|
||||
|
||||
166
.github/workflows/ci.yml
vendored
166
.github/workflows/ci.yml
vendored
@@ -27,7 +27,7 @@ env:
|
||||
jobs:
|
||||
changes:
|
||||
name: Change Classification
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
outputs:
|
||||
code: ${{ steps.classify.outputs.code }}
|
||||
docs: ${{ steps.classify.outputs.docs }}
|
||||
@@ -35,13 +35,10 @@ jobs:
|
||||
workflow: ${{ steps.classify.outputs.workflow }}
|
||||
testsOnly: ${{ steps.classify.outputs.testsOnly }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
# Refuse a PR that targets its own head branch before spending anything on it. #8912 has
|
||||
# head == base == release/v3.8.50: no diff, can never merge, and it sits in the queue with
|
||||
# a full check board attached on every push to that branch. One field comparison.
|
||||
@@ -77,7 +74,7 @@ jobs:
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
@@ -91,13 +88,9 @@ jobs:
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run audit:deps
|
||||
@@ -171,7 +164,7 @@ jobs:
|
||||
|
||||
quality-gate:
|
||||
name: Quality Ratchet
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
# needs lint so eslint-results artifact is available (same inventory as the
|
||||
# blocking lint step). Allow lint failure so other ratchets still run.
|
||||
needs: [changes, test-coverage, lint]
|
||||
@@ -191,13 +184,9 @@ jobs:
|
||||
contents: read
|
||||
security-events: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- name: Restore ESLint file cache
|
||||
uses: actions/cache@v6
|
||||
@@ -289,7 +278,7 @@ jobs:
|
||||
# SonarQube needs SONAR_TOKEN/SONAR_HOST_URL secrets.
|
||||
quality-extended:
|
||||
name: Quality Gates (Extended)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
@@ -299,14 +288,10 @@ jobs:
|
||||
# fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base
|
||||
# spec via `git show <base_ref>:docs/openapi.yaml`; a shallow clone
|
||||
# would lack the base ref and the gate would self-skip (base-unresolved).
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
# Dead-code, cognitive-complexity, type-coverage foram promovidos ao job
|
||||
# quality-gate (bloqueante) na Fase 7 INT — não rodam aqui para evitar duplo custo.
|
||||
@@ -409,20 +394,16 @@ jobs:
|
||||
|
||||
docs-sync-strict:
|
||||
name: Docs Sync (Strict)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
# Run when docs OR code change: API/route code can break doc/OpenAPI contract gates.
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:docs-all
|
||||
# Previously-orphaned contract gates (existed as files, never wired anywhere).
|
||||
@@ -442,7 +423,7 @@ jobs:
|
||||
|
||||
docs-lint:
|
||||
name: Docs Lint (prose — advisory)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
@@ -452,13 +433,9 @@ jobs:
|
||||
# existing doc corpus is brought up to style. Promote to blocking once it converges.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- name: markdownlint (docs + root, advisory)
|
||||
run: npx --yes markdownlint-cli2 "docs/**/*.md" "*.md" "!docs/i18n" "!docs/research" || true
|
||||
- name: Vale prose lint (Microsoft style, advisory)
|
||||
@@ -473,7 +450,7 @@ jobs:
|
||||
|
||||
i18n-ui-coverage:
|
||||
name: i18n UI Coverage
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
@@ -483,14 +460,10 @@ jobs:
|
||||
# fetch-depth: 0 — the value-drift gate diffs en.json against the merge base to
|
||||
# find rewritten English strings. On a shallow clone the base ref is missing and
|
||||
# the gate self-skips (base-unresolved), so it would never actually run.
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
|
||||
# #8463: a rewritten English value used to leave its 39 translations behind
|
||||
@@ -506,17 +479,13 @@ jobs:
|
||||
# without needing app-boot/Playwright infra. Same gating as i18n-ui-coverage.
|
||||
i18n-glossary-zhcn:
|
||||
name: i18n Glossary (zh-CN)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.i18n == 'true' || needs.changes.outputs.code == 'true')) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN
|
||||
- run: node scripts/i18n/check-glossary-consistency.mjs --locale=zh-TW
|
||||
@@ -528,7 +497,7 @@ jobs:
|
||||
# idioma (a matrix antiga subia 40 artifacts cujo result.txt colidia no merge-multiple).
|
||||
i18n:
|
||||
name: i18n Validation (all languages)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
@@ -536,7 +505,7 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.i18n == 'true') }}
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v7
|
||||
@@ -571,15 +540,12 @@ jobs:
|
||||
pr-test-policy:
|
||||
name: PR Test Policy
|
||||
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft == false }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
- name: Fetch base branch
|
||||
run: git fetch --no-tags origin "${GITHUB_BASE_REF}"
|
||||
- name: Validate source changes include tests
|
||||
@@ -606,17 +572,17 @@ jobs:
|
||||
# online), the heavy jobs run on the dedicated 32-core VPS runners (label
|
||||
# omni-release) instead of queueing on the 20-concurrent-job hosted pool.
|
||||
# Safety: fork PRs NEVER reach the self-hosted runner — the expression falls
|
||||
# back to ubuntu-latest unless the PR head repo is this repository (push /
|
||||
# back to ubuntu-26.04 unless the PR head repo is this repository (push /
|
||||
# dispatch events are own-origin by definition). Any failure path (VM down,
|
||||
# var unset/false) also falls back to ubuntu-latest.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
||||
# var unset/false) also falls back to ubuntu-26.04.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-26.04' }}
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
@@ -656,18 +622,14 @@ jobs:
|
||||
|
||||
package-artifact:
|
||||
name: Package Artifact
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: build
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Download Next.js build artifact
|
||||
@@ -703,15 +665,15 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
os: [ubuntu-26.04, windows-latest]
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
@@ -750,14 +712,14 @@ jobs:
|
||||
|
||||
test-unit:
|
||||
name: Unit Tests (${{ matrix.shard }}/8)
|
||||
# Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-latest).
|
||||
# Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-26.04).
|
||||
# PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable
|
||||
# governed the build and the test jobs, which want OPPOSITE machines: the build needs the
|
||||
# .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 —
|
||||
# actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm
|
||||
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
|
||||
# self-hosted is strictly worse here and there is nothing to configure.
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 25
|
||||
# needs: changes (not build) — this job never downloads the next-build artifact;
|
||||
# gating it on Build only serialized ~20min of wall-clock for nothing. Jobs that
|
||||
@@ -775,13 +737,9 @@ jobs:
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
# QW-d (plano mestre): fonte única — o MESMO npm script dos runs locais (adiciona o
|
||||
@@ -811,31 +769,27 @@ jobs:
|
||||
|
||||
test-bun-sqlite:
|
||||
name: Bun SQLite Compatibility
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 10
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run test:bun:db
|
||||
|
||||
test-vitest:
|
||||
name: Vitest (MCP / autoCombo / UI components)
|
||||
# Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-latest).
|
||||
# Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-26.04).
|
||||
# PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable
|
||||
# governed the build and the test jobs, which want OPPOSITE machines: the build needs the
|
||||
# .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 —
|
||||
# actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm
|
||||
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
|
||||
# self-hosted is strictly worse here and there is nothing to configure.
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
# needs: changes (not build) — no artifact consumed; see test-unit note.
|
||||
needs: changes
|
||||
@@ -845,13 +799,9 @@ jobs:
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
# The second test runner (CLAUDE.md: "Both test runners must pass") — was never
|
||||
# wired into CI until the 2026-06-09 quality audit (Fase 6A.2).
|
||||
@@ -879,7 +829,7 @@ jobs:
|
||||
# the release gate can still exercise them via workflow_dispatch when needed).
|
||||
test-coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
# 10min was sized before #7114 added the lcov reporter (Codecov/Sonar need it);
|
||||
# merging 8 shard JSONs + text+json+lcov now takes ~10-12min — three consecutive
|
||||
# release-tip runs died at exactly 10m as job-timeout "cancelled" (2026-07-15/16).
|
||||
@@ -890,13 +840,9 @@ jobs:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- name: Download all shard coverage
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -980,14 +926,14 @@ jobs:
|
||||
|
||||
sonarqube:
|
||||
name: SonarQube
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: test-coverage
|
||||
if: ${{ !cancelled() && needs.test-coverage.result == 'success' }}
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -1032,7 +978,7 @@ jobs:
|
||||
|
||||
coverage-pr-comment:
|
||||
name: PR Coverage Comment
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false && needs.changes.outputs.code == 'true' }}
|
||||
needs:
|
||||
- changes
|
||||
@@ -1111,7 +1057,7 @@ jobs:
|
||||
|
||||
test-e2e:
|
||||
name: E2E Tests (${{ matrix.shard }}/9)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
# Build artifact from the `build` job is downloaded instead of rebuilding
|
||||
# (~5min saved per shard). 9 shards (up from 6) reduces tests per shard by
|
||||
# ~33%. Playwright browser is cached across runs (~1.5min saved per shard).
|
||||
@@ -1133,13 +1079,9 @@ jobs:
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
OMNIROUTE_PLAYWRIGHT_SKIP_BUILD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Cache Playwright browsers
|
||||
@@ -1188,7 +1130,7 @@ jobs:
|
||||
|
||||
test-integration:
|
||||
name: Integration Tests (${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
# needs: changes (not build) — no artifact consumed; see test-unit note.
|
||||
needs: changes
|
||||
@@ -1204,13 +1146,9 @@ jobs:
|
||||
DATA_DIR: /tmp/omniroute-ci-${{ matrix.shard }}
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
# (tsx/esm = QW-b; o alinhamento de ESCOPO do integration com o npm script fica p/ follow-up)
|
||||
@@ -1218,7 +1156,7 @@ jobs:
|
||||
|
||||
test-security:
|
||||
name: Security Tests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
# needs: changes (not build) — no artifact consumed; see test-unit note.
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
@@ -1227,20 +1165,16 @@ jobs:
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:security
|
||||
|
||||
ci-summary:
|
||||
name: CI Dashboard
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
if: ${{ !cancelled() }}
|
||||
needs:
|
||||
- changes
|
||||
|
||||
4
.github/workflows/claude.yml
vendored
4
.github/workflows/claude.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -13,7 +13,7 @@ permissions:
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (javascript-typescript)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
|
||||
6
.github/workflows/dast-smoke.yml
vendored
6
.github/workflows/dast-smoke.yml
vendored
@@ -18,7 +18,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
dast-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
# ADVISORY while this new gate matures (repo convention: advisory -> blocking).
|
||||
# Flip to blocking (remove continue-on-error) once it's proven stable across a few PRs.
|
||||
continue-on-error: true
|
||||
@@ -33,10 +33,6 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
|
||||
2
.github/workflows/deploy-vps.yml
vendored
2
.github/workflows/deploy-vps.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
(github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success')
|
||||
&& vars.DEPLOY_ENABLED == 'true'
|
||||
name: Deploy OmniRoute to VPS
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: Check VPS SSH reachability from runner
|
||||
id: reach
|
||||
|
||||
10
.github/workflows/docker-publish.yml
vendored
10
.github/workflows/docker-publish.yml
vendored
@@ -33,7 +33,7 @@ permissions:
|
||||
jobs:
|
||||
prepare:
|
||||
name: Resolve Docker release metadata
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
promote_latest: ${{ steps.version.outputs.promote_latest }}
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
IMAGE_NAME: diegosouzapw/omniroute
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
@@ -233,7 +233,7 @@ jobs:
|
||||
- prepare
|
||||
- build
|
||||
if: needs.prepare.outputs.skip != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -245,7 +245,7 @@ jobs:
|
||||
PROMOTE_LATEST: ${{ needs.prepare.outputs.promote_latest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
|
||||
16
.github/workflows/electron-release.yml
vendored
16
.github/workflows/electron-release.yml
vendored
@@ -20,14 +20,14 @@ permissions:
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate version
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.validate.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -78,17 +78,17 @@ jobs:
|
||||
target: mac-arm64
|
||||
ext: -arm64.dmg
|
||||
- platform: linux
|
||||
runner: ubuntu-latest
|
||||
runner: ubuntu-26.04
|
||||
target: linux
|
||||
ext: .AppImage
|
||||
deb_ext: .deb
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
@@ -239,12 +239,12 @@ jobs:
|
||||
# Now: attach everything that did build, then fail the job loudly (see the last
|
||||
# step) so an incomplete channel is visible instead of silent.
|
||||
if: ${{ !cancelled() && needs.validate.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: write # softprops/action-gh-release creates the GitHub Release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -329,7 +329,7 @@ jobs:
|
||||
# of passing unnoticed — the v3.8.49 release had ZERO assets and every gate was
|
||||
# green, because nothing ever asserted the release HAS binaries.
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
|
||||
4
.github/workflows/lock-released-branch.yml
vendored
4
.github/workflows/lock-released-branch.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
lock-branch:
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: Lock release/<tag> branch
|
||||
env:
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
guard-no-push-after-release:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: Reject push if matching release tag exists
|
||||
env:
|
||||
|
||||
8
.github/workflows/mutation-redundancy.yml
vendored
8
.github/workflows/mutation-redundancy.yml
vendored
@@ -22,7 +22,7 @@ permissions:
|
||||
jobs:
|
||||
stryker-nobail:
|
||||
name: Stryker disableBail (batch ${{ matrix.batch.name }})
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -41,13 +41,9 @@ jobs:
|
||||
mutate: "open-sse/handlers/chatCore/telemetryHelpers.ts,open-sse/handlers/chatCore/memorySkillsInjection.ts,open-sse/handlers/chatCore/semanticCache.ts"
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Run Stryker (disableBail)
|
||||
env:
|
||||
|
||||
20
.github/workflows/nightly-compat.yml
vendored
20
.github/workflows/nightly-compat.yml
vendored
@@ -28,11 +28,11 @@ concurrency:
|
||||
jobs:
|
||||
resolve-branch:
|
||||
name: Resolve active release branch
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
outputs:
|
||||
target: ${{ steps.branch.outputs.target }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -58,15 +58,15 @@ jobs:
|
||||
|
||||
compat-build-26:
|
||||
name: Node 26 Compatibility Build
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 25
|
||||
needs: resolve-branch
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.resolve-branch.outputs.target }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "26"
|
||||
cache: npm
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
# CI_NODE_VERSION=24). It failed every nightly with the runner-reclaimed
|
||||
# signature ("The runner has received a shutdown signal" / "The operation was
|
||||
# canceled", no exit code) always at the same Turbopack compile phase — a
|
||||
# classic OOM kill on the memory-constrained ubuntu-latest runner. Turbopack's
|
||||
# classic OOM kill on the memory-constrained 16 GB hosted runner. Turbopack's
|
||||
# native (Rust, off-V8-heap) allocation is NOT bounded by --max-old-space-size
|
||||
# and peaks far higher than webpack on this large module graph (#6409), and is
|
||||
# heavier still under Node 26. Use the documented webpack fallback here: it still
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
|
||||
compat-tests:
|
||||
name: Node ${{ matrix.node }} Compat Tests (${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 25
|
||||
needs: resolve-branch
|
||||
strategy:
|
||||
@@ -102,11 +102,11 @@ jobs:
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
TEST_SHARD: ${{ matrix.shard }}/4
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.resolve-branch.outputs.target }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
|
||||
report:
|
||||
name: Open / update tracking issue on failure
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
if: ${{ !cancelled() && (needs.compat-tests.result == 'failure' || needs.compat-build-26.result == 'failure') }}
|
||||
needs: [resolve-branch, compat-build-26, compat-tests]
|
||||
permissions:
|
||||
|
||||
13
.github/workflows/nightly-llm-security.yml
vendored
13
.github/workflows/nightly-llm-security.yml
vendored
@@ -10,13 +10,11 @@ permissions:
|
||||
jobs:
|
||||
promptfoo-guard:
|
||||
name: promptfoo — injection guard (block mode, no secret)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: "24", cache: npm }
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
@@ -46,7 +44,7 @@ jobs:
|
||||
|
||||
garak:
|
||||
name: garak probes (skip without provider secret)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
# NOTE: the `secrets` context is NOT available in a job-level `if:` — referencing
|
||||
# it there makes GitHub reject the file on push (startup_failure on every push).
|
||||
# Map the secret into a job-level env and gate each step on a presence check, so
|
||||
@@ -63,13 +61,10 @@ jobs:
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::PROMPTFOO_PROVIDER_KEY not set — skipping garak probes (advisory)."
|
||||
fi
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
- uses: actions/setup-node@v7
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
with: { node-version: "24", cache: npm }
|
||||
- run: npm ci
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
- name: Build CLI bundle
|
||||
|
||||
15
.github/workflows/nightly-mutation.yml
vendored
15
.github/workflows/nightly-mutation.yml
vendored
@@ -10,7 +10,7 @@ permissions:
|
||||
jobs:
|
||||
stryker:
|
||||
name: Stryker mutation (batch ${{ matrix.batch.name }} — advisory)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
# Mutation testing is expensive. History of the budget:
|
||||
# - Full 8-module set TIMED OUT at the 180min cap (run 27705123780 = exactly 180min).
|
||||
# The two god-files chatCore.ts/combo.ts dominated ~2/3 of the mutants and were
|
||||
@@ -104,13 +104,9 @@ jobs:
|
||||
# scripts/quality/mutation-radiography.mjs both merge per file).
|
||||
timeout-minutes: ${{ matrix.batch.timeout || 180 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Restore Stryker incremental cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
@@ -145,15 +141,12 @@ jobs:
|
||||
name: Mutation score ratchet (blocking)
|
||||
needs: stryker
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Download all mutation reports
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
|
||||
8
.github/workflows/nightly-property.yml
vendored
8
.github/workflows/nightly-property.yml
vendored
@@ -8,15 +8,11 @@ permissions:
|
||||
issues: write
|
||||
jobs:
|
||||
property-random-seed:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: fast-check random seed (high runs)
|
||||
id: prop
|
||||
|
||||
21
.github/workflows/nightly-release-green.yml
vendored
21
.github/workflows/nightly-release-green.yml
vendored
@@ -68,13 +68,13 @@ jobs:
|
||||
# this runs on the dedicated VPS runner — clean env (no operator OMNIROUTE_API_KEY,
|
||||
# no local noauth CLIs => zero machine-specific false positives) and no contention.
|
||||
# Nightly cron normally finds the var false (VM off) and falls back to hosted.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-26.04' }}
|
||||
env:
|
||||
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-nightly-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
git checkout "$TARGET"
|
||||
git log -1 --oneline
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
@@ -217,19 +217,19 @@ jobs:
|
||||
# On a push, only run for a push to main — a push to release/* is handled by
|
||||
# release-green above. Schedule/dispatch always run (they also sweep main).
|
||||
if: ${{ github.event_name != 'push' || github.ref_name == 'main' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-26.04' }}
|
||||
env:
|
||||
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-nightly-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main # literal — no injection surface; scheduled runs default to the repo default branch (a release/v*), so pin main explicitly
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
@@ -331,12 +331,12 @@ jobs:
|
||||
bank-ratchet-shrinks:
|
||||
name: Bank ratchet shrinks
|
||||
if: ${{ github.event_name != 'push' }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -371,11 +371,6 @@ jobs:
|
||||
git checkout "$TARGET"
|
||||
git log -1 --oneline
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
|
||||
- name: Ratchet the baselines down
|
||||
|
||||
32
.github/workflows/nightly-resilience.yml
vendored
32
.github/workflows/nightly-resilience.yml
vendored
@@ -10,43 +10,31 @@ permissions:
|
||||
jobs:
|
||||
heap:
|
||||
name: Heap-growth gate
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:heap
|
||||
|
||||
chaos:
|
||||
name: Resilience chaos (fault injection)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:chaos
|
||||
|
||||
k6-soak:
|
||||
name: k6 load/soak
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
@@ -78,7 +66,7 @@ jobs:
|
||||
|
||||
a11y:
|
||||
name: A11y axe (nightly, freeze-and-alert)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
# The Playwright webServer (`start` mode) builds Next via build-next-isolated.mjs and
|
||||
# boots the standalone server itself (waits on /api/monitoring/health, 15min webServer
|
||||
# timeout). Unlike the per-PR test-e2e job, this nightly job has no pre-built artifact,
|
||||
@@ -92,13 +80,9 @@ jobs:
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
REQUIRE_AXE: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v6.1.0
|
||||
|
||||
6
.github/workflows/nightly-schemathesis.yml
vendored
6
.github/workflows/nightly-schemathesis.yml
vendored
@@ -10,14 +10,12 @@ permissions:
|
||||
jobs:
|
||||
schemathesis:
|
||||
name: Schemathesis — OpenAPI contract fuzz (advisory)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: "24", cache: npm }
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
|
||||
12
.github/workflows/npm-publish.yml
vendored
12
.github/workflows/npm-publish.yml
vendored
@@ -62,7 +62,7 @@ jobs:
|
||||
# mid-"Creating an optimized production build" while v3.8.48 had still fit in 16min.
|
||||
# This job never runs on `pull_request`, so the fork-safety clause is always true here;
|
||||
# it is kept verbatim so the expression stays greppable against ci.yml.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-26.04' }}
|
||||
permissions:
|
||||
actions: read # find + download the CI run's next-build artifact for this SHA
|
||||
contents: write # gh release upload (attach SBOM to the GitHub Release)
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
packages: write # publish to npm.pkg.github.com
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Need full tag history to compare against highest semver when
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
@@ -339,20 +339,20 @@ jobs:
|
||||
echo "✅ Action finished for GitHub Packages"
|
||||
|
||||
publish-opencode-plugin:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # npm provenance
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
# Full history needed for auto-bump: git diff against previous release tag
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
12
.github/workflows/opencode-plugin-ci.yml
vendored
12
.github/workflows/opencode-plugin-ci.yml
vendored
@@ -26,16 +26,16 @@ defaults:
|
||||
jobs:
|
||||
test:
|
||||
name: Test (Node ${{ matrix.node }})
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ["22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
@@ -46,13 +46,13 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
|
||||
12
.github/workflows/opencode-provider-ci.yml
vendored
12
.github/workflows/opencode-provider-ci.yml
vendored
@@ -26,16 +26,16 @@ defaults:
|
||||
jobs:
|
||||
test:
|
||||
name: Test (Node ${{ matrix.node }})
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ["20", "22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
@@ -45,13 +45,13 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
|
||||
105
.github/workflows/quality.yml
vendored
105
.github/workflows/quality.yml
vendored
@@ -25,20 +25,17 @@ jobs:
|
||||
# path filters share existence reasons: code / docs / i18n / workflow.
|
||||
changes:
|
||||
name: Change Classification
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
outputs:
|
||||
code: ${{ steps.classify.outputs.code }}
|
||||
docs: ${{ steps.classify.outputs.docs }}
|
||||
i18n: ${{ steps.classify.outputs.i18n }}
|
||||
workflow: ${{ steps.classify.outputs.workflow }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
- id: classify
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
@@ -61,19 +58,16 @@ jobs:
|
||||
name: Build (advisory)
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
||||
# Dynamic runner — same fork-safe rule as ci.yml / fast-gates.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
||||
# Fork-safe fallback uses Ubuntu 26.04's bundled Node 24 without setup-node.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-26.04' }}
|
||||
# #7307: advisory for the first week of release-PR runs; remove
|
||||
# continue-on-error after the production-build signal is stable.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: node -e 'if (process.versions.node.split(".")[0] !== process.env.CI_NODE_VERSION) throw new Error("Expected Node " + process.env.CI_NODE_VERSION)'
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run build
|
||||
@@ -88,15 +82,11 @@ jobs:
|
||||
name: Docs Gates (fast-path)
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# One walk of src/app/api for openapi-routes + docs-symbols (both still fail independently).
|
||||
- run: npm run check:api-docs-refs
|
||||
@@ -111,7 +101,7 @@ jobs:
|
||||
# Dynamic runner (same rule as ci.yml): use the self-hosted VPS pool only when the
|
||||
# release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin
|
||||
# branches only — a fork PR must never execute on the LAN runner). Var unset/false
|
||||
# or a fork PR falls back to ubuntu-latest, so this is inert until the flag flips.
|
||||
# or a fork PR falls back to ubuntu-26.04, so this is inert until the flag flips.
|
||||
# PINNED to hosted (gap 19). This job carried the USE_VPS_RUNNER expression, and that
|
||||
# expression was DEAD CONFIGURATION: across 160 quality.yml runs the job never once landed on
|
||||
# a self-hosted runner — every non-skipped sample is `GitHub Actions NNNN`. The classifier is
|
||||
@@ -125,7 +115,7 @@ jobs:
|
||||
#
|
||||
# With this pinned, USE_VPS_RUNNER governs ONLY build-like jobs — one variable, one coherent
|
||||
# purpose. That is what gap 19 asked for; a second variable turned out to be unnecessary.
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
# tsx gates (known-symbols, route-guard-membership) import modules that open
|
||||
# SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
|
||||
env:
|
||||
@@ -133,14 +123,10 @@ jobs:
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Restore ESLint file cache
|
||||
uses: actions/cache@v6
|
||||
@@ -279,7 +265,7 @@ jobs:
|
||||
# selector returns __RUN_ALL__ — full-suite authority is the parallel
|
||||
# `fast-unit` 4-shard job (test:unit:ci:shard; was 2-shard, #6781), NOT an
|
||||
# unsharded re-run here. Stacking unsharded test:unit:ci on top of fast-unit
|
||||
# doubled wall time (~16 min extra on ubuntu-latest) without extra coverage.
|
||||
# doubled wall time (~16 min extra on the hosted runner) without extra coverage.
|
||||
#
|
||||
# BLOCKING for the *impacted subset* (flipped 2026-06-17). Fail-safe full
|
||||
# coverage remains required via `Unit Tests fast-path` (fast-unit).
|
||||
@@ -343,36 +329,15 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
fast-vitest:
|
||||
name: Vitest (fast-path)
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
||||
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
|
||||
# PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable
|
||||
# governed the build and the test jobs, which want OPPOSITE machines: the build needs the
|
||||
# .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 —
|
||||
# actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm
|
||||
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
|
||||
# self-hosted is strictly worse here and there is nothing to configure.
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR,
|
||||
# which is where flaky-detection volume actually comes from (ci.yml's heavy
|
||||
# jobs only run on the release PR). Advisory upload, own-origin only.
|
||||
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-fastpath.xml
|
||||
- name: Upload test results to Trunk (advisory)
|
||||
# Share fast-gates' checkout + npm ci instead of spending ~80s preparing a
|
||||
# separate runner for a ~13s Vitest invocation. !cancelled() preserves the
|
||||
# independent test signal when an earlier fast gate fails.
|
||||
- name: Vitest
|
||||
if: ${{ !cancelled() }}
|
||||
run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-fastpath.xml
|
||||
# WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast path runs on every PR.
|
||||
# Advisory upload, own-origin only.
|
||||
- name: Upload Vitest results to Trunk (advisory)
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
continue-on-error: true
|
||||
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
|
||||
@@ -385,9 +350,9 @@ jobs:
|
||||
name: Unit Tests fast-path (${{ matrix.shard }}/4)
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
||||
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
|
||||
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-26.04).
|
||||
# This is the heaviest fast-path job; 4-way sharding (was 2, #6781) halves the
|
||||
# critical path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot
|
||||
# critical path again (~8.5min → ~4.5min hosted; ~2min on the 8-slot
|
||||
# runner box). Node's native --test-shard=N/total takes any denominator — only
|
||||
# this matrix and the TEST_SHARD env below encode the shard count.
|
||||
# PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable
|
||||
@@ -396,7 +361,7 @@ jobs:
|
||||
# actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm
|
||||
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
|
||||
# self-hosted is strictly worse here and there is nothing to configure.
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -406,13 +371,9 @@ jobs:
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# QW-d: fonte única — o mesmo npm script do CI pesado/local. Fecha dois drifts do
|
||||
# comando inline antigo: os dirs `memory` e `usage` estavam FORA do glob (testes
|
||||
@@ -437,7 +398,7 @@ jobs:
|
||||
name: No new ESLint warnings
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
|
||||
# G0 (trilho .50): security-events:read lets the CodeQL ratchet below read open
|
||||
# code-scanning alerts via `gh api .../code-scanning/alerts` (same as ci.yml's
|
||||
@@ -446,13 +407,9 @@ jobs:
|
||||
contents: read
|
||||
security-events: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Restore ESLint file cache
|
||||
uses: actions/cache@v6
|
||||
@@ -505,21 +462,17 @@ jobs:
|
||||
name: Merge integrity (changelog + generated skills)
|
||||
# Always on non-draft PRs — CHANGELOG/skills can break on docs-only merges too.
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
|
||||
env:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: CHANGELOG integrity (nenhum bullet da base pode sumir no merge-result)
|
||||
run: npm run check:changelog-integrity
|
||||
|
||||
4
.github/workflows/scorecard.yml
vendored
4
.github/workflows/scorecard.yml
vendored
@@ -11,7 +11,7 @@ permissions: read-all
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
# security-events: write removed — Scorecard findings are advisory and no longer
|
||||
# uploaded to the code-scanning Security tab (they are supply-chain/posture scores,
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/semgrep.yml
vendored
2
.github/workflows/semgrep.yml
vendored
@@ -14,7 +14,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
semgrep:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
container:
|
||||
image: semgrep/semgrep
|
||||
steps:
|
||||
|
||||
9
.github/workflows/wiki-sync.yml
vendored
9
.github/workflows/wiki-sync.yml
vendored
@@ -34,15 +34,10 @@ concurrency:
|
||||
jobs:
|
||||
sync-wiki:
|
||||
name: Sync wiki with docs
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Clone wiki
|
||||
env:
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -72,6 +72,7 @@ yarn-error.log*
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.devin-bridge.example
|
||||
!.env.homolog.example
|
||||
# Provider API keys (never commit)
|
||||
*.api-key
|
||||
@@ -209,6 +210,8 @@ scripts/i18n/_pending-keys.json
|
||||
.agents/
|
||||
.antigravitycli/
|
||||
.claude/
|
||||
!tests/fixtures/devin-bridge/e2e-workspace/.claude/
|
||||
!tests/fixtures/devin-bridge/e2e-workspace/.claude/**
|
||||
|
||||
# PR Reviews and local feedback files
|
||||
pr_reviews*.json
|
||||
@@ -245,6 +248,8 @@ _artifacts/ # release-green artifacts
|
||||
|
||||
# CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.)
|
||||
.artifacts/
|
||||
# Isolated Devin bridge workspaces, evidence, and test databases
|
||||
.sandbox/
|
||||
|
||||
# Homologation E2E suite (npm run homolog) — real-environment credentials + report output
|
||||
.env.homolog
|
||||
|
||||
4
@omniroute/opencode-plugin/package-lock.json
generated
4
@omniroute/opencode-plugin/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
|
||||
5
@omniroute/openhands-plugin/.gitignore
vendored
Normal file
5
@omniroute/openhands-plugin/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
21
@omniroute/openhands-plugin/LICENSE
Normal file
21
@omniroute/openhands-plugin/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 OmniRoute contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
118
@omniroute/openhands-plugin/README.md
Normal file
118
@omniroute/openhands-plugin/README.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# @omniroute/openhands-plugin
|
||||
|
||||
OpenHands integration for the **OmniRoute AI Gateway**. Generates the OpenHands
|
||||
environment and Docker config that wires an OpenHands agent-server to a running
|
||||
OmniRoute instance — with the integration gotchas already handled.
|
||||
|
||||
## Why
|
||||
|
||||
Running OpenHands against OmniRoute directly hits several wall:
|
||||
|
||||
1. **Model name mismatch** — OpenHands sends `model: "deepseek-chat"`, OmniRoute
|
||||
uses provider-prefixed IDs (`ds/deepseek-v4-flash`) or combos.
|
||||
2. **Python 3.13 sandbox** — `socket.socketpair()` fails under Docker's default
|
||||
seccomp profile; the agent-server needs `privileged: true`.
|
||||
3. **Host reachability** — the sandbox can't resolve `localhost` to the OmniRoute
|
||||
host; needs `host.docker.internal:host-gateway`.
|
||||
4. **Lost state** — conversations die with the container unless
|
||||
`OH_PERSISTENCE_DIR` is a host volume.
|
||||
5. **CORS** — the dashboard origin can't reach agent-server unless
|
||||
`PERMITTED_CORS_ORIGINS` allows it.
|
||||
|
||||
This plugin encodes all of that into one command.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install -g @omniroute/openhands-plugin
|
||||
# or: npx @omniroute/openhands-plugin ...
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
Generate the OpenHands `.env`:
|
||||
|
||||
```bash
|
||||
omniroute-openhands env \
|
||||
--api-key sk-... \
|
||||
--model deepseek-chat \
|
||||
--url http://192.168.3.106:20128
|
||||
```
|
||||
|
||||
Generate a `docker-compose.yml` service:
|
||||
|
||||
```bash
|
||||
omniroute-openhands compose \
|
||||
--api-key sk-... \
|
||||
--model glm-5.2 \
|
||||
--persistence-dir /Users/me/.openhands-state \
|
||||
--cors-origins http://100.73.44.17:3000
|
||||
```
|
||||
|
||||
Or a plain `docker run`:
|
||||
|
||||
```bash
|
||||
omniroute-openhands docker-run \
|
||||
--api-key sk-... \
|
||||
--model vivanta-core \
|
||||
--persistence-dir /Users/me/.openhands-state
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `env` | Print OpenHands `.env` contents |
|
||||
| `compose` | Print a Docker Compose service block |
|
||||
| `docker-run` | Print a full `docker run` command |
|
||||
| `models` | Print the default OpenHands → OmniRoute model map |
|
||||
|
||||
### Common options
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--api-key` | OmniRoute API key (`sk-...`) | — |
|
||||
| `--model` | OpenHands model name or OmniRoute combo | — |
|
||||
| `--url` | OmniRoute base URL | `http://localhost:20128` |
|
||||
| `--persistence-dir` | Host dir for conversation state | `.openhands-state` |
|
||||
| `--cors-origins` | Comma-separated allowed origins | `localhost:3000,3001` |
|
||||
| `--sandbox-image` | OpenHands sandbox base image | — |
|
||||
|
||||
## Model mapping
|
||||
|
||||
OpenHands-friendly names are mapped to OmniRoute IDs/combo names:
|
||||
|
||||
| OpenHands sends | OmniRoute resolves to |
|
||||
|-----------------|----------------------|
|
||||
| `deepseek-chat` | `ds/deepseek-v4-flash` |
|
||||
| `deepseek-reasoner` | `ds/deepseek-v4-pro` |
|
||||
| `glm-5.2` | `nvidia/z-ai/glm-5.2` |
|
||||
| `gpt-4o` | `openai/gpt-4o` |
|
||||
| `claude-sonnet-4.5` | `anthropic/claude-sonnet-4.5` |
|
||||
| ... | ... |
|
||||
|
||||
Or just pass an OmniRoute combo name (e.g. `--model vivanta-core`) — the Model
|
||||
Alias Resolver and combo router accept it directly.
|
||||
|
||||
## Library usage
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildOpenHandsEnv,
|
||||
serializeOpenHandsEnv,
|
||||
buildOpenHandsCompose,
|
||||
resolveOpenHandsModel,
|
||||
} from "@omniroute/openhands-plugin";
|
||||
|
||||
const env = buildOpenHandsEnv({
|
||||
apiKey: "sk-...",
|
||||
model: resolveOpenHandsModel("deepseek-chat"),
|
||||
omnirouteUrl: "http://localhost:20128",
|
||||
persistenceDir: "/Users/me/.openhands-state",
|
||||
});
|
||||
console.log(serializeOpenHandsEnv(env));
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — same as OmniRoute.
|
||||
2033
@omniroute/openhands-plugin/package-lock.json
generated
Normal file
2033
@omniroute/openhands-plugin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
79
@omniroute/openhands-plugin/package.json
Normal file
79
@omniroute/openhands-plugin/package.json
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "@omniroute/openhands-plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "OpenHands integration for the OmniRoute AI Gateway. Generates OpenHands env + Docker Compose config (model mapping, sandbox, CORS, persistence) so OpenHands agents talk to OmniRoute out of the box.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"omniroute-openhands": "./dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./env": {
|
||||
"types": "./dist/env.d.ts",
|
||||
"import": "./dist/env.js"
|
||||
},
|
||||
"./docker": {
|
||||
"types": "./dist/docker.d.ts",
|
||||
"import": "./dist/docker.js"
|
||||
},
|
||||
"./model-map": {
|
||||
"types": "./dist/model-map.d.ts",
|
||||
"import": "./dist/model-map.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "node --import tsx/esm --test tests/env.test.ts tests/model-map.test.ts tests/docker.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
"omniroute",
|
||||
"openhands",
|
||||
"open-hands",
|
||||
"openhands-plugin",
|
||||
"openai-compatible",
|
||||
"docker",
|
||||
"agent"
|
||||
],
|
||||
"author": "OmniRoute contributors",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/diegosouzapw/OmniRoute.git",
|
||||
"directory": "@omniroute/openhands-plugin"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/diegosouzapw/OmniRoute/issues"
|
||||
},
|
||||
"homepage": "https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/openhands-plugin#readme",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@omniroute/open-sse": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@omniroute/open-sse": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.19",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.22.3"
|
||||
}
|
||||
}
|
||||
104
@omniroute/openhands-plugin/src/cli.ts
Normal file
104
@omniroute/openhands-plugin/src/cli.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @omniroute/openhands-plugin CLI — generate OpenHands .env / Docker config
|
||||
* for a running OmniRoute instance.
|
||||
*
|
||||
* Usage:
|
||||
* omniroute-openhands env --api-key sk-... --model deepseek-chat [--url http://localhost:20128]
|
||||
* omniroute-openhands compose --api-key sk-... --model deepseek-chat [--persistence-dir /path]
|
||||
* omniroute-openhands docker-run --api-key sk-... --model deepseek-chat
|
||||
* omniroute-openhands models (print the default model map)
|
||||
*/
|
||||
import { buildOpenHandsEnv, serializeOpenHandsEnv } from "./env.ts";
|
||||
import { buildOpenHandsCompose, buildOpenHandsDockerRun } from "./docker.ts";
|
||||
import { DEFAULT_OPENHANDS_MODEL_MAP } from "./model-map.ts";
|
||||
|
||||
function parseArgs(argv: string[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (!arg.startsWith("--")) continue;
|
||||
const key = arg.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next !== undefined && !next.startsWith("--")) {
|
||||
out[key] = next;
|
||||
i++;
|
||||
} else {
|
||||
out[key] = "true";
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function requireArgs(args: Record<string, string>, names: string[]): void {
|
||||
for (const name of names) {
|
||||
if (!args[name]) {
|
||||
console.error(`Missing required --${name}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
const args = parseArgs(rest);
|
||||
|
||||
switch (cmd) {
|
||||
case "env": {
|
||||
requireArgs(args, ["api-key", "model"]);
|
||||
const env = buildOpenHandsEnv({
|
||||
apiKey: args["api-key"],
|
||||
model: args.model,
|
||||
omnirouteUrl: args.url,
|
||||
persistenceDir: args["persistence-dir"],
|
||||
corsOrigins: args["cors-origins"]?.split(","),
|
||||
});
|
||||
process.stdout.write(serializeOpenHandsEnv(env));
|
||||
break;
|
||||
}
|
||||
case "compose": {
|
||||
requireArgs(args, ["api-key", "model"]);
|
||||
process.stdout.write(
|
||||
buildOpenHandsCompose({
|
||||
apiKey: args["api-key"],
|
||||
model: args.model,
|
||||
omnirouteUrl: args.url,
|
||||
persistenceDir: args["persistence-dir"] ?? ".openhands-state",
|
||||
corsOrigins: args["cors-origins"]?.split(","),
|
||||
sandboxBaseImage: args["sandbox-image"],
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "docker-run": {
|
||||
requireArgs(args, ["api-key", "model"]);
|
||||
process.stdout.write(
|
||||
buildOpenHandsDockerRun({
|
||||
apiKey: args["api-key"],
|
||||
model: args.model,
|
||||
omnirouteUrl: args.url,
|
||||
persistenceDir: args["persistence-dir"] ?? ".openhands-state",
|
||||
corsOrigins: args["cors-origins"]?.split(","),
|
||||
sandboxBaseImage: args["sandbox-image"],
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "models": {
|
||||
for (const [name, target] of Object.entries(DEFAULT_OPENHANDS_MODEL_MAP)) {
|
||||
process.stdout.write(`${name}\t->\t${target}\n`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(
|
||||
"Usage: omniroute-openhands <env|compose|docker-run|models> [options]\n" +
|
||||
"Options:\n" +
|
||||
" --api-key <sk-...> OmniRoute API key (required for env/compose/docker-run)\n" +
|
||||
" --model <name> OpenHands model name or OmniRoute combo\n" +
|
||||
" --url <base> OmniRoute URL (default http://localhost:20128)\n" +
|
||||
" --persistence-dir <path> Host dir for conversation state\n" +
|
||||
" --cors-origins <a,b,...> Allowed CORS origins\n" +
|
||||
" --sandbox-image <image> OpenHands sandbox base image"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
92
@omniroute/openhands-plugin/src/docker.ts
Normal file
92
@omniroute/openhands-plugin/src/docker.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* OpenHands agent-server Docker Compose generator for OmniRoute.
|
||||
*
|
||||
* Bakes in the integration fixes that were needed to run OpenHands against
|
||||
* OmniRoute reliably:
|
||||
* - `privileged: true` — Python 3.13 socket.socketpair() needs it under
|
||||
* Docker's default seccomp profile
|
||||
* - `extra_hosts` — host.docker.internal → host-gateway so the
|
||||
* sandbox can reach OmniRoute on the host
|
||||
* - host volume for OH_PERSISTENCE_DIR so conversations survive `docker rm`
|
||||
* - PERMITTED_CORS_ORIGINS — allow the dashboard origin to hit agent-server
|
||||
*/
|
||||
|
||||
export interface OpenHandsDockerOptions {
|
||||
/** Agent-server image (default: the official OpenHands runtime image). */
|
||||
image?: string;
|
||||
/** Container name (default: openhands-agent). */
|
||||
containerName?: string;
|
||||
/** Model name to pass via LLM_MODEL. */
|
||||
model: string;
|
||||
/** OmniRoute API key. */
|
||||
apiKey: string;
|
||||
/** OmniRoute base URL reachable from the sandbox (default http://localhost:20128). */
|
||||
omnirouteUrl?: string;
|
||||
/** Host directory for OH_PERSISTENCE_DIR (must match env.ts persistenceDir). */
|
||||
persistenceDir: string;
|
||||
/** CORS origins to permit. */
|
||||
corsOrigins?: string[];
|
||||
/** Sandbox base image (defaults to OpenHands default). */
|
||||
sandboxBaseImage?: string;
|
||||
/** Set true to use host networking instead of extra_hosts. */
|
||||
hostNetwork?: boolean;
|
||||
}
|
||||
|
||||
export function buildOpenHandsCompose(opts: OpenHandsDockerOptions): string {
|
||||
const image = opts.image ?? "docker.all-hands.dev/all-hands-ai/openhands:latest";
|
||||
const containerName = opts.containerName ?? "openhands-agent";
|
||||
const omnirouteHost = (opts.omnirouteUrl ?? "http://localhost:20128").replace(/\/+$/, "");
|
||||
const cors =
|
||||
opts.corsOrigins && opts.corsOrigins.length > 0
|
||||
? opts.corsOrigins
|
||||
: ["http://localhost:3000", "http://localhost:3001"];
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`services:`);
|
||||
lines.push(` openhands:`);
|
||||
lines.push(` image: ${image}`);
|
||||
lines.push(` container_name: ${containerName}`);
|
||||
lines.push(` privileged: true`);
|
||||
lines.push(` environment:`);
|
||||
lines.push(` LLM_MODEL: "${opts.model}"`);
|
||||
lines.push(` LLM_BASE_URL: "${omnirouteHost}/v1"`);
|
||||
lines.push(` LLM_API_KEY: "${opts.apiKey}"`);
|
||||
lines.push(` OH_PERSISTENCE_DIR: "/opt/.openhands-state"`);
|
||||
lines.push(` PERMITTED_CORS_ORIGINS: "${cors.join(",")}"`);
|
||||
if (opts.sandboxBaseImage) {
|
||||
lines.push(` SANDBOX_BASE_IMAGE: "${opts.sandboxBaseImage}"`);
|
||||
}
|
||||
lines.push(` volumes:`);
|
||||
lines.push(` - ${opts.persistenceDir}:/opt/.openhands-state`);
|
||||
lines.push(` extra_hosts:`);
|
||||
lines.push(` - "host.docker.internal:host-gateway"`);
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* docker run equivalent of {@link buildOpenHandsCompose} — returns the full
|
||||
* `docker run` command line.
|
||||
*/
|
||||
export function buildOpenHandsDockerRun(opts: OpenHandsDockerOptions): string {
|
||||
const image = opts.image ?? "docker.all-hands.dev/all-hands-ai/openhands:latest";
|
||||
const omnirouteHost = (opts.omnirouteUrl ?? "http://localhost:20128").replace(/\/+$/, "");
|
||||
const cors =
|
||||
opts.corsOrigins && opts.corsOrigins.length > 0
|
||||
? opts.corsOrigins
|
||||
: ["http://localhost:3000", "http://localhost:3001"];
|
||||
|
||||
const parts = [
|
||||
"docker run",
|
||||
"--privileged",
|
||||
"--add-host host.docker.internal:host-gateway",
|
||||
`-e LLM_MODEL="${opts.model}"`,
|
||||
`-e LLM_BASE_URL="${omnirouteHost}/v1"`,
|
||||
`-e LLM_API_KEY="${opts.apiKey}"`,
|
||||
`-e OH_PERSISTENCE_DIR=/opt/.openhands-state`,
|
||||
`-e PERMITTED_CORS_ORIGINS="${cors.join(",")}"`,
|
||||
`-v "${opts.persistenceDir}:/opt/.openhands-state"`,
|
||||
image,
|
||||
];
|
||||
return parts.join(" ") + "\n";
|
||||
}
|
||||
63
@omniroute/openhands-plugin/src/env.ts
Normal file
63
@omniroute/openhands-plugin/src/env.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* OpenHands `.env` generator for the OmniRoute AI Gateway.
|
||||
*
|
||||
* Produces the OpenHands environment that points an OpenHands agent-server at
|
||||
* a running OmniRoute instance and fixes the integration gotchas found in the
|
||||
* field:
|
||||
* - LLM_MODEL — OpenHands-friendly model name → OmniRoute model/combo
|
||||
* - LLM_BASE_URL — OmniRoute OpenAI-compatible endpoint
|
||||
* - LLM_API_KEY — OmniRoute key (sk-...)
|
||||
* - OH_PERSISTENCE_DIR — host-mounted SQLite/conversation persistence
|
||||
* - PERMITTED_CORS_ORIGINS — allow the dashboard origin to reach agent-server
|
||||
*/
|
||||
|
||||
export interface OpenHandsEnvOptions {
|
||||
/** OmniRoute base URL as seen from the agent-server (default localhost:20128). */
|
||||
omnirouteUrl?: string;
|
||||
/** OmniRoute API key (sk-...). */
|
||||
apiKey: string;
|
||||
/** OpenHands model name (e.g. "deepseek-chat") or OmniRoute combo/model. */
|
||||
model: string;
|
||||
/** Host directory for OH_PERSISTENCE_DIR (default: current dir + .openhands-state). */
|
||||
persistenceDir?: string;
|
||||
/** CORS origins that must reach the agent-server (default dashboard origin + localhost). */
|
||||
corsOrigins?: string[];
|
||||
/** Optional OpenHands sandbox base image. */
|
||||
sandboxBaseImage?: string;
|
||||
}
|
||||
|
||||
export function buildOpenHandsEnv(opts: OpenHandsEnvOptions): Record<string, string> {
|
||||
const omnirouteHost = (opts.omnirouteUrl ?? "http://localhost:20128").replace(/\/+$/, "");
|
||||
const persistence = opts.persistenceDir ?? `${process.cwd()}/.openhands-state`;
|
||||
const cors =
|
||||
opts.corsOrigins && opts.corsOrigins.length > 0
|
||||
? opts.corsOrigins
|
||||
: ["http://localhost:3000", "http://localhost:3001"];
|
||||
|
||||
const env: Record<string, string> = {
|
||||
LLM_MODEL: opts.model,
|
||||
LLM_BASE_URL: `${omnirouteHost}/v1`,
|
||||
LLM_API_KEY: opts.apiKey,
|
||||
OH_PERSISTENCE_DIR: persistence,
|
||||
PERMITTED_CORS_ORIGINS: cors.join(","),
|
||||
};
|
||||
|
||||
if (opts.sandboxBaseImage) {
|
||||
env.SANDBOX_BASE_IMAGE = opts.sandboxBaseImage;
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the env record to `.env` file content (KEY=VALUE lines).
|
||||
* Values are not quoted unless they contain whitespace or `#`.
|
||||
*/
|
||||
export function serializeOpenHandsEnv(env: Record<string, string>): string {
|
||||
const lines: string[] = [];
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
const needsQuotes = /[\s#]/.test(value);
|
||||
lines.push(needsQuotes ? `${key}="${value}"` : `${key}=${value}`);
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
18
@omniroute/openhands-plugin/src/index.ts
Normal file
18
@omniroute/openhands-plugin/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @omniroute/openhands-plugin — OpenHands integration for the OmniRoute AI Gateway.
|
||||
*
|
||||
* Generates the OpenHands environment and Docker Compose / docker run config
|
||||
* that wires an OpenHands agent-server to a running OmniRoute instance:
|
||||
* model mapping, sandbox privileges, host-gateway networking, persistent
|
||||
* conversation state and CORS.
|
||||
*/
|
||||
export { buildOpenHandsEnv, serializeOpenHandsEnv } from "./env.ts";
|
||||
export type { OpenHandsEnvOptions } from "./env.ts";
|
||||
export { buildOpenHandsCompose, buildOpenHandsDockerRun } from "./docker.ts";
|
||||
export type { OpenHandsDockerOptions } from "./docker.ts";
|
||||
export {
|
||||
DEFAULT_OPENHANDS_MODEL_MAP,
|
||||
resolveOpenHandsModel,
|
||||
buildOpenHandsModel,
|
||||
} from "./model-map.ts";
|
||||
export type { OpenHandsModelMap } from "./model-map.ts";
|
||||
64
@omniroute/openhands-plugin/src/model-map.ts
Normal file
64
@omniroute/openhands-plugin/src/model-map.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* OpenHands → OmniRoute model mapping.
|
||||
*
|
||||
* OpenHands sends `model: "<LLM_MODEL>"` and expects the OpenAI-compatible
|
||||
* endpoint to accept that exact string. OmniRoute uses provider-prefixed
|
||||
* model IDs (`ds/deepseek-v4-flash`) and combo names. This module maps
|
||||
* common OpenHands-friendly names to the OmniRoute model/combo they should
|
||||
* resolve to, and back-fills the `LLM_MODEL` value for OpenHands.
|
||||
*/
|
||||
|
||||
export interface OpenHandsModelMap {
|
||||
/** OpenHands-friendly model name (e.g. "deepseek-chat") */
|
||||
[openHandsName: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default mapping for the model names OpenHands and the broader ecosystem
|
||||
* commonly send. Values are OmniRoute model IDs or combo names. Extend or
|
||||
* override via {@link resolveOpenHandsModel}.
|
||||
*/
|
||||
export const DEFAULT_OPENHANDS_MODEL_MAP: OpenHandsModelMap = Object.freeze({
|
||||
// DeepSeek
|
||||
"deepseek-chat": "ds/deepseek-v4-flash",
|
||||
"deepseek-reasoner": "ds/deepseek-v4-pro",
|
||||
// Claude / Anthropic
|
||||
"claude-sonnet-4.5": "anthropic/claude-sonnet-4.5",
|
||||
"claude-opus-4.1": "anthropic/claude-opus-4.1",
|
||||
"claude-haiku-4.5": "anthropic/claude-haiku-4.5",
|
||||
// GPT / OpenAI
|
||||
"gpt-4o": "openai/gpt-4o",
|
||||
"gpt-4o-mini": "openai/gpt-4o-mini",
|
||||
"gpt-5": "openai/gpt-5",
|
||||
// Gemini
|
||||
"gemini-2.5-flash": "gemini/gemini-2.5-flash",
|
||||
"gemini-2.5-pro": "gemini/gemini-2.5-pro",
|
||||
// GLM / Z.AI (NVIDIA NIM free endpoint)
|
||||
"glm-5.2": "nvidia/z-ai/glm-5.2",
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve the OmniRoute model ID for an OpenHands-friendly model name.
|
||||
* Returns the input unchanged when no mapping exists (OmniRoute will try to
|
||||
* resolve it as a literal model/combo).
|
||||
*/
|
||||
export function resolveOpenHandsModel(
|
||||
openHandsModel: string,
|
||||
map: OpenHandsModelMap = DEFAULT_OPENHANDS_MODEL_MAP
|
||||
): string {
|
||||
if (!openHandsModel) return openHandsModel;
|
||||
const mapped = map[openHandsModel];
|
||||
return mapped ?? openHandsModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `LLM_MODEL` value for OpenHands from an OmniRoute model ID/combo.
|
||||
*
|
||||
* OpenHands only surfaces the literal `LLM_MODEL` string in its UI, so for
|
||||
* OmniRoute combos (e.g. "vivanta-core") that's already the right value.
|
||||
* For provider-prefixed IDs, we return them as-is — the OmniRoute Model
|
||||
* Alias Resolver accepts both the raw ID and aliases on the `/v1` endpoint.
|
||||
*/
|
||||
export function buildOpenHandsModel(omnirouteModelOrCombo: string): string {
|
||||
return omnirouteModelOrCombo;
|
||||
}
|
||||
76
@omniroute/openhands-plugin/tests/env.test.ts
Normal file
76
@omniroute/openhands-plugin/tests/env.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildOpenHandsEnv, serializeOpenHandsEnv } from "../src/env.ts";
|
||||
import { resolveOpenHandsModel, buildOpenHandsModel } from "../src/model-map.ts";
|
||||
import { buildOpenHandsCompose, buildOpenHandsDockerRun } from "../src/docker.ts";
|
||||
|
||||
test("buildOpenHandsEnv produces LLM vars pointing at OmniRoute", () => {
|
||||
const env = buildOpenHandsEnv({
|
||||
apiKey: "sk-test-123",
|
||||
model: "deepseek-chat",
|
||||
omnirouteUrl: "http://192.168.3.106:20128",
|
||||
persistenceDir: "/opt/state",
|
||||
corsOrigins: ["http://100.73.44.17:3000"],
|
||||
});
|
||||
assert.equal(env.LLM_MODEL, "deepseek-chat");
|
||||
assert.equal(env.LLM_BASE_URL, "http://192.168.3.106:20128/v1");
|
||||
assert.equal(env.LLM_API_KEY, "sk-test-123");
|
||||
assert.equal(env.OH_PERSISTENCE_DIR, "/opt/state");
|
||||
assert.equal(env.PERMITTED_CORS_ORIGINS, "http://100.73.44.17:3000");
|
||||
});
|
||||
|
||||
test("serializeOpenHandsEnv quotes values with whitespace/#", () => {
|
||||
const out = serializeOpenHandsEnv({ LLM_MODEL: "deepseek-chat", LLM_BASE_URL: "http://localhost:20128/v1" });
|
||||
const lines = out.trim().split("\n");
|
||||
assert.ok(lines.some((l) => l.startsWith("LLM_MODEL=deepseek-chat")));
|
||||
assert.ok(lines.some((l) => l.startsWith("LLM_BASE_URL=http://localhost:20128/v1")));
|
||||
});
|
||||
|
||||
test("resolveOpenHandsModel maps known names to OmniRoute IDs", () => {
|
||||
assert.equal(resolveOpenHandsModel("deepseek-chat"), "ds/deepseek-v4-flash");
|
||||
assert.equal(resolveOpenHandsModel("glm-5.2"), "nvidia/z-ai/glm-5.2");
|
||||
assert.equal(resolveOpenHandsModel("gpt-4o"), "openai/gpt-4o");
|
||||
});
|
||||
|
||||
test("resolveOpenHandsModel passes unknown names through unchanged", () => {
|
||||
assert.equal(resolveOpenHandsModel("vivanta-core"), "vivanta-core");
|
||||
assert.equal(resolveOpenHandsModel(""), "");
|
||||
});
|
||||
|
||||
test("resolveOpenHandsModel accepts custom map overrides", () => {
|
||||
const custom = { "my-alias": "nvidia/z-ai/glm-5.2" };
|
||||
assert.equal(resolveOpenHandsModel("my-alias", custom), "nvidia/z-ai/glm-5.2");
|
||||
assert.equal(resolveOpenHandsModel("deepseek-chat", custom), "deepseek-chat");
|
||||
});
|
||||
|
||||
test("buildOpenHandsModel passes combo names through", () => {
|
||||
assert.equal(buildOpenHandsModel("vivanta-core"), "vivanta-core");
|
||||
assert.equal(buildOpenHandsModel("ds/deepseek-v4-flash"), "ds/deepseek-v4-flash");
|
||||
});
|
||||
|
||||
test("buildOpenHandsCompose includes privileged, extra_hosts, volume, CORS", () => {
|
||||
const compose = buildOpenHandsCompose({
|
||||
apiKey: "sk-x",
|
||||
model: "deepseek-chat",
|
||||
persistenceDir: "/Users/me/.openhands-state",
|
||||
corsOrigins: ["http://localhost:3000"],
|
||||
});
|
||||
assert.ok(compose.includes("privileged: true"), "privileged present");
|
||||
assert.ok(compose.includes("host.docker.internal:host-gateway"), "host-gateway present");
|
||||
assert.ok(compose.includes("/Users/me/.openhands-state"), "persistence volume present");
|
||||
assert.ok(compose.includes("LLM_BASE_URL: \"http://localhost:20128/v1\""), "base url present");
|
||||
assert.ok(compose.includes("PERMITTED_CORS_ORIGINS: \"http://localhost:3000\""), "cors present");
|
||||
});
|
||||
|
||||
test("buildOpenHandsDockerRun produces a runnable docker command", () => {
|
||||
const run = buildOpenHandsDockerRun({
|
||||
apiKey: "sk-x",
|
||||
model: "glm-5.2",
|
||||
persistenceDir: "/opt/state",
|
||||
});
|
||||
assert.ok(run.startsWith("docker run"));
|
||||
assert.ok(run.includes("--privileged"));
|
||||
assert.ok(run.includes("--add-host host.docker.internal:host-gateway"));
|
||||
assert.ok(run.includes("LLM_MODEL=\"glm-5.2\""));
|
||||
assert.ok(run.includes("LLM_BASE_URL=\"http://localhost:20128/v1\""));
|
||||
});
|
||||
22
@omniroute/openhands-plugin/tsconfig.json
Normal file
22
@omniroute/openhands-plugin/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"ignoreDeprecations": "6.0",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"declaration": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules", "tests"]
|
||||
}
|
||||
15
@omniroute/openhands-plugin/tsup.config.ts
Normal file
15
@omniroute/openhands-plugin/tsup.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts", "src/cli.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: false,
|
||||
splitting: false,
|
||||
treeshake: false,
|
||||
target: "node18",
|
||||
outDir: "dist",
|
||||
minify: false,
|
||||
cjsInterop: false,
|
||||
});
|
||||
@@ -236,6 +236,11 @@ FROM runner-base AS runner-cli
|
||||
# runner-base runs.
|
||||
USER root
|
||||
|
||||
# The CLI image can use the internal ChatGPT Web (Codex) Chromium sidecar over
|
||||
# CDP without installing a second browser in this container.
|
||||
COPY --from=builder /app/node_modules/playwright-core ./node_modules/playwright-core
|
||||
COPY --from=builder /app/node_modules/playwright ./node_modules/playwright
|
||||
|
||||
# Install system dependencies required by openclaw (git+ssh references).
|
||||
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
|
||||
26
THIRD_PARTY_NOTICES.md
Normal file
26
THIRD_PARTY_NOTICES.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Third-Party Notices
|
||||
|
||||
## codex-chatgpt-web
|
||||
|
||||
Parts of `open-sse/vendor/codex-chatgpt-web/` are adapted from
|
||||
[`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web), commit
|
||||
`55592fca0ba19a27f1b769cec8fff61ff340a785`.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 codex-chatgpt-web contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
||||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
|
||||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
56
bin/chatgpt-web-codex-mcp.mjs
Normal file
56
bin/chatgpt-web-codex-mcp.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(here, "..");
|
||||
|
||||
export function resolveChatGptWebCodexMcpEntry(rootDir = root, exists = existsSync) {
|
||||
const candidates = [
|
||||
join(
|
||||
rootDir,
|
||||
"dist",
|
||||
"open-sse",
|
||||
"vendor",
|
||||
"codex-chatgpt-web",
|
||||
"adapters",
|
||||
"chatgpt-web",
|
||||
"mcp-server.js"
|
||||
),
|
||||
join(
|
||||
rootDir,
|
||||
"open-sse",
|
||||
"vendor",
|
||||
"codex-chatgpt-web",
|
||||
"adapters",
|
||||
"chatgpt-web",
|
||||
"mcp-server.ts"
|
||||
),
|
||||
];
|
||||
return candidates.find((candidate) => exists(candidate)) ?? null;
|
||||
}
|
||||
|
||||
export async function startChatGptWebCodexMcp(args = process.argv.slice(2), rootDir = root) {
|
||||
const socketIndex = args.indexOf("--broker-socket");
|
||||
const brokerSocketPath = socketIndex >= 0 ? args[socketIndex + 1] : undefined;
|
||||
if (!brokerSocketPath) throw new Error("--broker-socket is required");
|
||||
const entry = resolveChatGptWebCodexMcpEntry(rootDir);
|
||||
if (!entry) throw new Error("ChatGPT Web (Codex) MCP entrypoint was not found");
|
||||
if (entry.endsWith(".ts")) {
|
||||
const { register } = await import("node:module");
|
||||
register("tsx/esm", pathToFileURL(`${rootDir}/`));
|
||||
}
|
||||
const module = await import(pathToFileURL(entry).href);
|
||||
await module.runChatGptMcpServer({ brokerSocketPath });
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
startChatGptWebCodexMcp().catch((error) => {
|
||||
console.error(
|
||||
`ChatGPT Web (Codex) MCP konnte nicht gestartet werden: ${error?.message || error}`
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(executors):** Vertex AI now routes Claude models through the native Anthropic `rawPredict` endpoint instead of the generic OpenAI-compatible partner endpoint, and synthesizes a real streaming response so Claude-via-Vertex works with `stream: true` ([#8909](https://github.com/diegosouzapw/OmniRoute/pull/8909)) — thanks @wgordon17
|
||||
14
changelog.d/fixes/9006-vertex-claude-catalog-dispatch.md
Normal file
14
changelog.d/fixes/9006-vertex-claude-catalog-dispatch.md
Normal file
@@ -0,0 +1,14 @@
|
||||
- **fix(sse):** Claude reasoning-effort suffix ids (`-high`/`-low`/`-medium`/`-xhigh`) now strip
|
||||
correctly on any provider serving a real Claude model, not just the direct Anthropic provider
|
||||
([#9006](https://github.com/diegosouzapw/OmniRoute/pull/9006))
|
||||
- **fix(sse):** the no-thinking (`no-think/`) catalog variant's provider-qualification bug — which
|
||||
made it unusable outside the direct provider, both in the discovery catalog and the dashboard
|
||||
playground — is fixed ([#9006](https://github.com/diegosouzapw/OmniRoute/pull/9006))
|
||||
- **fix(sse):** a single unrecognized model id on a Vertex connection no longer cools down every
|
||||
other model on that connection for 2 minutes — Vertex 404s are now scoped to a per-model
|
||||
lockout via `passthroughModels` instead of a connection-wide cooldown
|
||||
([#9006](https://github.com/diegosouzapw/OmniRoute/pull/9006))
|
||||
- **fix(sse):** Vertex `PERMISSION_DENIED` 403s are now disambiguated using Google's own
|
||||
documented error format — a genuinely connection-wide cause (API disabled, project-level IAM
|
||||
denial) still cools the whole connection, while a model-specific denial locks out only that
|
||||
model ([#9006](https://github.com/diegosouzapw/OmniRoute/pull/9006))
|
||||
1
changelog.d/fixes/9013-model-param-filter-save.md
Normal file
1
changelog.d/fixes/9013-model-param-filter-save.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(dashboard):** model-level allowed/blocked param edits now persist when the compatibility popover is closed by clicking outside, and a failed save no longer clears the edit or reports success ([#9013](https://github.com/diegosouzapw/OmniRoute/pull/9013))
|
||||
2
compression-core/.gitignore
vendored
Normal file
2
compression-core/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
27
compression-core/Cargo.toml
Normal file
27
compression-core/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/core-api",
|
||||
"crates/tokenizer",
|
||||
"crates/tests",
|
||||
"crates/bench",
|
||||
"crates/ffi",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/Egorich-print/OmniRoute"
|
||||
|
||||
[workspace.dependencies]
|
||||
core-api = { path = "crates/core-api" }
|
||||
tokenizer = { path = "crates/tokenizer" }
|
||||
tiktoken-rs = "0.6"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
61
compression-core/README.md
Normal file
61
compression-core/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# compression-core
|
||||
|
||||
Standalone Rust core for AI context optimization — tokenization, compression,
|
||||
hashing, translation primitives. Independent OSS library usable by OmniRoute,
|
||||
OpenCode, Cline, Roo, and any AI proxy. No OmniRoute imports anywhere.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
compression-core/
|
||||
├── Cargo.toml # workspace
|
||||
├── crates/
|
||||
│ ├── core-api/ # stable public API (traits + types) — no host deps
|
||||
│ ├── tokenizer/ # tiktoken (cl100k_base, o200k_base) — PORTED
|
||||
│ ├── tests/ # golden tests against fixtures/expected/
|
||||
│ ├── bench/ # criterion benchmarks
|
||||
│ └── ffi/ # N-API adapter (integration phase)
|
||||
├── fixtures/
|
||||
│ ├── tokenizer/ # JS-generated token counts (13 samples)
|
||||
│ └── expected/ # manifests
|
||||
└── scripts/
|
||||
├── generate-fixtures.ts # JS reference output (source of truth)
|
||||
└── verify-golden.ts # regen + cargo test
|
||||
```
|
||||
|
||||
## Porting order (per design)
|
||||
|
||||
1. tiktoken (done — golden 100%)
|
||||
2. ionizer
|
||||
3. headroom
|
||||
4. caveman
|
||||
5. RTK (last — biggest, requires proven harness)
|
||||
|
||||
## Golden pipeline
|
||||
|
||||
```text
|
||||
fixtures → JS implementation → expected.json → Rust → assert_eq!
|
||||
```
|
||||
|
||||
`node scripts/verify-golden.ts` regenerates fixtures from the current JS code
|
||||
and runs `cargo test -p compression-tests`. Until 100% match, JS stays in prod.
|
||||
|
||||
## Measured baseline
|
||||
|
||||
| Impl | Input | Cost |
|
||||
|---|---|---|
|
||||
| JS js-tiktoken (cl100k) | 230K chars | 37.9 ms |
|
||||
| Rust tiktoken-rs (cl100k) | ~440K chars | 21.4 ms |
|
||||
|
||||
Per-char Rust is ~3x faster; golden output is byte-identical on all fixtures.
|
||||
|
||||
## Status
|
||||
|
||||
- [x] workspace + stable API (`core-api`)
|
||||
- [x] tokenizer port + golden tests (100% match)
|
||||
- [x] bench harness (criterion)
|
||||
- [ ] ionizer
|
||||
- [ ] headroom
|
||||
- [ ] caveman
|
||||
- [ ] RTK
|
||||
- [ ] N-API adapter
|
||||
17
compression-core/crates/bench/Cargo.toml
Normal file
17
compression-core/crates/bench/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "compression-bench"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
core-api = { workspace = true }
|
||||
tokenizer = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = "0.5"
|
||||
|
||||
[[bench]]
|
||||
name = "tokenizer"
|
||||
harness = false
|
||||
19
compression-core/crates/bench/benches/tokenizer.rs
Normal file
19
compression-core/crates/bench/benches/tokenizer.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
//! Criterion bench for the tokenizer. Baseline target: < 5 ms per 57K tokens
|
||||
//! (JS js-tiktoken measures ~38 ms on the same input).
|
||||
|
||||
use core_api::TokenCounter;
|
||||
use criterion::{criterion_group, criterion_main, Criterion};
|
||||
use tokenizer::TiktokenCounter;
|
||||
|
||||
fn bench_tokenizer(c: &mut Criterion) {
|
||||
let counter = TiktokenCounter::default();
|
||||
// ~230K chars ≈ 57K cl100k tokens (mirrors the measured JS baseline).
|
||||
let text = "Hello world! This is a test of tokenization performance. \
|
||||
The quick brown fox jumps over the lazy dog. "
|
||||
.repeat(4000);
|
||||
|
||||
c.bench_function("cl100k_57k_tokens", |b| b.iter(|| counter.count(&text)));
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_tokenizer);
|
||||
criterion_main!(benches);
|
||||
10
compression-core/crates/core-api/Cargo.toml
Normal file
10
compression-core/crates/core-api/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "core-api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
76
compression-core/crates/core-api/src/lib.rs
Normal file
76
compression-core/crates/core-api/src/lib.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! Stable public API of the compression core.
|
||||
//!
|
||||
//! This crate is intentionally free of any OmniRoute-specific types.
|
||||
//! It defines the contracts that every adapter (N-API, sidecar, CLI)
|
||||
//! implements, so algorithms stay independent of the host project.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Role of a message in a conversation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// One chat message. Field-compatible with OpenAI `messages[]` entries.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
pub role: Role,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Tokenizer encodings supported by the core.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Encoding {
|
||||
#[serde(rename = "cl100k_base")]
|
||||
Cl100kBase,
|
||||
#[serde(rename = "o200k_base")]
|
||||
O200kBase,
|
||||
}
|
||||
|
||||
/// Configuration for a compression pass.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct CompressionConfig {
|
||||
/// Target token budget for the compressed messages.
|
||||
pub budget_tokens: Option<u64>,
|
||||
/// Engine stack priority hint (rtk=10, ionizer=13, headroom=15, ...).
|
||||
pub stack_priority: Option<u32>,
|
||||
}
|
||||
|
||||
/// Outcome of a compression pass.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CompressionResult {
|
||||
pub messages: Vec<Message>,
|
||||
pub compressed: bool,
|
||||
pub stats: Option<CompressionStats>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CompressionStats {
|
||||
pub saved_tokens: Option<u64>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// A token counter. Pure, stateless, thread-safe.
|
||||
pub trait TokenCounter {
|
||||
fn count(&self, text: &str) -> usize;
|
||||
}
|
||||
|
||||
/// A compressor. Pure, deterministic, stateless per call.
|
||||
pub trait Compressor {
|
||||
fn compress(
|
||||
&self,
|
||||
messages: &[Message],
|
||||
config: &CompressionConfig,
|
||||
) -> CompressionResult;
|
||||
}
|
||||
13
compression-core/crates/ffi/Cargo.toml
Normal file
13
compression-core/crates/ffi/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "compression-ffi"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
core-api = { workspace = true }
|
||||
tokenizer = { workspace = true }
|
||||
|
||||
# napi-rs bindings are added in the integration phase. This crate exists to
|
||||
# keep the N-API adapter out of the algorithm crates.
|
||||
8
compression-core/crates/ffi/src/lib.rs
Normal file
8
compression-core/crates/ffi/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
//! N-API binding crate (integration phase).
|
||||
//!
|
||||
//! This crate is intentionally empty until the N-API phase. It will expose
|
||||
//! `count_tokens` / `compress` over napi-rs using the core-api traits, so the
|
||||
//! algorithms in `tokenizer` and the future `compression` crates stay free of
|
||||
//! any Node bindings.
|
||||
|
||||
pub use core_api;
|
||||
11
compression-core/crates/tests/Cargo.toml
Normal file
11
compression-core/crates/tests/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "compression-tests"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
core-api = { workspace = true }
|
||||
tokenizer = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
66
compression-core/crates/tests/src/lib.rs
Normal file
66
compression-core/crates/tests/src/lib.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
//! Golden tests: run the Rust implementations against fixtures and compare
|
||||
//! byte-for-byte with the JS-produced `expected/` files.
|
||||
//!
|
||||
//! The `verify-golden.ts` script regenerates fixtures from the OmniRoute JS
|
||||
//! implementation. Until this crate passes 100% of golden fixtures, the JS
|
||||
//! implementation must NOT be replaced in production.
|
||||
|
||||
use core_api::{Encoding, TokenCounter};
|
||||
use std::path::Path;
|
||||
use tokenizer::TiktokenCounter;
|
||||
|
||||
const FIXTURES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../fixtures");
|
||||
|
||||
fn fixture_path(relative: &str) -> String {
|
||||
Path::new(FIXTURES_DIR).join(relative).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokenizer_golden_cl100k() {
|
||||
let counter = TiktokenCounter::default();
|
||||
let dir = fixture_path("tokenizer");
|
||||
let entries = std::fs::read_dir(&dir).expect("fixtures/tokenizer must exist");
|
||||
let mut checked = 0;
|
||||
for entry in entries {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
let input: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
let text = input["text"].as_str().unwrap();
|
||||
let expected = input["cl100k_tokens"].as_u64().unwrap() as usize;
|
||||
assert_eq!(
|
||||
counter.count(text),
|
||||
expected,
|
||||
"cl100k mismatch on {}",
|
||||
path.display()
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(checked > 0, "no tokenizer fixtures found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokenizer_golden_o200k() {
|
||||
let counter = TiktokenCounter::default();
|
||||
let dir = fixture_path("tokenizer");
|
||||
let entries = std::fs::read_dir(&dir).expect("fixtures/tokenizer must exist");
|
||||
let mut checked = 0;
|
||||
for entry in entries {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
let input: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
let text = input["text"].as_str().unwrap();
|
||||
let expected = input["o200k_tokens"].as_u64().unwrap() as usize;
|
||||
assert_eq!(
|
||||
counter.count_with_encoding(text, Encoding::O200kBase),
|
||||
expected,
|
||||
"o200k mismatch on {}",
|
||||
path.display()
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(checked > 0, "no tokenizer fixtures found");
|
||||
}
|
||||
13
compression-core/crates/tokenizer/Cargo.toml
Normal file
13
compression-core/crates/tokenizer/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "tokenizer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
core-api = { workspace = true }
|
||||
tiktoken-rs = { workspace = true }
|
||||
anyhow = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
71
compression-core/crates/tokenizer/src/lib.rs
Normal file
71
compression-core/crates/tokenizer/src/lib.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! Tiktoken token counter backed by `tiktoken-rs`.
|
||||
//!
|
||||
//! Port target: `src/shared/utils/tiktokenCounter.ts` in OmniRoute.
|
||||
//! Encodings: cl100k_base (default), o200k_base (Codex).
|
||||
|
||||
use core_api::{Encoding, TokenCounter};
|
||||
use tiktoken_rs::tokenizer::Tokenizer;
|
||||
|
||||
pub struct TiktokenCounter {
|
||||
cl100k: tiktoken_rs::CoreBPE,
|
||||
o200k: tiktoken_rs::CoreBPE,
|
||||
}
|
||||
|
||||
impl TiktokenCounter {
|
||||
pub fn new() -> Result<Self, anyhow::Error> {
|
||||
let cl100k = tiktoken_rs::get_bpe_from_tokenizer(Tokenizer::Cl100kBase)?;
|
||||
let o200k = tiktoken_rs::get_bpe_from_tokenizer(Tokenizer::O200kBase)?;
|
||||
Ok(Self { cl100k, o200k })
|
||||
}
|
||||
|
||||
pub fn count_with_encoding(&self, text: &str, encoding: Encoding) -> usize {
|
||||
let bpe = match encoding {
|
||||
Encoding::Cl100kBase => &self.cl100k,
|
||||
Encoding::O200kBase => &self.o200k,
|
||||
};
|
||||
// CoreBPE::encode_with_special_tokens requires allocation; the
|
||||
// plain encode is the closest equivalent to the JS byte-pair count.
|
||||
bpe.encode_ordinary(text).len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TiktokenCounter {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("tiktoken rank tables must load")
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenCounter for TiktokenCounter {
|
||||
fn count(&self, text: &str) -> usize {
|
||||
self.count_with_encoding(text, Encoding::Cl100kBase)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn counts_known_tokens_cl100k() {
|
||||
let counter = TiktokenCounter::default();
|
||||
// "Hello world" is 2 tokens in cl100k_base.
|
||||
assert_eq!(counter.count("Hello world"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_string_is_zero() {
|
||||
let counter = TiktokenCounter::default();
|
||||
assert_eq!(counter.count(""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn o200k_differs_from_cl100k_on_emoji() {
|
||||
let counter = TiktokenCounter::default();
|
||||
let emoji = "🎉";
|
||||
let cl100k = counter.count_with_encoding(emoji, Encoding::Cl100kBase);
|
||||
let o200k = counter.count_with_encoding(emoji, Encoding::O200kBase);
|
||||
// o200k has dedicated emoji tokens; counts may differ. Just assert both are > 0.
|
||||
assert!(cl100k > 0);
|
||||
assert!(o200k > 0);
|
||||
}
|
||||
}
|
||||
80
compression-core/fixtures/expected/tokenizer-manifest.json
Normal file
80
compression-core/fixtures/expected/tokenizer-manifest.json
Normal file
@@ -0,0 +1,80 @@
|
||||
[
|
||||
{
|
||||
"id": "000",
|
||||
"chars": 28,
|
||||
"cl100k_tokens": 8,
|
||||
"o200k_tokens": 8
|
||||
},
|
||||
{
|
||||
"id": "001",
|
||||
"chars": 44,
|
||||
"cl100k_tokens": 10,
|
||||
"o200k_tokens": 10
|
||||
},
|
||||
{
|
||||
"id": "002",
|
||||
"chars": 40,
|
||||
"cl100k_tokens": 15,
|
||||
"o200k_tokens": 12
|
||||
},
|
||||
{
|
||||
"id": "003",
|
||||
"chars": 38,
|
||||
"cl100k_tokens": 15,
|
||||
"o200k_tokens": 15
|
||||
},
|
||||
{
|
||||
"id": "004",
|
||||
"chars": 47,
|
||||
"cl100k_tokens": 15,
|
||||
"o200k_tokens": 15
|
||||
},
|
||||
{
|
||||
"id": "005",
|
||||
"chars": 100,
|
||||
"cl100k_tokens": 100,
|
||||
"o200k_tokens": 50
|
||||
},
|
||||
{
|
||||
"id": "006",
|
||||
"chars": 100,
|
||||
"cl100k_tokens": 41,
|
||||
"o200k_tokens": 23
|
||||
},
|
||||
{
|
||||
"id": "007",
|
||||
"chars": 10000,
|
||||
"cl100k_tokens": 1250,
|
||||
"o200k_tokens": 1250
|
||||
},
|
||||
{
|
||||
"id": "008",
|
||||
"chars": 1,
|
||||
"cl100k_tokens": 1,
|
||||
"o200k_tokens": 1
|
||||
},
|
||||
{
|
||||
"id": "009",
|
||||
"chars": 0,
|
||||
"cl100k_tokens": 0,
|
||||
"o200k_tokens": 0
|
||||
},
|
||||
{
|
||||
"id": "010",
|
||||
"chars": 49,
|
||||
"cl100k_tokens": 18,
|
||||
"o200k_tokens": 14
|
||||
},
|
||||
{
|
||||
"id": "011",
|
||||
"chars": 69,
|
||||
"cl100k_tokens": 24,
|
||||
"o200k_tokens": 24
|
||||
},
|
||||
{
|
||||
"id": "012",
|
||||
"chars": 405000,
|
||||
"cl100k_tokens": 90001,
|
||||
"o200k_tokens": 90001
|
||||
}
|
||||
]
|
||||
6
compression-core/fixtures/tokenizer/sample-000.json
Normal file
6
compression-core/fixtures/tokenizer/sample-000.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "000",
|
||||
"text": "Hello world! This is a test.",
|
||||
"cl100k_tokens": 8,
|
||||
"o200k_tokens": 8
|
||||
}
|
||||
6
compression-core/fixtures/tokenizer/sample-001.json
Normal file
6
compression-core/fixtures/tokenizer/sample-001.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "001",
|
||||
"text": "The quick brown fox jumps over the lazy dog.",
|
||||
"cl100k_tokens": 10,
|
||||
"o200k_tokens": 10
|
||||
}
|
||||
6
compression-core/fixtures/tokenizer/sample-002.json
Normal file
6
compression-core/fixtures/tokenizer/sample-002.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "002",
|
||||
"text": "🎉🎊 party time! emoji heavy sentence 🚀",
|
||||
"cl100k_tokens": 15,
|
||||
"o200k_tokens": 12
|
||||
}
|
||||
6
compression-core/fixtures/tokenizer/sample-003.json
Normal file
6
compression-core/fixtures/tokenizer/sample-003.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "003",
|
||||
"text": "JSON:\n{\"name\":\"test\",\"values\":[1,2,3]}",
|
||||
"cl100k_tokens": 15,
|
||||
"o200k_tokens": 15
|
||||
}
|
||||
6
compression-core/fixtures/tokenizer/sample-004.json
Normal file
6
compression-core/fixtures/tokenizer/sample-004.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "004",
|
||||
"text": "Code:\n```rust\nfn main() { println!(\"hi\"); }\n```",
|
||||
"cl100k_tokens": 15,
|
||||
"o200k_tokens": 15
|
||||
}
|
||||
6
compression-core/fixtures/tokenizer/sample-005.json
Normal file
6
compression-core/fixtures/tokenizer/sample-005.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "005",
|
||||
"text": "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀",
|
||||
"cl100k_tokens": 100,
|
||||
"o200k_tokens": 50
|
||||
}
|
||||
6
compression-core/fixtures/tokenizer/sample-006.json
Normal file
6
compression-core/fixtures/tokenizer/sample-006.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "006",
|
||||
"text": "Поддерживается ли русский текст корректно? Проверяем длинное предложение с кириллицей и пунктуацией!",
|
||||
"cl100k_tokens": 41,
|
||||
"o200k_tokens": 23
|
||||
}
|
||||
6
compression-core/fixtures/tokenizer/sample-007.json
Normal file
6
compression-core/fixtures/tokenizer/sample-007.json
Normal file
File diff suppressed because one or more lines are too long
6
compression-core/fixtures/tokenizer/sample-008.json
Normal file
6
compression-core/fixtures/tokenizer/sample-008.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "008",
|
||||
"text": "t",
|
||||
"cl100k_tokens": 1,
|
||||
"o200k_tokens": 1
|
||||
}
|
||||
6
compression-core/fixtures/tokenizer/sample-009.json
Normal file
6
compression-core/fixtures/tokenizer/sample-009.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "009",
|
||||
"text": "",
|
||||
"cl100k_tokens": 0,
|
||||
"o200k_tokens": 0
|
||||
}
|
||||
6
compression-core/fixtures/tokenizer/sample-010.json
Normal file
6
compression-core/fixtures/tokenizer/sample-010.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "010",
|
||||
"text": "Mixed 🎯 unicode 中文 한국어 + english + numbers 12345",
|
||||
"cl100k_tokens": 18,
|
||||
"o200k_tokens": 14
|
||||
}
|
||||
6
compression-core/fixtures/tokenizer/sample-011.json
Normal file
6
compression-core/fixtures/tokenizer/sample-011.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "011",
|
||||
"text": "function foo(a,b){return a+b*2;}\n\nconst x = foo(1,2);\nconsole.log(x);",
|
||||
"cl100k_tokens": 24,
|
||||
"o200k_tokens": 24
|
||||
}
|
||||
6
compression-core/fixtures/tokenizer/sample-012.json
Normal file
6
compression-core/fixtures/tokenizer/sample-012.json
Normal file
File diff suppressed because one or more lines are too long
77
compression-core/scripts/generate-fixtures.ts
Normal file
77
compression-core/scripts/generate-fixtures.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generates golden fixtures for compression-core from the OmniRoute JS
|
||||
* implementation. Every fixture records: input text + expected token counts
|
||||
* (cl100k / o200k) computed by the JS tokenizer.
|
||||
*
|
||||
* Usage: node --import tsx/esm scripts/generate-fixtures.ts
|
||||
* Output: fixtures/tokenizer/*.json, fixtures/conversations/*.json
|
||||
*
|
||||
* The Rust side (crates/tests) reads these and asserts equality. Until 100%
|
||||
* of fixtures pass, the JS implementation must not be replaced.
|
||||
*/
|
||||
import { countTextTokens } from "../../src/shared/utils/tiktokenCounter.ts";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, "..");
|
||||
const TOKENIZER_DIR = join(ROOT, "fixtures", "tokenizer");
|
||||
const EXPECTED_DIR = join(ROOT, "fixtures", "expected");
|
||||
|
||||
mkdirSync(TOKENIZER_DIR, { recursive: true });
|
||||
mkdirSync(EXPECTED_DIR, { recursive: true });
|
||||
|
||||
const SAMPLES = [
|
||||
"Hello world! This is a test.",
|
||||
"The quick brown fox jumps over the lazy dog.",
|
||||
"🎉🎊 party time! emoji heavy sentence 🚀",
|
||||
"JSON:\n{\"name\":\"test\",\"values\":[1,2,3]}",
|
||||
"Code:\n```rust\nfn main() { println!(\"hi\"); }\n```",
|
||||
"😀".repeat(50),
|
||||
"Поддерживается ли русский текст корректно? Проверяем длинное предложение с кириллицей и пунктуацией!",
|
||||
"a".repeat(10000),
|
||||
"t".repeat(1),
|
||||
"",
|
||||
"Mixed 🎯 unicode 中文 한국어 + english + numbers 12345",
|
||||
"function foo(a,b){return a+b*2;}\n\nconst x = foo(1,2);\nconsole.log(x);",
|
||||
];
|
||||
|
||||
// A longer realistic conversation-style text (~230K chars) to mirror the
|
||||
// measured baseline and to stress the counter on large inputs.
|
||||
const LONG = ("The quick brown fox jumps over the lazy dog. ").repeat(9000);
|
||||
SAMPLES.push(LONG);
|
||||
|
||||
const cl100k = (t) => countTextTokens(t);
|
||||
const o200k = (t) => countTextTokens(t, { provider: "codex", model: "codex/gpt-5.5" });
|
||||
|
||||
let count = 0;
|
||||
for (const [idx, text] of SAMPLES.entries()) {
|
||||
const id = String(idx).padStart(3, "0");
|
||||
const record = {
|
||||
id,
|
||||
text,
|
||||
cl100k_tokens: cl100k(text),
|
||||
o200k_tokens: o200k(text),
|
||||
};
|
||||
writeFileSync(join(TOKENIZER_DIR, `sample-${id}.json`), JSON.stringify(record, null, 2));
|
||||
count++;
|
||||
}
|
||||
|
||||
// Also emit a combined manifest for quick scanning.
|
||||
writeFileSync(
|
||||
join(EXPECTED_DIR, "tokenizer-manifest.json"),
|
||||
JSON.stringify(
|
||||
SAMPLES.map((t, idx) => ({
|
||||
id: String(idx).padStart(3, "0"),
|
||||
chars: t.length,
|
||||
cl100k_tokens: cl100k(t),
|
||||
o200k_tokens: o200k(t),
|
||||
})),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
console.log(`Generated ${count} tokenizer fixtures + manifest in fixtures/`);
|
||||
45
compression-core/scripts/verify-golden.ts
Normal file
45
compression-core/scripts/verify-golden.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verifies golden equivalence between the JS implementation and the Rust
|
||||
* implementation.
|
||||
*
|
||||
* Rust side: runs `cargo test -p compression-tests` which asserts byte-level
|
||||
* equality against fixtures/expected/. This script:
|
||||
* 1. regenerates fixtures from the current JS implementation
|
||||
* 2. runs cargo tests
|
||||
* 3. reports pass/fail per fixture family
|
||||
*
|
||||
* Usage: node scripts/verify-golden.ts [--skip-generate]
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const CORE_DIR = join(HERE, "..");
|
||||
|
||||
const skipGenerate = process.argv.includes("--skip-generate");
|
||||
|
||||
if (!skipGenerate) {
|
||||
console.log("[verify-golden] regenerating fixtures from JS implementation...");
|
||||
const gen = spawnSync("node", ["--import", "tsx/esm", "scripts/generate-fixtures.ts"], {
|
||||
cwd: CORE_DIR,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (gen.status !== 0) {
|
||||
console.error("FAIL: fixture generation exited with", gen.status);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[verify-golden] running Rust golden tests...");
|
||||
const run = spawnSync("cargo", ["test", "-p", "compression-tests"], {
|
||||
cwd: CORE_DIR,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (run.status !== 0) {
|
||||
console.error("FAIL: Rust golden tests exited with", run.status);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("[verify-golden] ALL GOLDEN TESTS PASSED ✅");
|
||||
@@ -127,12 +127,6 @@
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx": {
|
||||
"TS2322": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx": {
|
||||
"TS2739": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx": {
|
||||
"TS2304": 5
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx": {
|
||||
"TS2322": 3,
|
||||
"TS2739": 1,
|
||||
@@ -147,9 +141,6 @@
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx": {
|
||||
"TS2503": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/phase1d.test.tsx": {
|
||||
"TS2739": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": {
|
||||
"TS2322": 1
|
||||
},
|
||||
|
||||
@@ -121,6 +121,8 @@
|
||||
"tailwind-merge",
|
||||
"tailwindcss",
|
||||
"tls-client-node",
|
||||
"turndown",
|
||||
"turndown-plugin-gfm",
|
||||
"tsup",
|
||||
"tsx",
|
||||
"type-coverage",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"_rebaseline_2026_07_30_9006_vertex_claude_catalog_dispatch": "PR #9006 (fix/vertex-claude-catalog-dispatch): three files, two causes. (1) src/sse/handlers/chat.ts 1845->1846 (+1): NOT this PR's own growth — this PR never touches chat.ts at all. Measured 1846 (split(\"\\n\").length) at this PR's own merge-base (before any of its 11 commits), so the drift was already inherited from already-merged PRs on release/v3.8.50 (fast-gates PR->release do not run check:file-size, same root cause as _rebaseline_2026_07_25_v3849_basered_filesize and _rebaseline_2026_07_02_5798_release_green) — no offending branch left to fix. (2) src/sse/services/auth.ts 2508->2512 (+4 net, after extraction — see below) and open-sse/handlers/chatCore.ts 5020->5023 (+3, comment-only): genuine own growth. auth.ts adds Vertex 403 PERMISSION_DENIED disambiguation (Google's google.rpc.ErrorInfo proto distinguishes a connection-wide cause — SERVICE_DISABLED, or IAM_PERMISSION_DENIED against a project-level resource — from a model-specific one scoped to a .../models/<id> resource), added mid-PR after a quality-gate reviewer flagged the plan's originally-accepted \"Vertex 403 always -> per-model lockout\" trade-off. The actual classification logic (~40 lines) was EXTRACTED into a new leaf module src/sse/services/vertexErrorClassifier.ts (mirrors the googApiKeyAuth.ts precedent, _rebaseline_2026_07_14_7034_goog_api_key), leaving only the irreducible call-site wiring in the frozen file: a 1-line import plus widening the existing #3027 per-model-403 guard condition. chatCore.ts's +3 is a pure comment expansion (no functional change) clarifying that the adjacent effort-suffix strip is no longer unconditional for every provider, requested by a separate quality-gate code-reviewer finding; not extractable (it's a comment). Auth.ts's disambiguation logic covered by 3 new test cases in tests/unit/vertex-passthrough-model-lockout.test.ts (SERVICE_DISABLED, IAM_PERMISSION_DENIED+model-resource, IAM_PERMISSION_DENIED+project-resource) plus a 4th regression test for a multi-detail-body correlation bug (reason and resource must be read from the SAME ErrorInfo detail, not independently regexed across the whole body) found by an adversarial quality-gate pass and fixed before merge.",
|
||||
"_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgent's conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PR's own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.",
|
||||
"_rebaseline_2026_07_25_8494_capability_filter_fail_closed": "PR #8494 (fix/capability-filters-fail-closed, #8488) own growth: open-sse/services/combo.ts 3640->3693 (+53) adds a fail-closed guard after filterTargetsByRequestCompatibility() — when every eligible target is excluded by request-capability filtering (vision/tools/etc) instead of quota/health, the combo now returns an explicit `capability_mismatch` 400 (describeCapabilityFilterExhaustion, imported from combo/comboStructure.ts) rather than silently falling through to a generic no-targets error, plus a `compatFilterFailOpen` escape hatch (combo config OR settings) mirrored at both the main/auto and round-robin call sites for symmetry. combo/comboStructure.ts (previously under cap, un-frozen) grows 794->918 (+124) — new home for describeCapabilityFilterExhaustion + providerSupportsEmulatedToolCalling (#5240 emulated tool-calling exemption so fail-closed does not regress prompt-emulation-only combos like all-chatgpt-web). Irreducible orchestration wiring at the existing filter chokepoint (same precedent as #7301's universal-cooldown-retry generalization). Companion test tests/unit/combo-routing-engine.test.ts 3409->3449 (+40, fail-closed/fail-open coverage across both call sites) also rebaselined. Covered by tests/unit/8488-capability-filter-fail-closed.test.ts (new) + 95/95 passing across both files. Structural shrink of combo.ts tracked in #3501.",
|
||||
"_rebaseline_2026_07_25_8499_ts7_result_union_predicates": "PR #8499 (backryun, chore/ts7-types-executor-scattered) own growth: muse-spark-web.ts 1396->1405 (+9, irreducible). Under this workspace's `strictNullChecks: false`, the boolean-literal discriminant on `GraphqlResult` (`{ ok: true } | { ok: false; error: string }`) narrows the positive `.ok===true` branch but leaves `!result.ok` at the full union under TS7, making `.error` unreachable to the checker at the two call sites (warmup, mode-switch). Fixed by adding a single `isGraphqlFailure()` type-predicate helper (doc comment + 3-line body) reused at both call sites instead of duplicating the predicate inline — not extractable to a shared module without splitting a single-file executor's local narrowing helper out of its own file. Covered by the existing muse-spark-web executor test suite (no behavior change, pure narrowing fix).",
|
||||
@@ -349,7 +350,7 @@
|
||||
"open-sse/executors/deepseek-web.ts": 1148,
|
||||
"open-sse/executors/grok-web.ts": 1044,
|
||||
"open-sse/executors/muse-spark-web.ts": 1405,
|
||||
"open-sse/handlers/chatCore.ts": 5020,
|
||||
"open-sse/handlers/chatCore.ts": 5023,
|
||||
"open-sse/handlers/imageGeneration.ts": 3101,
|
||||
"open-sse/handlers/responseSanitizer.ts": 1115,
|
||||
"open-sse/handlers/search.ts": 1536,
|
||||
@@ -400,8 +401,8 @@
|
||||
"src/shared/components/RequestLoggerV2.tsx": 1629,
|
||||
"src/shared/components/analytics/charts.tsx": 1035,
|
||||
"src/shared/services/cliRuntime.ts": 1122,
|
||||
"src/sse/handlers/chat.ts": 1845,
|
||||
"src/sse/services/auth.ts": 2508,
|
||||
"src/sse/handlers/chat.ts": 1846,
|
||||
"src/sse/services/auth.ts": 2512,
|
||||
"tests/unit/account-fallback-service.test.ts": 1572,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 2980,
|
||||
"open-sse/executors/hyperagent.ts": 1026
|
||||
|
||||
@@ -46,6 +46,8 @@ services:
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
chatgpt-web-codex-browser:
|
||||
condition: service_started
|
||||
build:
|
||||
context: .
|
||||
target: runner-cli
|
||||
@@ -67,6 +69,7 @@ services:
|
||||
- HOSTNAME=0.0.0.0
|
||||
- DATA_DIR=/app/data
|
||||
- OMNIROUTE_BASE_PATH=${OMNIROUTE_BASE_PATH:-}
|
||||
- CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223
|
||||
ports:
|
||||
- "${PROD_DASHBOARD_PORT:-20130}:${DASHBOARD_PORT:-${PORT:-20128}}"
|
||||
- "${PROD_API_PORT:-20131}:${API_PORT:-20129}"
|
||||
@@ -80,7 +83,19 @@ services:
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
chatgpt-web-codex-browser:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/chatgpt-web-codex-browser/Dockerfile
|
||||
image: omniroute:chatgpt-web-codex-browser
|
||||
restart: unless-stopped
|
||||
shm_size: "2gb"
|
||||
volumes:
|
||||
- chatgpt-web-codex-browser-prod-data:/browser-profile
|
||||
|
||||
volumes:
|
||||
chatgpt-web-codex-browser-prod-data:
|
||||
name: omniroute-chatgpt-web-codex-browser-prod-data
|
||||
omniroute-prod-data:
|
||||
name: omniroute-prod-data
|
||||
redis-prod-data:
|
||||
|
||||
@@ -98,6 +98,21 @@ services:
|
||||
args:
|
||||
OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-}
|
||||
image: omniroute:web
|
||||
depends_on:
|
||||
chatgpt-web-codex-browser:
|
||||
condition: service_started
|
||||
environment:
|
||||
- DATA_DIR=/app/data
|
||||
- PORT=${PORT:-20128}
|
||||
- DASHBOARD_PORT=${DASHBOARD_PORT:-20128}
|
||||
- API_PORT=${API_PORT:-20129}
|
||||
- API_HOST=${API_HOST:-0.0.0.0}
|
||||
- LIVE_WS_PORT=${LIVE_WS_PORT:-20132}
|
||||
- LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0}
|
||||
- LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- OMNIROUTE_BASE_PATH=${OMNIROUTE_BASE_PATH:-}
|
||||
- CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223
|
||||
ports:
|
||||
- "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
|
||||
- "${API_PORT:-20129}:${API_PORT:-20129}"
|
||||
@@ -105,6 +120,20 @@ services:
|
||||
profiles:
|
||||
- web
|
||||
|
||||
# Internal-only Chromium runtime for ChatGPT Web (Codex). No CDP or browser
|
||||
# UI port is published to the host.
|
||||
chatgpt-web-codex-browser:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/chatgpt-web-codex-browser/Dockerfile
|
||||
image: omniroute:chatgpt-web-codex-browser
|
||||
restart: unless-stopped
|
||||
shm_size: "2gb"
|
||||
volumes:
|
||||
- chatgpt-web-codex-browser-data:/browser-profile
|
||||
profiles:
|
||||
- web
|
||||
|
||||
# ── Profile: cli (CLIs installed inside container) ─────────────────
|
||||
omniroute-cli:
|
||||
<<: *common
|
||||
@@ -252,6 +281,8 @@ services:
|
||||
- cliproxyapi
|
||||
|
||||
volumes:
|
||||
chatgpt-web-codex-browser-data:
|
||||
name: omniroute-chatgpt-web-codex-browser-data
|
||||
cliproxyapi-data:
|
||||
name: cliproxyapi-data
|
||||
redis-data:
|
||||
|
||||
10
docker/chatgpt-web-codex-browser/Dockerfile
Normal file
10
docker/chatgpt-web-codex-browser/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
|
||||
USER root
|
||||
RUN mkdir -p /browser-profile && chown -R pwuser:pwuser /browser-profile
|
||||
COPY --chown=pwuser:pwuser docker/chatgpt-web-codex-browser/cdp-proxy.mjs /opt/cdp-proxy.mjs
|
||||
USER pwuser
|
||||
|
||||
EXPOSE 9223
|
||||
|
||||
CMD ["/bin/sh", "-lc", "node /opt/cdp-proxy.mjs & exec $(find /ms-playwright -path '*/chrome-linux/chrome' -type f | head -n 1) --headless=new --no-sandbox --disable-dev-shm-usage --remote-debugging-port=9222 --user-data-dir=/browser-profile about:blank"]
|
||||
72
docker/chatgpt-web-codex-browser/cdp-proxy.mjs
Normal file
72
docker/chatgpt-web-codex-browser/cdp-proxy.mjs
Normal file
@@ -0,0 +1,72 @@
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
|
||||
const listenPort = 9223;
|
||||
const upstreamHost = "127.0.0.1";
|
||||
const upstreamPort = 9222;
|
||||
|
||||
function proxyHeaders(headers) {
|
||||
const next = { ...headers, host: `${upstreamHost}:${upstreamPort}` };
|
||||
delete next.connection;
|
||||
delete next.upgrade;
|
||||
return next;
|
||||
}
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
const upstream = http.request(
|
||||
{
|
||||
host: upstreamHost,
|
||||
port: upstreamPort,
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
headers: proxyHeaders(request.headers),
|
||||
},
|
||||
(upstreamResponse) => {
|
||||
const chunks = [];
|
||||
upstreamResponse.on("data", (chunk) => chunks.push(chunk));
|
||||
upstreamResponse.on("end", () => {
|
||||
let body = Buffer.concat(chunks);
|
||||
const contentType = String(upstreamResponse.headers["content-type"] || "");
|
||||
if (contentType.includes("application/json")) {
|
||||
body = Buffer.from(
|
||||
body
|
||||
.toString("utf8")
|
||||
.replaceAll(`ws://${upstreamHost}:${upstreamPort}`, `ws://${request.headers.host}`)
|
||||
);
|
||||
}
|
||||
const headers = { ...upstreamResponse.headers, "content-length": String(body.length) };
|
||||
response.writeHead(upstreamResponse.statusCode || 502, headers);
|
||||
response.end(body);
|
||||
});
|
||||
}
|
||||
);
|
||||
upstream.on("error", () => {
|
||||
response.writeHead(503, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "CDP browser is starting" }));
|
||||
});
|
||||
request.pipe(upstream);
|
||||
});
|
||||
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
const upstream = net.connect(upstreamPort, upstreamHost, () => {
|
||||
const upgradeHeaders = {
|
||||
...request.headers,
|
||||
host: `${upstreamHost}:${upstreamPort}`,
|
||||
connection: "Upgrade",
|
||||
upgrade: "websocket",
|
||||
};
|
||||
const headers = Object.entries(upgradeHeaders)
|
||||
.flatMap(([name, value]) =>
|
||||
Array.isArray(value) ? value.map((item) => `${name}: ${item}`) : [`${name}: ${value}`]
|
||||
)
|
||||
.join("\r\n");
|
||||
upstream.write(
|
||||
`${request.method} ${request.url} HTTP/${request.httpVersion}\r\n${headers}\r\n\r\n`
|
||||
);
|
||||
if (head.length > 0) upstream.write(head);
|
||||
socket.pipe(upstream).pipe(socket);
|
||||
});
|
||||
upstream.on("error", () => socket.destroy());
|
||||
});
|
||||
|
||||
server.listen(listenPort, "0.0.0.0");
|
||||
57
docker/devin-bridge/Dockerfile
Normal file
57
docker/devin-bridge/Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
||||
FROM node:26.0.0-bookworm-slim
|
||||
|
||||
ARG CLAUDE_CODE_VERSION=2.1.220
|
||||
ARG DEVIN_CLI_VERSION=3000.2.17
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl git bash python3 make g++ tini \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& npm install --global "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
|
||||
|
||||
RUN set -eu; \
|
||||
case "${TARGETARCH}" in \
|
||||
amd64) devin_arch=x86_64-unknown-linux; devin_sha=f0e1e9363afc6ee68c4ef87bab4aeb7ff5cc08a5fa838350ef3ceefdbb2a2be2 ;; \
|
||||
arm64) devin_arch=aarch64-unknown-linux; devin_sha=116dc71ef085a922bc3ff0ea0377d4b26c529a431d58246e36572913e2d25624 ;; \
|
||||
*) echo "Unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
curl -fsSL "https://static.devin.ai/cli/${DEVIN_CLI_VERSION}/devin-${DEVIN_CLI_VERSION}-${devin_arch}.tar.gz" -o /tmp/devin.tar.gz; \
|
||||
echo "${devin_sha} /tmp/devin.tar.gz" | sha256sum -c -; \
|
||||
tar -xzf /tmp/devin.tar.gz -C /tmp; \
|
||||
install -m 0755 "$(find /tmp -type f -name devin | head -1)" /usr/local/bin/devin; \
|
||||
rm -rf /tmp/devin.tar.gz /tmp/devin-*
|
||||
|
||||
RUN groupadd --gid 10001 bridge \
|
||||
&& useradd --uid 10001 --gid bridge --create-home --home-dir /home/bridge --shell /bin/bash bridge \
|
||||
&& mkdir -p /opt/omniroute /workspace \
|
||||
&& chown -R bridge:bridge /opt/omniroute /workspace
|
||||
|
||||
WORKDIR /opt/omniroute
|
||||
USER bridge
|
||||
COPY --chown=bridge:bridge package.json package-lock.json .npmrc ./
|
||||
RUN npm ci --ignore-scripts --no-audit --fund=false
|
||||
COPY --chown=bridge:bridge . .
|
||||
RUN npm rebuild better-sqlite3 || true
|
||||
|
||||
ENV HOME=/home/bridge \
|
||||
CLAUDE_CONFIG_DIR=/home/bridge/.claude-devin-isolated \
|
||||
DEVIN_AGENTIC_HOME=/home/bridge \
|
||||
DATA_DIR=/home/bridge/.omniroute-isolated \
|
||||
SQLITE_FILE=/home/bridge/.omniroute-isolated/storage.sqlite \
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
|
||||
DISABLE_TELEMETRY=1 \
|
||||
DISABLE_ERROR_REPORTING=1 \
|
||||
DISABLE_AUTOUPDATER=1 \
|
||||
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN mkdir -p /home/bridge/.claude-devin-isolated /home/bridge/.local/share/devin \
|
||||
/home/bridge/.omniroute-isolated
|
||||
|
||||
RUN DATA_DIR=/tmp/omniroute-build-data \
|
||||
SQLITE_FILE=/tmp/omniroute-build-data/storage.sqlite \
|
||||
npm run build \
|
||||
&& rm -rf /tmp/omniroute-build-data
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["bash"]
|
||||
218
docker/devin-bridge/compose.yml
Normal file
218
docker/devin-bridge/compose.yml
Normal file
@@ -0,0 +1,218 @@
|
||||
name: omniroute-devin-bridge
|
||||
|
||||
x-isolated-environment: &isolated-environment
|
||||
HOME: /home/bridge
|
||||
CLAUDE_CONFIG_DIR: /home/bridge/.claude-devin-isolated
|
||||
DEVIN_AGENTIC_HOME: /home/bridge
|
||||
DATA_DIR: /home/bridge/.omniroute-isolated
|
||||
SQLITE_FILE: /home/bridge/.omniroute-isolated/storage.sqlite
|
||||
ANTHROPIC_BASE_URL: http://omniroute:20128
|
||||
ANTHROPIC_AUTH_TOKEN: sk-local-devin-gateway
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
|
||||
DISABLE_TELEMETRY: "1"
|
||||
DISABLE_ERROR_REPORTING: "1"
|
||||
DISABLE_AUTOUPDATER: "1"
|
||||
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1"
|
||||
DEVIN_BRIDGE_MODEL: ${DEVIN_BRIDGE_MODEL:-devin-cli-agentic/swe-1-7}
|
||||
ANTHROPIC_MODEL: ${DEVIN_BRIDGE_MODEL:-devin-cli-agentic/swe-1-7}
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: ${DEVIN_BRIDGE_SONNET_MODEL:-devin-cli-agentic/swe-1-7}
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: ${DEVIN_BRIDGE_OPUS_MODEL:-devin-cli-agentic/swe-1-7}
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: ${DEVIN_BRIDGE_HAIKU_MODEL:-devin-cli-agentic/swe-1-7}
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: ${DEVIN_BRIDGE_SUBAGENT_MODEL:-devin-cli-agentic/swe-1-7}
|
||||
REQUIRE_API_KEY: "true"
|
||||
OMNIROUTE_API_KEY: sk-local-devin-gateway
|
||||
|
||||
x-runtime: &runtime
|
||||
image: omniroute-devin-bridge:local
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/devin-bridge/Dockerfile
|
||||
args:
|
||||
CLAUDE_CODE_VERSION: 2.1.220
|
||||
DEVIN_CLI_VERSION: 3000.2.17
|
||||
user: "10001:10001"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,nodev,size=256m
|
||||
- /opt/omniroute/.source:rw,nosuid,nodev,size=16m,uid=10001,gid=10001
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
environment: *isolated-environment
|
||||
networks: [bridge-internal]
|
||||
|
||||
services:
|
||||
omniroute:
|
||||
<<: *runtime
|
||||
profiles: [offline]
|
||||
hostname: omniroute
|
||||
environment:
|
||||
<<: *isolated-environment
|
||||
CLI_DEVIN_AGENTIC_BIN: /opt/omniroute/docker/devin-bridge/mock-devin.mjs
|
||||
DEVIN_BRIDGE_MOCK_LOG: /evidence/mock-acp.jsonl
|
||||
command: ["npm", "run", "start"]
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:20128/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 60
|
||||
volumes:
|
||||
- omniroute-offline-data:/home/bridge/.omniroute-isolated
|
||||
- ../../.sandbox/evidence:/evidence
|
||||
- ./mock-devin.mjs:/opt/omniroute/docker/devin-bridge/mock-devin.mjs:ro
|
||||
|
||||
claude:
|
||||
<<: *runtime
|
||||
profiles: [offline]
|
||||
depends_on:
|
||||
omniroute:
|
||||
condition: service_healthy
|
||||
claude-egress-guard:
|
||||
condition: service_healthy
|
||||
working_dir: /workspace
|
||||
command: ["bash", "/opt/omniroute/docker/devin-bridge/run-claude-e2e.sh"]
|
||||
environment:
|
||||
<<: *isolated-environment
|
||||
NODE_USE_ENV_PROXY: "1"
|
||||
HTTP_PROXY: http://claude-egress-guard:8080
|
||||
HTTPS_PROXY: http://claude-egress-guard:8080
|
||||
NO_PROXY: omniroute
|
||||
volumes:
|
||||
- claude-isolated-config:/home/bridge/.claude-devin-isolated
|
||||
- ../../.sandbox/e2e-workspace:/workspace
|
||||
- ../../.sandbox/evidence:/evidence
|
||||
- ./run-claude-e2e.sh:/opt/omniroute/docker/devin-bridge/run-claude-e2e.sh:ro
|
||||
|
||||
contract:
|
||||
<<: *runtime
|
||||
profiles: [offline]
|
||||
depends_on:
|
||||
omniroute:
|
||||
condition: service_healthy
|
||||
command: ["node", "/opt/omniroute/docker/devin-bridge/run-contract.mjs"]
|
||||
volumes:
|
||||
- ./run-contract.mjs:/opt/omniroute/docker/devin-bridge/run-contract.mjs:ro
|
||||
|
||||
claude-egress-guard:
|
||||
image: node:26.0.0-bookworm-slim
|
||||
profiles: [offline, live-devin]
|
||||
user: "10001:10001"
|
||||
read_only: true
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
command: ["node", "/guard/proxy.mjs"]
|
||||
environment:
|
||||
GUARD_LISTEN: 0.0.0.0:8080
|
||||
GUARD_POLICY: deny-all
|
||||
GUARD_LOG: /guard-audit/egress.jsonl
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"require('net').connect(8080,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))",
|
||||
]
|
||||
interval: 1s
|
||||
timeout: 1s
|
||||
retries: 15
|
||||
volumes:
|
||||
- ./network-guard:/guard:ro
|
||||
- ../../.sandbox/guard-audit/claude:/guard-audit
|
||||
networks: [bridge-internal]
|
||||
|
||||
network-guard:
|
||||
image: node:26.0.0-bookworm-slim
|
||||
profiles: [live-devin]
|
||||
user: "10001:10001"
|
||||
read_only: true
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
command: ["node", "/guard/proxy.mjs"]
|
||||
environment:
|
||||
GUARD_LISTEN: 0.0.0.0:8080
|
||||
GUARD_POLICY: devin
|
||||
GUARD_LOG: /guard-audit/egress.jsonl
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"require('net').connect(8080,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))",
|
||||
]
|
||||
interval: 1s
|
||||
timeout: 1s
|
||||
retries: 15
|
||||
volumes:
|
||||
- ./network-guard:/guard:ro
|
||||
- ../../.sandbox/guard-audit/devin:/guard-audit
|
||||
networks: [devin-guard-internal, guard-egress]
|
||||
|
||||
omniroute-live:
|
||||
<<: *runtime
|
||||
profiles: [live-devin]
|
||||
hostname: omniroute
|
||||
depends_on:
|
||||
network-guard:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
<<: *isolated-environment
|
||||
CLI_DEVIN_AGENTIC_BIN: /usr/local/bin/devin
|
||||
DEVIN_BRIDGE_PROXY_URL: http://network-guard:8080
|
||||
networks: [bridge-internal, devin-guard-internal]
|
||||
command: ["npm", "run", "start"]
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:20128/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 60
|
||||
volumes:
|
||||
- devin-auth:/home/bridge/.local/share/devin
|
||||
- omniroute-live-data:/home/bridge/.omniroute-isolated
|
||||
|
||||
claude-live:
|
||||
<<: *runtime
|
||||
profiles: [live-devin]
|
||||
depends_on:
|
||||
omniroute-live:
|
||||
condition: service_healthy
|
||||
claude-egress-guard:
|
||||
condition: service_healthy
|
||||
working_dir: /workspace
|
||||
command: ["bash", "/opt/omniroute/docker/devin-bridge/run-claude-live-e2e.sh"]
|
||||
environment:
|
||||
<<: *isolated-environment
|
||||
NODE_USE_ENV_PROXY: "1"
|
||||
HTTP_PROXY: http://claude-egress-guard:8080
|
||||
HTTPS_PROXY: http://claude-egress-guard:8080
|
||||
NO_PROXY: omniroute
|
||||
volumes:
|
||||
- claude-isolated-config:/home/bridge/.claude-devin-isolated
|
||||
- ../../.sandbox/live-workspace:/workspace
|
||||
- ../../.sandbox/evidence:/evidence
|
||||
- ./run-claude-live-e2e.sh:/opt/omniroute/docker/devin-bridge/run-claude-live-e2e.sh:ro
|
||||
|
||||
networks:
|
||||
bridge-internal:
|
||||
internal: true
|
||||
devin-guard-internal:
|
||||
internal: true
|
||||
guard-egress: {}
|
||||
|
||||
volumes:
|
||||
claude-isolated-config: {}
|
||||
devin-auth: {}
|
||||
omniroute-offline-data: {}
|
||||
omniroute-live-data: {}
|
||||
229
docker/devin-bridge/mock-devin.mjs
Executable file
229
docker/devin-bridge/mock-devin.mjs
Executable file
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import readline from "node:readline";
|
||||
|
||||
if (
|
||||
process.argv[2] !== "acp" ||
|
||||
process.argv[3] !== "--agent-type" ||
|
||||
process.argv[4] !== "summarizer" ||
|
||||
process.argv.length !== 5
|
||||
) {
|
||||
process.exit(64);
|
||||
}
|
||||
|
||||
const logFile = process.env.DEVIN_BRIDGE_MOCK_LOG || "/evidence/mock-acp.jsonl";
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
const send = (value) => process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
const log = (value) => fs.appendFileSync(logFile, `${JSON.stringify(value)}\n`);
|
||||
|
||||
const actions = [
|
||||
{
|
||||
name: "Skill",
|
||||
arguments: { skill: "bridge-proof" },
|
||||
},
|
||||
{
|
||||
name: "Bash",
|
||||
arguments: {
|
||||
command: "find . -maxdepth 2 -type f -print",
|
||||
description: "Locate the fixture files",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Read",
|
||||
arguments: { file_path: "/workspace/math.js" },
|
||||
},
|
||||
{
|
||||
name: "Edit",
|
||||
arguments: {
|
||||
file_path: "/workspace/math.js",
|
||||
old_string: "return a - b;",
|
||||
new_string: "return a * b;",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Bash",
|
||||
arguments: { command: "npm test", description: "Run the fixture tests" },
|
||||
},
|
||||
{
|
||||
name: "Edit",
|
||||
arguments: {
|
||||
file_path: "/workspace/math.js",
|
||||
old_string: "return a * b;",
|
||||
new_string: "return a + b;",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Bash",
|
||||
arguments: { command: "npm test", description: "Confirm the corrected fixture" },
|
||||
},
|
||||
];
|
||||
|
||||
rl.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") {
|
||||
if (message.params?.protocolVersion !== 1) {
|
||||
send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "ACP v1 required" } });
|
||||
return;
|
||||
}
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: 1 } });
|
||||
} else if (message.method === "session/new") {
|
||||
if (message.params?.cwd !== "/home/bridge" || !Array.isArray(message.params?.mcpServers)) {
|
||||
send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "unsafe session" } });
|
||||
return;
|
||||
}
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: { sessionId: "offline" },
|
||||
});
|
||||
} else if (message.method === "session/set_config_option") {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: { code: -32602, message: "summarizer mode must not be mutated" },
|
||||
});
|
||||
} else if (message.method === "session/prompt") {
|
||||
const prompt = String(message.params?.prompt?.[0]?.text || "");
|
||||
if (!prompt.includes("[Devin Summarizer Bridge]") || !prompt.includes("[Execution Trace]")) {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: { code: -32602, message: "summarizer bridge framing required" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (prompt.includes("CONTRACT_AFTER_TOOL")) {
|
||||
log({ provider: "devin-cli-agentic", scenario: "after-tool" });
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "offline",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "contract continued" },
|
||||
},
|
||||
},
|
||||
});
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
|
||||
return;
|
||||
}
|
||||
if (prompt.includes("CONTRACT_EXIT")) {
|
||||
log({ provider: "devin-cli-agentic", scenario: "exit" });
|
||||
process.exit(7);
|
||||
}
|
||||
if (prompt.includes("CONTRACT_ERROR")) {
|
||||
log({ provider: "devin-cli-agentic", scenario: "error" });
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: { code: -32000, message: "deterministic upstream failure" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (prompt.includes("CONTRACT_TEXT")) {
|
||||
log({ provider: "devin-cli-agentic", scenario: "text" });
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "offline",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "contract text" },
|
||||
},
|
||||
},
|
||||
});
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
|
||||
return;
|
||||
}
|
||||
if (prompt.includes("CONTRACT_NARRATIVE_REPAIR")) {
|
||||
const isRepair = prompt.includes("[Single Repair Attempt]");
|
||||
log({
|
||||
provider: "devin-cli-agentic",
|
||||
scenario: "narrative-repair",
|
||||
stage: isRepair ? "repair" : "initial",
|
||||
});
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "offline",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: isRepair
|
||||
? '<tool>{"name":"Read","arguments":{"file_path":"/workspace/math.js"}}</tool>'
|
||||
: "I'll start by reading the math.js file, then run the tests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
|
||||
return;
|
||||
}
|
||||
if (prompt.includes("CONTRACT_TOOL")) {
|
||||
log({ provider: "devin-cli-agentic", scenario: "tool" });
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "offline",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: '<tool>{"name":"Read","arguments":{"file_path":"/workspace/math.js"}}</tool>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
|
||||
return;
|
||||
}
|
||||
const resultCount = (prompt.match(/\[Tool Result\]/g) || []).length;
|
||||
if (!prompt.includes("CLAUDE_MD_BRIDGE_ACTIVE") || !prompt.includes("COMMAND_BRIDGE_ACTIVE")) {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: { code: -32602, message: "Claude project context missing" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const action = actions[resultCount];
|
||||
const text = action
|
||||
? `<tool>${JSON.stringify(action)}</tool>`
|
||||
: "BRIDGE_E2E_COMPLETE CLAUDE_MD_BRIDGE_ACTIVE SKILL_BRIDGE_ACTIVE COMMAND_BRIDGE_ACTIVE";
|
||||
if (!action && !prompt.includes("SKILL_BRIDGE_ACTIVE")) {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: { code: -32602, message: "Skill result missing" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
log({
|
||||
provider: "devin-cli-agentic",
|
||||
model: message.params?.model || "swe-1-7",
|
||||
resultCount,
|
||||
action: action?.name || "final",
|
||||
});
|
||||
const midpoint = Math.max(1, Math.floor(text.length / 2));
|
||||
for (const chunk of [text.slice(0, midpoint), text.slice(midpoint)]) {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "offline",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: chunk },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
|
||||
}
|
||||
});
|
||||
130
docker/devin-bridge/network-guard/policy.mjs
Normal file
130
docker/devin-bridge/network-guard/policy.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
export const DEVIN_ALLOWED_SUFFIXES = Object.freeze([".devin.ai", ".cognition.ai"]);
|
||||
export const DEVIN_ALLOWED_EXACT_HOSTS = Object.freeze([
|
||||
"server.codeium.com",
|
||||
"unleash.codeium.com",
|
||||
]);
|
||||
|
||||
function normalizeHostname(hostname) {
|
||||
return String(hostname || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\.$/, "");
|
||||
}
|
||||
|
||||
export function isAllowedGuardHostname(hostname, policy = "deny-all") {
|
||||
if (policy !== "devin") return false;
|
||||
const value = normalizeHostname(hostname);
|
||||
if (!value) return false;
|
||||
if (DEVIN_ALLOWED_EXACT_HOSTS.includes(value)) return true;
|
||||
return DEVIN_ALLOWED_SUFFIXES.some(
|
||||
(suffix) => value === suffix.slice(1) || value.endsWith(suffix)
|
||||
);
|
||||
}
|
||||
|
||||
const HOP_BY_HOP_HEADERS = new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
export function sanitizeForwardHeaders(headers, target) {
|
||||
const connectionTokens = String(headers.connection || "")
|
||||
.split(",")
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const blocked = new Set([...HOP_BY_HOP_HEADERS, ...connectionTokens]);
|
||||
const sanitized = {};
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (value === undefined || blocked.has(name.toLowerCase()) || name.toLowerCase() === "host") {
|
||||
continue;
|
||||
}
|
||||
sanitized[name] = value;
|
||||
}
|
||||
sanitized.host = target.host;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function parseConnectAuthority(authority) {
|
||||
const value = String(authority || "");
|
||||
const match = value.match(/^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/);
|
||||
if (!match) return null;
|
||||
const hostname = normalizeHostname(match[1] || match[2]);
|
||||
const port = Number(match[3]);
|
||||
if (!hostname || port !== 443) return null;
|
||||
return { hostname, port };
|
||||
}
|
||||
|
||||
function readUint24(buffer, offset) {
|
||||
return (buffer[offset] << 16) | (buffer[offset + 1] << 8) | buffer[offset + 2];
|
||||
}
|
||||
|
||||
export function parseTlsClientHelloSni(buffer) {
|
||||
if (!Buffer.isBuffer(buffer)) return { status: "invalid", reason: "not_buffer" };
|
||||
let offset = 0;
|
||||
const handshakeParts = [];
|
||||
while (offset < buffer.length) {
|
||||
if (buffer.length - offset < 5) return { status: "need-more" };
|
||||
if (buffer[offset] !== 22) return { status: "invalid", reason: "not_handshake_record" };
|
||||
const recordLength = buffer.readUInt16BE(offset + 3);
|
||||
if (recordLength <= 0 || recordLength > 18432) {
|
||||
return { status: "invalid", reason: "invalid_record_length" };
|
||||
}
|
||||
if (buffer.length - offset - 5 < recordLength) return { status: "need-more" };
|
||||
handshakeParts.push(buffer.subarray(offset + 5, offset + 5 + recordLength));
|
||||
offset += 5 + recordLength;
|
||||
}
|
||||
const handshake = Buffer.concat(handshakeParts);
|
||||
if (handshake.length < 4) return { status: "need-more" };
|
||||
if (handshake[0] !== 1) return { status: "invalid", reason: "not_client_hello" };
|
||||
const helloLength = readUint24(handshake, 1);
|
||||
if (helloLength > 65531) return { status: "invalid", reason: "client_hello_too_large" };
|
||||
if (handshake.length - 4 < helloLength) return { status: "need-more" };
|
||||
const hello = handshake.subarray(4, 4 + helloLength);
|
||||
let cursor = 34;
|
||||
if (hello.length < cursor + 1) return { status: "invalid", reason: "truncated_hello" };
|
||||
const sessionLength = hello[cursor++];
|
||||
cursor += sessionLength;
|
||||
if (hello.length < cursor + 2) return { status: "invalid", reason: "truncated_ciphers" };
|
||||
const cipherLength = hello.readUInt16BE(cursor);
|
||||
cursor += 2 + cipherLength;
|
||||
if (hello.length < cursor + 1) return { status: "invalid", reason: "truncated_compression" };
|
||||
const compressionLength = hello[cursor++];
|
||||
cursor += compressionLength;
|
||||
if (hello.length < cursor + 2) return { status: "invalid", reason: "missing_extensions" };
|
||||
const extensionsLength = hello.readUInt16BE(cursor);
|
||||
cursor += 2;
|
||||
const extensionsEnd = cursor + extensionsLength;
|
||||
if (extensionsEnd > hello.length) return { status: "invalid", reason: "truncated_extensions" };
|
||||
while (cursor < extensionsEnd) {
|
||||
if (extensionsEnd - cursor < 4) return { status: "invalid", reason: "truncated_extension" };
|
||||
const type = hello.readUInt16BE(cursor);
|
||||
const length = hello.readUInt16BE(cursor + 2);
|
||||
cursor += 4;
|
||||
if (cursor + length > extensionsEnd) {
|
||||
return { status: "invalid", reason: "invalid_extension_length" };
|
||||
}
|
||||
if (type === 0) {
|
||||
const data = hello.subarray(cursor, cursor + length);
|
||||
if (data.length < 5 || data.readUInt16BE(0) !== data.length - 2 || data[2] !== 0) {
|
||||
return { status: "invalid", reason: "invalid_server_name" };
|
||||
}
|
||||
const nameLength = data.readUInt16BE(3);
|
||||
if (nameLength !== data.length - 5) {
|
||||
return { status: "invalid", reason: "invalid_server_name_length" };
|
||||
}
|
||||
const serverName = normalizeHostname(data.subarray(5).toString("ascii"));
|
||||
if (!/^[a-z0-9.-]+$/.test(serverName)) {
|
||||
return { status: "invalid", reason: "invalid_server_name_value" };
|
||||
}
|
||||
return { status: "ok", serverName };
|
||||
}
|
||||
cursor += length;
|
||||
}
|
||||
return { status: "invalid", reason: "missing_sni" };
|
||||
}
|
||||
136
docker/devin-bridge/network-guard/proxy.mjs
Normal file
136
docker/devin-bridge/network-guard/proxy.mjs
Normal file
@@ -0,0 +1,136 @@
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import {
|
||||
isAllowedGuardHostname,
|
||||
parseConnectAuthority,
|
||||
parseTlsClientHelloSni,
|
||||
sanitizeForwardHeaders,
|
||||
} from "./policy.mjs";
|
||||
|
||||
const MAX_CLIENT_HELLO_BYTES = 64 * 1024;
|
||||
const CLIENT_HELLO_TIMEOUT_MS = 3000;
|
||||
|
||||
export function createGuardProxy({
|
||||
policy = "deny-all",
|
||||
logPath = "/tmp/egress.jsonl",
|
||||
allowHostname = (hostname) => isAllowedGuardHostname(hostname, policy),
|
||||
connectSocket = (port, hostname, onConnect) => net.connect(port, hostname, onConnect),
|
||||
} = {}) {
|
||||
if (!new Set(["deny-all", "devin"]).has(policy)) {
|
||||
throw new Error(`Unknown network guard policy: ${policy}`);
|
||||
}
|
||||
|
||||
function audit(hostname, decision, reason) {
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`${JSON.stringify({ at: new Date().toISOString(), hostname, decision, reason })}\n`
|
||||
);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
let target;
|
||||
try {
|
||||
target = new URL(req.url);
|
||||
} catch {
|
||||
res.writeHead(400).end("invalid proxy target\n");
|
||||
return;
|
||||
}
|
||||
if (target.protocol !== "http:" || target.username || target.password) {
|
||||
audit(target.hostname, "deny", "invalid_http_target");
|
||||
res.writeHead(403).end("egress denied\n");
|
||||
return;
|
||||
}
|
||||
if (!allowHostname(target.hostname)) {
|
||||
audit(target.hostname, "deny", "host_policy");
|
||||
res.writeHead(403).end("egress denied\n");
|
||||
return;
|
||||
}
|
||||
audit(target.hostname, "allow", "host_policy");
|
||||
const upstream = http.request(
|
||||
target,
|
||||
{
|
||||
method: req.method,
|
||||
headers: sanitizeForwardHeaders(req.headers, target),
|
||||
},
|
||||
(reply) => {
|
||||
res.writeHead(reply.statusCode || 502, reply.headers);
|
||||
reply.pipe(res);
|
||||
}
|
||||
);
|
||||
req.pipe(upstream);
|
||||
upstream.on("error", () => res.writeHead(502).end("upstream error\n"));
|
||||
});
|
||||
|
||||
server.on("connect", (req, client, head) => {
|
||||
const authority = parseConnectAuthority(req.url);
|
||||
if (!authority) {
|
||||
audit(req.url, "deny", "invalid_connect_authority");
|
||||
client.end("HTTP/1.1 403 Forbidden\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
const { hostname, port } = authority;
|
||||
if (!allowHostname(hostname)) {
|
||||
audit(hostname, "deny", "host_policy");
|
||||
client.end("HTTP/1.1 403 Forbidden\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
let buffer = Buffer.from(head);
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => fail("client_hello_timeout"), CLIENT_HELLO_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
client.removeListener("data", onData);
|
||||
};
|
||||
const fail = (reason) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
audit(hostname, "deny", reason);
|
||||
client.destroy();
|
||||
};
|
||||
const inspect = () => {
|
||||
if (buffer.length > MAX_CLIENT_HELLO_BYTES) return fail("client_hello_too_large");
|
||||
const parsed = parseTlsClientHelloSni(buffer);
|
||||
if (parsed.status === "need-more") return;
|
||||
if (parsed.status !== "ok") return fail(parsed.reason || "invalid_client_hello");
|
||||
if (parsed.serverName !== hostname) return fail("sni_mismatch");
|
||||
settled = true;
|
||||
cleanup();
|
||||
client.pause();
|
||||
const upstream = connectSocket(port, hostname, () => {
|
||||
audit(hostname, "allow", "sni_match");
|
||||
if (buffer.length) upstream.write(buffer);
|
||||
upstream.pipe(client);
|
||||
client.pipe(upstream);
|
||||
client.resume();
|
||||
});
|
||||
upstream.on("error", () => client.destroy());
|
||||
};
|
||||
const onData = (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
inspect();
|
||||
};
|
||||
|
||||
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
client.on("data", onData);
|
||||
if (buffer.length) inspect();
|
||||
client.resume();
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
||||
const [host, portText] = (process.env.GUARD_LISTEN || "0.0.0.0:8080").split(":");
|
||||
const server = createGuardProxy({
|
||||
policy: process.env.GUARD_POLICY || "deny-all",
|
||||
logPath: process.env.GUARD_LOG || "/tmp/egress.jsonl",
|
||||
});
|
||||
server.listen(Number(portText), host);
|
||||
}
|
||||
27
docker/devin-bridge/run-claude-e2e.sh
Executable file
27
docker/devin-bridge/run-claude-e2e.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
unset ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_BEDROCK_BASE_URL ANTHROPIC_VERTEX_BASE_URL
|
||||
unset CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX CLAUDE_CODE_USE_FOUNDRY
|
||||
|
||||
set -o pipefail
|
||||
check() {
|
||||
"$@"
|
||||
printf 'E2E check passed: %s\n' "$*"
|
||||
}
|
||||
|
||||
claude -p --output-format stream-json --verbose --max-turns 12 \
|
||||
--permission-mode bypassPermissions \
|
||||
"/bridge-check" | tee /evidence/claude-stream.jsonl
|
||||
|
||||
if grep -Eqi 'log[ -]?in|authenticate.*anthropic|claude\.ai' /evidence/claude-stream.jsonl; then
|
||||
echo "Claude Code requested forbidden authentication" >&2
|
||||
exit 1
|
||||
fi
|
||||
check grep -q 'return a + b;' /workspace/math.js
|
||||
npm test
|
||||
check grep -q 'Skill' /workspace/.e2e-hook.log
|
||||
check grep -q 'Read' /workspace/.e2e-hook.log
|
||||
check grep -q 'Edit' /workspace/.e2e-hook.log
|
||||
check grep -q 'Bash' /workspace/.e2e-hook.log
|
||||
check grep -q 'BRIDGE_E2E_COMPLETE' /evidence/claude-stream.jsonl
|
||||
52
docker/devin-bridge/run-claude-live-e2e.sh
Normal file
52
docker/devin-bridge/run-claude-live-e2e.sh
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
unset ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_BEDROCK_BASE_URL ANTHROPIC_VERTEX_BASE_URL
|
||||
unset CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX CLAUDE_CODE_USE_FOUNDRY
|
||||
|
||||
bridge_system_prompt="You are a coding agent inside Claude Code. Use only the client-owned tools supplied in the request. Never execute or request a Devin-owned tool. When work requires a tool, select the appropriate client tool and wait for its result before continuing."
|
||||
scenario_cooldown_seconds="${DEVIN_BRIDGE_LIVE_SCENARIO_COOLDOWN_SECONDS:-15}"
|
||||
|
||||
run_scenario() {
|
||||
local evidence_file="$1"
|
||||
local prompt="$2"
|
||||
claude -p --output-format stream-json --verbose --max-turns 12 \
|
||||
--tools Read,Edit,Bash \
|
||||
--system-prompt "$bridge_system_prompt" \
|
||||
--permission-mode bypassPermissions "$prompt" | tee "$evidence_file"
|
||||
if grep -Eqi 'log[ -]?in|authenticate.*anthropic|claude\.ai' "$evidence_file"; then
|
||||
echo "Claude Code requested forbidden authentication" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_scenario() {
|
||||
local evidence_file="$1"
|
||||
local marker="$2"
|
||||
local required_tools="$3"
|
||||
local require_npm_test="$4"
|
||||
local required_slash_command="${5:-}"
|
||||
local required_skill="${6:-}"
|
||||
local accept_explicit_completion="${7:-false}"
|
||||
node /opt/omniroute/scripts/devin-bridge/validate-claude-evidence.mjs \
|
||||
"$evidence_file" "$marker" "$required_tools" "$require_npm_test" \
|
||||
"$required_slash_command" "$required_skill" "$accept_explicit_completion"
|
||||
}
|
||||
|
||||
run_scenario /evidence/live-analysis.jsonl \
|
||||
"Read /workspace/CLAUDE.md, /workspace/math.js, and /workspace/math.test.js directly without searching or editing. Explain the defect, then end with LIVE_ANALYSIS_COMPLETE."
|
||||
validate_scenario /evidence/live-analysis.jsonl LIVE_ANALYSIS_COMPLETE Read false
|
||||
sleep "$scenario_cooldown_seconds"
|
||||
|
||||
run_scenario /evidence/live-fix.jsonl \
|
||||
"Use Edit now to replace 'return a - b;' with 'return a + b;' in /workspace/math.js. Then use Bash to run npm test. Do not summarize before npm test succeeds. End with LIVE_FIX_COMPLETE only after the test passes."
|
||||
grep -q 'return a + b;' /workspace/math.js
|
||||
npm test
|
||||
validate_scenario /evidence/live-fix.jsonl LIVE_FIX_COMPLETE Edit,Bash true
|
||||
sleep "$scenario_cooldown_seconds"
|
||||
|
||||
run_scenario /evidence/live-command.jsonl "/bridge-check"
|
||||
validate_scenario /evidence/live-command.jsonl BRIDGE_E2E_COMPLETE Bash true \
|
||||
bridge-check bridge-proof true
|
||||
|
||||
printf 'PASS: three live Devin-backed Claude Code scenarios completed\n'
|
||||
135
docker/devin-bridge/run-contract.mjs
Normal file
135
docker/devin-bridge/run-contract.mjs
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const endpoint = "http://omniroute:20128/v1/messages";
|
||||
const headers = {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
"x-api-key": "sk-local-devin-gateway",
|
||||
};
|
||||
const model = process.env.DEVIN_BRIDGE_MODEL || "devin-cli-agentic/swe-1-7";
|
||||
|
||||
async function request(prompt, extra = {}) {
|
||||
return fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 256,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
...extra,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const textReply = await request("CONTRACT_TEXT");
|
||||
assert.equal(textReply.status, 200);
|
||||
assert.match(textReply.headers.get("content-type") || "", /application\/json/);
|
||||
const textBody = await textReply.json();
|
||||
assert.equal(textBody.type, "message");
|
||||
assert.equal(textBody.role, "assistant");
|
||||
assert.equal(textBody.stop_reason, "end_turn");
|
||||
assert.deepEqual(textBody.content, [{ type: "text", text: "contract text" }]);
|
||||
|
||||
const toolReply = await request("CONTRACT_TOOL", {
|
||||
stream: true,
|
||||
tools: [
|
||||
{
|
||||
name: "Read",
|
||||
description: "Read a file",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: { file_path: { type: "string" } },
|
||||
required: ["file_path"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(toolReply.status, 200);
|
||||
assert.match(toolReply.headers.get("content-type") || "", /text\/event-stream/);
|
||||
const toolStream = await toolReply.text();
|
||||
const eventNames = toolStream
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("event: "))
|
||||
.map((line) => line.slice(7));
|
||||
assert.deepEqual(eventNames, [
|
||||
"message_start",
|
||||
"content_block_start",
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
"message_delta",
|
||||
"message_stop",
|
||||
]);
|
||||
const toolEvents = toolStream
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("data: "))
|
||||
.map((line) => JSON.parse(line.slice(6)));
|
||||
const toolUse = toolEvents.find((event) => event.type === "content_block_start")?.content_block;
|
||||
assert.equal(toolUse?.type, "tool_use");
|
||||
assert.equal(toolUse?.name, "Read");
|
||||
assert.match(toolUse?.id || "", /^tool_devin_/);
|
||||
|
||||
const repairedNarrativeReply = await request("CONTRACT_NARRATIVE_REPAIR", {
|
||||
tools: [
|
||||
{
|
||||
name: "Read",
|
||||
description: "Read a file",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: { file_path: { type: "string" } },
|
||||
required: ["file_path"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(repairedNarrativeReply.status, 200);
|
||||
const repairedNarrativeBody = await repairedNarrativeReply.json();
|
||||
assert.equal(repairedNarrativeBody.stop_reason, "tool_use");
|
||||
assert.equal(repairedNarrativeBody.content?.[0]?.type, "tool_use");
|
||||
assert.equal(repairedNarrativeBody.content?.[0]?.name, "Read");
|
||||
|
||||
const continuationReply = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 256,
|
||||
tools: [
|
||||
{
|
||||
name: "Read",
|
||||
description: "Read a file",
|
||||
input_schema: { type: "object", properties: {}, additionalProperties: true },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{ role: "user", content: "CONTRACT_TOOL" },
|
||||
{ role: "assistant", content: [toolUse] },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: toolUse.id,
|
||||
content: "CONTRACT_AFTER_TOOL",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
assert.equal(continuationReply.status, 200);
|
||||
const continuationBody = await continuationReply.json();
|
||||
assert.equal(continuationBody.stop_reason, "end_turn");
|
||||
assert.deepEqual(continuationBody.content, [{ type: "text", text: "contract continued" }]);
|
||||
|
||||
for (const marker of ["CONTRACT_ERROR", "CONTRACT_EXIT"]) {
|
||||
const failedReply = await request(marker);
|
||||
assert.equal(failedReply.status, 502);
|
||||
const failedBody = await failedReply.json();
|
||||
assert.equal(failedBody.error?.type, "server_error");
|
||||
assert.doesNotMatch(JSON.stringify(failedBody), /stack|anthropic|openai/i);
|
||||
}
|
||||
|
||||
console.log("PASS: Anthropic Messages wire contracts and fail-closed errors passed");
|
||||
181
docs/DEVIN_CLAUDE_BRIDGE.md
Normal file
181
docs/DEVIN_CLAUDE_BRIDGE.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Devin Claude Bridge
|
||||
|
||||
`devin-cli-agentic` lets the real Claude Code runtime use OmniRoute's local Anthropic
|
||||
Messages endpoint while the official Devin CLI supplies model responses over ACP stdio. It
|
||||
does not modify the existing Anthropic, Claude OAuth, Claude Web, or `devin-cli` providers.
|
||||
|
||||
> **Current status: offline and live validated.** The pinned Claude Code `2.1.220` completed
|
||||
> three isolated scenarios through Devin CLI `3000.2.17` and model
|
||||
> `swe-1-7-lightning`. The final live run proved client-owned `Read`, `Edit`, and `Bash`
|
||||
> turns, successful `npm test` results, project command and skill discovery, Devin-only
|
||||
> routing, and zero Claude egress.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
Claude Code 2.1.220 (isolated non-root Linux container)
|
||||
-> http://omniroute:20128/v1/messages
|
||||
-> devin-cli-agentic (Claude-format, no-auth provider)
|
||||
-> devin acp --agent-type summarizer (official ACP stdio, no Devin tools)
|
||||
-> Devin account in the dedicated devin-auth volume
|
||||
```
|
||||
|
||||
The official CLI's default ACP agent can execute its own tools, so this bridge does not use
|
||||
it. It starts the fixed `summarizer` ACP agent, whose official CLI mode has no tools, and
|
||||
frames the serialized Anthropic request as an execution trace. When another Claude-owned
|
||||
action is needed, the response must contain exactly one client tool envelope. Any ACP
|
||||
`tool_call` or `tool_call_update` is rejected before a response can be reported as
|
||||
successful.
|
||||
|
||||
The serializer in `open-sse/executors/devin-agentic/serializer.ts` preserves `system`,
|
||||
`text`, `tool_use`, `tool_result`, `thinking`, `redacted_thinking`, `tool_choice`, and the
|
||||
tools supplied by Claude Code. Images and unknown blocks fail explicitly. Large tool results
|
||||
use a visible truncation marker.
|
||||
|
||||
The parser accepts one standalone `<tool>{...}</tool>` envelope per model turn. It checks
|
||||
the name against the request's tool list, validates arguments against that tool's JSON
|
||||
Schema, rejects mixed narrative/actions, and permits one bounded repair. Claude Code then
|
||||
executes the resulting Anthropic `tool_use` locally and sends the `tool_result` back through
|
||||
OmniRoute.
|
||||
|
||||
## Isolation and threat model
|
||||
|
||||
The host's Claude installation, account, and configuration are out of scope and treated as
|
||||
forbidden. The Compose services:
|
||||
|
||||
- run as UID/GID `10001:10001`, with a read-only root filesystem, dropped capabilities, and
|
||||
`no-new-privileges`;
|
||||
- use a private `/home/bridge`, a dedicated Claude config volume, isolated OmniRoute data,
|
||||
and a separate `devin-auth` volume;
|
||||
- mount only disposable `.sandbox` workspaces/evidence;
|
||||
- do not mount the host home, Keychain, SSH, cloud credentials, or Docker socket;
|
||||
- construct explicit environments and remove Anthropic API/OAuth/routing variables;
|
||||
- direct Claude Code inference only to `http://omniroute:20128` with a local-only key.
|
||||
|
||||
The offline profile uses an internal network. In the live profile, OmniRoute reaches the
|
||||
official Devin endpoints only through `network-guard`; unrelated destinations are denied.
|
||||
Claude Code has a separate deny-all egress guard and can reach only the local OmniRoute
|
||||
service through `NO_PROXY`. Guard audit files are mounted only by their guard process. The
|
||||
scripts verify file ownership, mode, link count, and every decision before exporting
|
||||
token-free evidence.
|
||||
|
||||
Run the isolation proof independently:
|
||||
|
||||
```bash
|
||||
./scripts/devin-bridge/verify-anthropic-isolation
|
||||
```
|
||||
|
||||
It validates topology, named mounts, non-root/read-only settings, explicit local routing,
|
||||
absence of sensitive environment variables, absence of the Docker socket, blocked access to
|
||||
`api.anthropic.com` and `claude.ai`, Devin-only provider selection, and explicit failure when
|
||||
the ACP backend is unavailable.
|
||||
|
||||
## First-time setup and normal use
|
||||
|
||||
Build the pinned image:
|
||||
|
||||
```bash
|
||||
./scripts/devin-bridge/build
|
||||
```
|
||||
|
||||
Authenticate only the isolated Devin volume:
|
||||
|
||||
```bash
|
||||
ENABLE_LIVE_DEVIN_TESTS=1 ./scripts/devin-bridge/login-devin
|
||||
```
|
||||
|
||||
The login command uses the official manual-token flow intended for remote/container
|
||||
environments. The value is entered directly into the CLI prompt; it is not passed as a
|
||||
process argument, written to Git, or copied from the host.
|
||||
|
||||
Launch the isolated Claude Code runtime:
|
||||
|
||||
```bash
|
||||
./scripts/devin-bridge/launch
|
||||
```
|
||||
|
||||
`launch` rechecks isolation, Devin authentication, and model discovery before starting the
|
||||
containerized Claude Code. It never runs the host's Claude executable. Model aliases can be
|
||||
set in `.env.devin-bridge`; every configured value must keep the
|
||||
`devin-cli-agentic/` prefix.
|
||||
|
||||
## Validation commands
|
||||
|
||||
The reproducible offline path requires no Devin account and has no runtime Internet:
|
||||
|
||||
```bash
|
||||
./scripts/devin-bridge/test-unit
|
||||
./scripts/devin-bridge/test-contract
|
||||
./scripts/devin-bridge/test-e2e-mock
|
||||
./scripts/devin-bridge/verify-anthropic-isolation
|
||||
```
|
||||
|
||||
The authenticated opt-in live path is:
|
||||
|
||||
```bash
|
||||
ENABLE_LIVE_DEVIN_TESTS=1 ./scripts/devin-bridge/test-live-devin
|
||||
```
|
||||
|
||||
The live runner waits between scenarios to avoid opening ACP sessions in a burst and
|
||||
validates structured Claude stream events instead of trusting textual claims. Its three
|
||||
scenarios prove:
|
||||
|
||||
1. direct project reads and defect analysis;
|
||||
2. a real `Edit`, a client-owned `Bash` `npm test`, and a terminal result;
|
||||
3. `/bridge-check` plus `bridge-proof` discovery, project reads, another successful
|
||||
client-owned `npm test`, and completion without pending work.
|
||||
|
||||
The final gate also checks the Devin network audit and requires the Claude egress audit to
|
||||
remain empty.
|
||||
|
||||
## Updating pinned tools
|
||||
|
||||
The image pins Node, Claude Code, and Devin CLI in
|
||||
`docker/devin-bridge/Dockerfile`. To update:
|
||||
|
||||
1. change the explicit versions;
|
||||
2. replace both architecture-specific Devin archive checksums with values for the official
|
||||
artifact;
|
||||
3. rebuild and run every offline validation command;
|
||||
4. confirm the versions inside the image;
|
||||
5. rerun the authenticated three-scenario live suite.
|
||||
|
||||
Do not install either CLI globally on the host or replace checksum verification with an
|
||||
unverified download.
|
||||
|
||||
## Diagnosis and cleanup
|
||||
|
||||
- `docker compose -f docker/devin-bridge/compose.yml --profile offline logs omniroute`
|
||||
shows local routing and sanitized executor errors.
|
||||
- `.sandbox/evidence/mock-acp.jsonl` records deterministic mock ACP actions.
|
||||
- `.sandbox/evidence/claude-stream.jsonl` records the real Claude Code offline run.
|
||||
- `.sandbox/evidence/live-*.jsonl` records the three validated live streams.
|
||||
- `.sandbox/evidence/egress.jsonl` and `.sandbox/evidence/claude-egress.jsonl` are validated,
|
||||
token-free copies of the guard audits.
|
||||
|
||||
Stop owned containers and networks while preserving login/config volumes:
|
||||
|
||||
```bash
|
||||
./scripts/devin-bridge/clean
|
||||
```
|
||||
|
||||
Remove the complete bridge-owned environment, including named volumes:
|
||||
|
||||
```bash
|
||||
./scripts/devin-bridge/clean --all
|
||||
```
|
||||
|
||||
## Limits
|
||||
|
||||
- The bridge relies on the fixed no-tools `summarizer` role because Devin CLI `3000.2.17`
|
||||
does not expose a neutral no-tools ACP agent. The adapter compensates for summary-shaped
|
||||
intermediate responses, but one bounded repair can still fail explicitly.
|
||||
- Live ACP calls can return transient `502`/`504` responses. The harness spaces scenarios;
|
||||
persistent failure remains fail-closed and never selects another provider.
|
||||
- ACP context is reconstructed from each Anthropic request; there is no process/session
|
||||
affinity.
|
||||
- One tool call is supported per model response; parallel calls are rejected.
|
||||
- Images are explicitly unsupported. Vision, thinking output, effort controls, and a 1M
|
||||
context window are not advertised.
|
||||
- SSE uses valid Anthropic lifecycle events but is emitted after the bounded ACP turn is
|
||||
collected; ACP chunks are not forwarded incrementally.
|
||||
115
docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md
Normal file
115
docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Devin Claude Bridge Progress
|
||||
|
||||
Updated: 2026-07-28
|
||||
|
||||
## Baseline
|
||||
|
||||
- Fork version: `3.8.49`.
|
||||
- Starting branch: `release/v3.8.49`.
|
||||
- Starting commit: `ed7db3ee5f89a144b2d931d8605534522f83de30`.
|
||||
- Fixed runtime artifacts: Node `26.0.0`, Claude Code `2.1.220`, Devin CLI `3000.2.17`.
|
||||
- Existing `devin-cli` remains unchanged; the new path is the separate
|
||||
`devin-cli-agentic` provider.
|
||||
|
||||
## Implemented architecture
|
||||
|
||||
- Claude Code runs only inside the non-root bridge container with its own empty config
|
||||
volume and local OmniRoute base URL.
|
||||
- `devin-cli-agentic` preserves Anthropic messages, tool schemas, `tool_use`, and
|
||||
`tool_result`, then calls the official Devin CLI over ACP stdio.
|
||||
- The executor starts `devin acp --agent-type summarizer`. This is the only fixed official
|
||||
ACP role in the pinned CLI that has no Devin-owned tools.
|
||||
- The request is framed as an execution trace. Devin can return one strict client tool
|
||||
envelope; Claude Code executes that tool locally.
|
||||
- Internal ACP `tool_call` events, unsupported blocks, invalid schemas, narrative actions,
|
||||
timeouts, cancellation, and process failure all fail closed.
|
||||
- Provider and network policy prevent combo/auto/Anthropic fallback.
|
||||
|
||||
## Offline proof
|
||||
|
||||
- Focused serializer, parser, executor, ACP lifecycle, wire-format, environment, and audit
|
||||
tests pass (39/39).
|
||||
- The contract suite covers Anthropic JSON/SSE, `tool_use`, `tool_result` continuation,
|
||||
fragmented ACP frames, stderr, early exit, timeout, cancellation, and fail-closed provider
|
||||
loss.
|
||||
- The production bridge image builds with the pinned CLIs.
|
||||
- Real Claude Code offline E2E loads `CLAUDE.md`, the project skill and slash command, fires
|
||||
hooks, executes local tools over multiple turns, observes a failed test, repairs the file,
|
||||
reruns the test, and completes.
|
||||
- The isolation verifier proves non-root/read-only execution, isolated mounts and config,
|
||||
blocked Anthropic/Claude access, no host credential mounts, local-only inference, and no
|
||||
fallback.
|
||||
|
||||
Evidence is generated under `.sandbox/evidence` and ignored by Git.
|
||||
|
||||
## Regression status
|
||||
|
||||
- `typecheck:core`, focused ESLint, Prettier, shell/Node syntax, and the complete documentation
|
||||
accuracy suite pass.
|
||||
- The broad `npm run check` is not reported as passed: after its lint phase, the repository
|
||||
test runner remained alive while an existing `ioredis` client repeatedly retried an
|
||||
unavailable local Redis endpoint after `quota-redis-store.test.ts`. The bridge-focused
|
||||
suites, production image build, offline E2E, isolation proof, and live gate do not use that
|
||||
Redis service and all pass.
|
||||
|
||||
## Live Devin proof
|
||||
|
||||
Passed with the official in-container login and discovered model
|
||||
`swe-1-7-lightning`. The terminal live run completed all three scenarios:
|
||||
|
||||
1. Claude Code loaded the fixture instructions, issued client-owned `Read` calls, and
|
||||
returned a correct defect analysis.
|
||||
2. Claude Code issued a real `Edit` changing subtraction to addition, then a client-owned
|
||||
`Bash` call running `npm test`; the test reported one pass and zero failures.
|
||||
3. Claude Code initialization listed `bridge-check` and `bridge-proof`, read the corrected
|
||||
source and test, executed another client-owned `npm test`, and completed successfully.
|
||||
|
||||
The live evidence validator parses stream JSON and requires successful tool results. It does
|
||||
not accept a textual claim that a tool ran. It also rejects terminal summaries that report a
|
||||
blocker, incomplete work, or required next steps.
|
||||
|
||||
The final live gate reported:
|
||||
|
||||
```text
|
||||
PASS: validated Claude evidence for LIVE_ANALYSIS_COMPLETE
|
||||
PASS: validated Claude evidence for LIVE_FIX_COMPLETE
|
||||
PASS: validated Claude evidence for BRIDGE_E2E_COMPLETE
|
||||
PASS: three live Devin-backed Claude Code scenarios completed
|
||||
PASS: live model swe-1-7-lightning was discovered and validated by three scenarios
|
||||
```
|
||||
|
||||
The same gate validated the network audit: only the Devin guard path was used, no internal
|
||||
Devin tool event was accepted, and the Claude egress audit remained empty.
|
||||
|
||||
## Investigation conclusion
|
||||
|
||||
The initial default-agent hypothesis failed because ACP permission modes do not turn the
|
||||
default Devin agent into a raw inference backend. Even `ask` mode can emit Devin-owned
|
||||
`tool_call` events. A discovered `allowed-tools: []` agent configuration was not consumed by
|
||||
`devin acp` in CLI `3000.2.17`.
|
||||
|
||||
The working adaptation uses the official `summarizer` agent because it is structurally
|
||||
no-tools. Its fixed summarization behavior can produce intermediate prose, so the bridge
|
||||
frames requests as execution traces, detects future-action narration, performs at most one
|
||||
strict repair, and otherwise fails. Live validation also exposed transient ACP timeouts;
|
||||
the harness now spaces independent scenarios rather than weakening routing or retrying into
|
||||
another provider.
|
||||
|
||||
## Safety record
|
||||
|
||||
No host Claude executable, configuration, login, OAuth token, Keychain, or Anthropic API was
|
||||
used. The dedicated Docker volumes remain role-separated. No credential value is written to
|
||||
the repository or evidence output.
|
||||
|
||||
During the early baseline, a focused test without isolated `DATA_DIR` initialized the
|
||||
repository's normal OmniRoute database at `/Users/lucasisrael/.omniroute/storage.sqlite`.
|
||||
It was not rolled back or touched again. Every bridge command now pins database and temporary
|
||||
paths under the worktree's `.sandbox` directory.
|
||||
|
||||
## Remaining limits
|
||||
|
||||
- The no-tools backend has a summarizer system role rather than a neutral generation role.
|
||||
- One client tool call per response is supported; parallel tool calls are rejected.
|
||||
- ACP processes are per-turn and stateless.
|
||||
- Live Devin availability can still produce explicit `502`/`504` failures.
|
||||
- Images and unadvertised vision/effort/large-context capabilities remain unsupported.
|
||||
@@ -64,6 +64,8 @@ How the system is put together — read these to understand the runtime, code la
|
||||
- [QUALITY_GATES.md](architecture/QUALITY_GATES.md) — quality-gate scripts and CI jobs inventory.
|
||||
- [MONITORING_SECTIONS.md](architecture/MONITORING_SECTIONS.md) — monitoring/costs dashboard navigation.
|
||||
- [cluster-decisions.md](architecture/cluster-decisions.md) — optional sidecar/cluster profile decisions.
|
||||
- [rust-port-research.md](rust-port-research.md) — feasibility study for porting the CPU-bound compression/tokenization core to a standalone Rust library (fork research, 2026-07-31).
|
||||
- [infrastructure.md](infrastructure.md) — deployment topology (Proxmox / LXC 101 / LXC 106 / Forgejo / Docker) and push flows (fork research, 2026-07-31).
|
||||
|
||||
## reference/
|
||||
|
||||
|
||||
99
docs/infrastructure.md
Normal file
99
docs/infrastructure.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# OmniRoute Deployment & Infrastructure
|
||||
|
||||
> **Date**: 2026-07-31
|
||||
> **Scope**: Local Mac dev instance + Proxmox/LXC production layout. Context for anyone resuming work.
|
||||
|
||||
## Topology
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ HOST Proxmox 100.73.44.17 (pve-n150.tailad1b81.ts.net) │
|
||||
│ │
|
||||
│ tailscaled → holds :443 (Funnel) │
|
||||
│ ├─ / → http://192.168.3.101:80/ (Forgejo) │
|
||||
│ └─ /omniroute → http://192.168.3.106:20128/ (OmniRoute)│
|
||||
│ │
|
||||
│ Docker: │
|
||||
│ ├─ openhands :3000 (host-network, --privileged) │
|
||||
│ └─ amnezia-awg2 :48243/udp (WireGuard, do NOT touch) │
|
||||
└───────┬───────────────────────────────────────────────────┘
|
||||
│ LXC (lxc-attach -n <id>)
|
||||
┌───────┴──────────┐ ┌────────────────────────────────────┐
|
||||
│ LXC 101 │ │ LXC 106 (agent-node, 192.168.3.106)│
|
||||
│ Forgejo :80 │ │ Docker: │
|
||||
│ (git-repositories)│ │ ├─ omniroute :20128 (data→/opt/ │
|
||||
└───────────────────┘ │ │ omniroute/data) │
|
||||
│ │ └─ openhands :8000/18000/8002 │
|
||||
│ │ (OLD duplicate — DELETE) │
|
||||
│ ├─ systemd project-history :43128 │
|
||||
│ ├─ component-vault :43133 (old) │
|
||||
│ └─ iptables: INPUT DROP + ACCEPT │
|
||||
│ for 22,20128,43128,43133,... │
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Where | Details |
|
||||
|---|---|---|
|
||||
| **Tailscale** | host | `tailscale serve` with Funnel; certs `/var/lib/tailscale/certs/pve-n150.*` |
|
||||
| **Caddy** | — | **not installed** (no package, no Caddyfile) — HTTPS handled by Tailscale Serve |
|
||||
| **OpenHands** | host, docker | image `openhands:fixed` (`4e631813f208`), host-network, privileged; DB in `/opt/openhands/workspace/.openhands-state`; created via `docker run -e LLM_MODEL=ds/deepseek-v4-flash -e LLM_BASE_URL=http://192.168.3.106:20128/v1 -e LLM_API_KEY=sk-d146...` (backup: `/opt/openhands/container-config-backup.txt`) |
|
||||
| **OmniRoute** | LXC 106, docker | image `diegosouzapw/omniroute`, mount `/opt/omniroute/data→/app/data`, cmd `node dev/run-standalone.mjs`; sources/build: `/opt/omniroute-build` (git + Dockerfile + compose) |
|
||||
| **Forgejo** | LXC 101 | git server, `http://192.168.3.101`, external `https://pve-n150.tailad1b81.ts.net/` (path prefix `/git/`; Gitea 15.0.1) |
|
||||
| **project-history** | LXC 106, systemd | Rust, `/opt/project-history` (src + binary + data), port 43128 |
|
||||
|
||||
## Pushing changes
|
||||
|
||||
**1. To Forgejo (any session):** remote `http://192.168.3.101/egorich/<repo>.git`.
|
||||
From Mac: `https://egorich:<token>@pve-n150.tailad1b81.ts.net/git/egorich/<repo>.git`
|
||||
⚠️ URL-encode `@` in the password as `%40`.
|
||||
|
||||
**2. To OpenHands (code/fixes):** image built via `docker commit`, so change = edit inside container + commit image:
|
||||
```bash
|
||||
docker exec -it openhands bash # edit /app/openhands/...
|
||||
docker commit openhands openhands:fixed # fix patch into image
|
||||
docker restart openhands # apply
|
||||
```
|
||||
Env config (model, key, CORS): recreate container with same command from `/opt/openhands/container-config-backup.txt` + new `-e`.
|
||||
|
||||
**3. To OmniRoute (LXC 106):**
|
||||
```bash
|
||||
lxc-attach -n 106 -- bash
|
||||
cd /opt/omniroute-build # git pull / checkout pr/fix-pack
|
||||
docker compose -f docker-compose.prod.yml build
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
Data (providers, keys) in `/opt/omniroute/data` — survives rebuild (volume).
|
||||
|
||||
**4. To project-history (LXC 106):**
|
||||
```bash
|
||||
lxc-attach -n 106 -- bash
|
||||
cd /opt/project-history # or git clone from Forgejo (no .git there!)
|
||||
# edit src/, then:
|
||||
cargo build --release
|
||||
systemctl restart project-history
|
||||
curl http://127.0.0.1:43128/api/health
|
||||
```
|
||||
|
||||
## Exposing a new path over HTTPS
|
||||
```bash
|
||||
tailscale serve --bg --set-path /history http://192.168.3.106:43128/
|
||||
```
|
||||
|
||||
## Do NOT touch
|
||||
- iptables in LXC 106 (INPUT DROP, persistent rules)
|
||||
- `omniroute` (needed by OpenHands)
|
||||
- `amnezia-awg2`
|
||||
- DB `data/project_history.sqlite3`
|
||||
|
||||
## Access notes (Mac)
|
||||
|
||||
- SSH to Proxmox/LXC **does not work** from this Mac (Tailscale is stopped here; ports time out).
|
||||
- Forgejo API works over `https://pve-n150.tailad1b81.ts.net/git/api/v1/` (Basic auth `egorich`).
|
||||
- Everything else reachable only from the Proxmox host / LXC sessions.
|
||||
|
||||
## Forgejo repo (created 2026-07-31)
|
||||
|
||||
- `egorich/OmniRoute` — branches `pr/fix-pack` (PR-ready), `feat/personal-build` (full history)
|
||||
- GitHub PR: https://github.com/diegosouzapw/OmniRoute/pull/9058
|
||||
94
docs/providers/CHATGPT_WEB_CODEX.md
Normal file
94
docs/providers/CHATGPT_WEB_CODEX.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# ChatGPT Web (Codex)
|
||||
|
||||
`ChatGPT Web (Codex)` ist ein zusätzlicher Provider. Der bestehende Provider
|
||||
`ChatGPT Web (Plus/Pro)` bleibt für normale Chats, Bilder und dessen bisherige
|
||||
Tool-Emulation unverändert.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- ein vollständiger Cookie-Header einer angemeldeten ChatGPT-Sitzung;
|
||||
- Chrome oder Chromium bei npm-, systemd- und PM2-Installationen;
|
||||
- beim Docker-Profil `web` der interne Chromium-Dienst aus `docker-compose.yml`;
|
||||
- ein OpenAI-Tunnel und ein ChatGPT-Custom-Connector für lokale Codex-Tools.
|
||||
|
||||
Der Tunnel ist nur für Tool-Runden nötig. `pro` ist read-only und benötigt keinen
|
||||
lokalen Tool-Connector.
|
||||
|
||||
## Einrichtung in der Weboberfläche
|
||||
|
||||
1. Öffne den Provider `ChatGPT Web (Codex)` und füge eine Connection hinzu.
|
||||
2. Füge den vollständigen ChatGPT-Cookie, die Tunnel-ID, den Runtime-Key und den
|
||||
Namen des Custom Connectors ein.
|
||||
3. Starte die Prüfung. OmniRoute öffnet headless einen Temporary Chat und erkennt
|
||||
dabei auch, ob `pro` für das Konto verfügbar ist.
|
||||
4. Speichere die Connection. OmniRoute ersetzt den eingegebenen Cookie durch den
|
||||
geprüften Playwright-Storage-State und speichert ihn zusammen mit dem Runtime-Key
|
||||
über die verschlüsselte Credential-Abstraktion.
|
||||
|
||||
Der rohe Cookie wird nach erfolgreichem Speichern nicht zusätzlich aufbewahrt.
|
||||
Wenn die Sitzung abläuft, öffne die Connection, gib einen frischen vollständigen
|
||||
Cookie ein und prüfe sie erneut. Der Doctor-Status im Edit-Dialog zeigt Browser,
|
||||
Storage-State, Anmeldung, Temporary Chat, Tunnel, Connector und Tool-Roundtrip
|
||||
getrennt an.
|
||||
|
||||
## Modelle und Combos
|
||||
|
||||
Die festen Modelle sind:
|
||||
|
||||
- `chatgpt-web-codex/instant`
|
||||
- `chatgpt-web-codex/medium`
|
||||
- `chatgpt-web-codex/high`
|
||||
- `chatgpt-web-codex/extra-high`
|
||||
- `chatgpt-web-codex/pro`
|
||||
|
||||
Füge eines davon wie jedes andere Modell zu einer Combo hinzu. Die Codex-App
|
||||
sendet nur den Combo-Namen als `model` an den normalen Responses-Endpunkt
|
||||
`/v1/responses`. Es gibt keinen Sonderendpoint und keinen Codex-Modus-Schalter.
|
||||
|
||||
`pro` führt keine lokalen Tools aus. Ein erzwungenes Tool macht dieses Combo-Ziel
|
||||
inkompatibel; bei optionalen Tools läuft der Turn read-only und meldet diese
|
||||
Einschränkung als Commentary.
|
||||
|
||||
## Sicherheitsmodell
|
||||
|
||||
- Der native Pfad verlangt einen Responses-Request, einen erkannten Codex-Client
|
||||
sowie zusammenpassende Thread- und Turn-Identitäten.
|
||||
- Workspace, Sandbox, Approval-Policy und Toolkatalog stammen aus der nativen
|
||||
Codex-Hülle. Freier Prompttext ist dafür keine Autorität.
|
||||
- ChatGPT erhält pro Turn nur eine kurzlebige Capability. Der MCP-Broker akzeptiert
|
||||
ausschließlich Tools, die Codex in genau diesem Turn angeboten hat.
|
||||
- Das automatische Bestätigen von „Allow once“ gibt nur den Tool-Wunsch an Codex
|
||||
zurück. Codex allein entscheidet über Freigabe und Ausführung.
|
||||
- Vor dem ersten Output darf die Combo auf ein anderes kompatibles Ziel fallen.
|
||||
Danach bleiben Provider, Modell, Connection und Browserturn bis zum Abschluss
|
||||
gepinnt.
|
||||
- Cookies, Runtime-Keys, Storage-State und Capability-Tokens erscheinen nicht in
|
||||
Providerantworten oder Request-Logs.
|
||||
|
||||
## Headless VPS und Docker
|
||||
|
||||
Bei npm-, systemd- und PM2-Betrieb erkennt OmniRoute übliche Chrome- und
|
||||
Chromium-Pfade. Alternativ kann `CHATGPT_WEB_CODEX_CHROME_PATH` gesetzt werden.
|
||||
|
||||
Das Docker-Profil `web` startet `chatgpt-web-codex-browser` im internen
|
||||
Compose-Netz. Sein CDP-Port wird nicht auf dem Host veröffentlicht. Das geschützte
|
||||
Profilvolume bleibt getrennt vom OmniRoute-Datenvolume und der Browser erhält
|
||||
ausreichend Shared Memory. Der interne CDP-Proxy lauscht nur im Compose-Netz auf
|
||||
Port `9223`; Chrome selbst bleibt im Sidecar an Loopback gebunden.
|
||||
|
||||
Eine Supervisor-Lease unter `DATA_DIR` verhindert, dass mehrere OmniRoute-Prozesse
|
||||
denselben Tunnel- und Brokerzustand besitzen. Ein Konflikt erscheint im Doctor.
|
||||
|
||||
## Interaktive Wiederherstellung
|
||||
|
||||
Der normale Pfad ist vollständig headless. Wenn ChatGPT eine interaktive
|
||||
Anmeldung oder Challenge verlangt, kann die bestehende VNC-Browser-Infrastruktur
|
||||
als Recovery-Weg verwendet werden. Browser-UI und CDP dürfen dabei nur über
|
||||
Loopback, eine authentifizierte Managementverbindung oder einen SSH-Tunnel
|
||||
erreichbar sein; noVNC bleibt im normalen Betrieb deaktiviert.
|
||||
|
||||
## WebSocket-Fallback
|
||||
|
||||
Enthält eine Combo `ChatGPT Web (Codex)`, fordert die Responses-WebSocket-Brücke
|
||||
vor der Upstream-Verbindung den HTTP/SSE-Fallback an. Die eigentliche Übertragung
|
||||
erfolgt dann über `/v1/responses`.
|
||||
@@ -377,6 +377,14 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex,
|
||||
| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. |
|
||||
| `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Custom path to the Qwen Code CLI binary. |
|
||||
| `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Custom path to the Devin CLI binary (v3.8.0). Used by the Windsurf/Devin executor. |
|
||||
| `CLI_DEVIN_AGENTIC_BIN` | `devin` | `open-sse/executors/devin-cli-agentic.ts` | Agentic bridge-only Devin CLI override. The executor accepts only the local ACP stdio upstream. |
|
||||
| `DEVIN_AGENTIC_HOME` | _(required)_ | `open-sse/executors/devin-cli-agentic.ts` | Absolute isolated home for the agentic Devin subprocess; accepted bridge paths are `/home/bridge` and task-local `.sandbox` paths. |
|
||||
| `DEVIN_AGENTIC_ACP_TIMEOUT_MS` | `120000` | `open-sse/executors/devin-cli-agentic.ts` | Maximum duration of one Devin ACP turn before the bridge terminates the child and returns an explicit timeout. |
|
||||
| `DEVIN_BRIDGE_MODEL` | `devin-cli-agentic/swe-1-7` | `docker/devin-bridge/compose.yml` | Main Claude Code model alias for the isolated bridge. The live harness replaces the example with a model returned by the current Devin account. |
|
||||
| `DEVIN_BRIDGE_SONNET_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Sonnet default. |
|
||||
| `DEVIN_BRIDGE_OPUS_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Opus default. |
|
||||
| `DEVIN_BRIDGE_HAIKU_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Haiku default. |
|
||||
| `DEVIN_BRIDGE_SUBAGENT_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used for Claude Code subagents. |
|
||||
| `AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Absolute-path override for the Augment (Auggie) CLI binary used by the local `auggie` provider. Falls back to `CLI_AUGGIE_BIN`, then a PATH lookup. |
|
||||
| `CLI_AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Alias override for the Augment (Auggie) CLI binary path (checked after `AUGGIE_BIN`). |
|
||||
| `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent home directory where OmniRoute reads/writes the Hermes CLI config. Matches the env var the Hermes PowerShell installer sets on Windows (`%LOCALAPPDATA%\hermes`). |
|
||||
@@ -1299,3 +1307,16 @@ Used by `src/lib/vncSession/manifest.ts` to configure Docker-based headless Chro
|
||||
| `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). |
|
||||
| `VIBEPROXY_DATA_DIR` | _(unset)_ | `open-sse/services/notionThreadSessions.ts` | Directory for Notion thread session persistence. |
|
||||
|
||||
### ChatGPT Web (Codex)
|
||||
|
||||
Globale Defaults für den headless Browser und den ausgehenden Tool-Tunnel. Im Dashboard gesetzte Connection-Werte haben Vorrang.
|
||||
|
||||
| Variable | Default | Source File | Description |
|
||||
| ------------------------------------ | -------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| `CHATGPT_WEB_CODEX_CHROME_PATH` | _(auto-detect)_ | `open-sse/executors/chatgpt-web-codex.ts` | Expliziter Chrome-/Chromium-Pfad für npm-, systemd- und PM2-Betrieb. |
|
||||
| `CHROME_PATH` | _(auto-detect)_ | `open-sse/executors/chatgpt-web-codex.ts` | Gemeinsamer Fallback für einen expliziten Chrome-/Chromium-Pfad. |
|
||||
| `CHATGPT_WEB_CODEX_CDP_URL` | _(unset)_ | `open-sse/executors/chatgpt-web-codex.ts` | Interner CDP-Endpunkt; Docker verwendet den Sidecar auf Port `9223`. |
|
||||
| `CHATGPT_WEB_CODEX_TUNNEL_ID` | _(unset)_ | `open-sse/executors/chatgpt-web-codex.ts` | Globale OpenAI-Tunnel-ID für lokale Codex-Tool-Runden. |
|
||||
| `CHATGPT_WEB_CODEX_RUNTIME_KEY` | _(unset)_ | `open-sse/executors/chatgpt-web-codex.ts` | Globaler Tunnel Runtime-Key; niemals in Logs ausgeben. |
|
||||
| `CHATGPT_WEB_CODEX_CONNECTOR_NAME` | _(unset)_ | `open-sse/executors/chatgpt-web-codex.ts` | Name des ChatGPT-Custom-Connectors für die MCP-Brücke. |
|
||||
|
||||
201
docs/rust-port-research.md
Normal file
201
docs/rust-port-research.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Rust Port Research — OmniRoute Compute Engine Extraction
|
||||
|
||||
> **Date**: 2026-07-31
|
||||
> **Status**: Feasibility study (research only, no code written yet)
|
||||
> **Author**: Egor (fork `Egorich-print/OmniRoute`, branch `feat/personal-build`)
|
||||
> **Reviewer feedback**: ChatGPT architecture review incorporated below
|
||||
|
||||
## TL;DR
|
||||
|
||||
OmniRoute's latency-critical path is the **CPU-bound deterministic compression + tokenization layer** — not the backend plumbing. Port these pure algorithms to a **standalone Rust library** (`compression-core`) with a thin N-API binding as the primary integration path. Ship in this order: **tiktoken → ionizer → headroom → caveman → RTK** (RTK last — thousands of rules, highest risk). Golden-test JS↔Rust byte-in-byte before replacing anything.
|
||||
|
||||
Target: an independent OSS crate (`ai-compression-engine` / `context-engine`) usable by OmniRoute, OpenCode, Cline, Roo, and any AI proxy — not `omniroute-rust`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Measured Baseline
|
||||
|
||||
Benchmark on the dev Mac (2026-07-31):
|
||||
|
||||
| Operation | Input | Cost | Notes |
|
||||
|---|---|---|---|
|
||||
| `countTextTokens()` — js-tiktoken `cl100k_base` | 230K chars (~57K tokens) | **37.9 ms/count** | Runs per chat request |
|
||||
| Rust `tiktoken-rs` (est.) | same | ~1-3 ms | 10-30x faster |
|
||||
|
||||
The token counter runs on **every** chat request. Compression runs per-request when the conversation exceeds budget.
|
||||
|
||||
## 2. Hot Path Map (chat streaming request)
|
||||
|
||||
All operations below are synchronous and block the Node event loop.
|
||||
|
||||
| # | Operation | File:Line | CPU | Freq | Rust portability |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | SSE chunk JSON parse/stringify | `open-sse/utils/stream.ts:2391` | Expensive | per-chunk | High (serde) |
|
||||
| 2 | Tiktoken token counting | `src/shared/utils/tiktokenCounter.ts:54` | Moderate | per-req | **High** |
|
||||
| 3 | RTK compression (regex/line filtering) | `open-sse/services/compression/engines/rtk/index.ts:525` | Expensive | per-req | **High** |
|
||||
| 4 | Headroom tabular compaction | `open-sse/services/compression/engines/headroom/index.ts:114` | Moderate | per-req | High |
|
||||
| 5 | Request format translation | `open-sse/translator/registry.ts:23` | Moderate | per-req | Moderate |
|
||||
| 6 | SQLite usage persistence | `src/lib/usage/usageHistory.ts:675` | Moderate | per-req | Low (DB coupling) |
|
||||
| 7 | PII sanitization (SSE transform) | `open-sse/handlers/chatCore/streamingPipeline.ts:91` | Moderate | per-chunk | High (regex) |
|
||||
| 8 | Memory/skills injection (context merge) | `open-sse/handlers/chatCore.ts:1065` | Cheap | per-req | Moderate |
|
||||
| 9 | Idempotency/request hashing | `open-sse/handlers/chatCore.ts:608` | Cheap | per-req | High (crypto) |
|
||||
| 10 | Usage estimation (fallback counting) | `open-sse/utils/usageTracking.ts:560` | Cheap | per-chunk | High |
|
||||
|
||||
**Where time goes (estimate):** network wait ≫ CPU (compression + tiktoken) > DB > per-chunk overhead.
|
||||
|
||||
## 3. Compression Engine Profiles
|
||||
|
||||
### Tiktoken counter — `src/shared/utils/tiktokenCounter.ts` (62 LOC + lib)
|
||||
- Library: **js-tiktoken** v1.0.21 — pure JS port of tiktoken, no WASM.
|
||||
- Mechanism: pre-computed BPE rank tables shipped as base64 binary blobs (~6 MB across 6 rank files); byte-pair merge on `TextEncoder` UTF-8 byte arrays.
|
||||
- Encodings used: `cl100k_base` (default), `o200k_base` (Codex).
|
||||
- Node deps: `TextEncoder`/`TextDecoder` (built-ins), `base64-js`.
|
||||
- **Verdict**: pure deterministic BPE → ideal Rust port (`tiktoken-rs` supports cl100k + o200k natively).
|
||||
|
||||
### RTK — `open-sse/services/compression/engines/rtk/` (20 files, ~4000 LOC)
|
||||
- `index.ts` 706, `commandDetector.ts` 482, `filterLoader.ts` 332, `tomlCompatibility.ts` 334, `learn.ts` 290, `lineFilter.ts` + more.
|
||||
- Deterministic rule engine: regex-based line classification, keep-patterns for code blocks/JSON, folding/merging rules, tool-call-aware filtering (bash vs non-shell tools).
|
||||
- Called per-request on the whole messages array; **sync** (no awaits in the core).
|
||||
- **Verdict**: port last. High effort, high risk — but regex crate gives linear-time matching (no backtracking blowups) and output equivalence is testable via golden tests.
|
||||
|
||||
### Headroom — `engines/headroom/` (~550 LOC)
|
||||
- "Tabular compaction": replaces array-of-objects message content with compact columnar blocks (```gcf-generic ... ```). Lossless, conservative (only when strictly smaller), never touches system messages.
|
||||
- **Verdict**: pure deterministic, port after ionizer.
|
||||
|
||||
### Ionizer — `engines/ionizer/` (124 + 205 LOC)
|
||||
- Lossy statistical sampling of oversized homogeneous JSON arrays: keeps schema + error rows + first/last rows + seeded uniform middle sample.
|
||||
- Deterministic: FNV-1a hash + mulberry32 PRNG (no Math.random).
|
||||
- **Verdict**: trivial port, nearly zero risk — do second.
|
||||
|
||||
### Caveman — `engines/cavemanAdapter.ts` + `caveman.ts` (~250 LOC)
|
||||
- Regex rule-based compaction for `standard` mode.
|
||||
- **Verdict**: port after headroom.
|
||||
|
||||
### Other engines (not first-wave)
|
||||
- `relevance/` — keyword scoring (no embeddings/network).
|
||||
- `session-dedup/` — dedupe via hash, per-request.
|
||||
- `llm/`, `llmlingua/` — **LLM-dependent (network)**, opt-in, NOT portable to pure CPU core.
|
||||
- `ccr/` (Content-Addressable Recovery) — stores full original for reconstruction.
|
||||
|
||||
## 4. Orchestrator
|
||||
|
||||
- Entry points: `applyCompression` (sync) / `applyCompressionAsync` (async) — `open-sse/services/compression/strategySelector.ts:255` / `:459`.
|
||||
- Exported via `open-sse/services/compression/index.ts:86-91`.
|
||||
- Mode dispatch: `off | rtk | codex-responses | omniglyph | lite | stacked | standard | aggressive | ultra`.
|
||||
- `stacked` mode runs engines sequentially by `stackPriority` (rtk=10, ionizer=13, headroom=15, ...).
|
||||
- Called from `chatCore.ts` via dynamic `import()`; sync CPU-bound → blocks event loop.
|
||||
- **Verdict**: single pure function `(messages, budget, config) → (messages, metrics)` — clean extraction surface for a Rust core.
|
||||
|
||||
## 5. Architecture Decision (revised per ChatGPT review)
|
||||
|
||||
### Recommendation: Rust library + thin N-API binding (NOT sidecar-first)
|
||||
|
||||
```
|
||||
crates/
|
||||
compression-core/ ← pure algorithms, no I/O, no OmniRoute knowledge
|
||||
src/
|
||||
tiktoken/ (cl100k_base, o200k_base)
|
||||
ionizer/
|
||||
headroom/
|
||||
caveman/
|
||||
rtk/ (last)
|
||||
napi/ ← N-API binding (primary integration path, in-process)
|
||||
sidecar/ ← optional HTTP/Unix-socket server over the same core
|
||||
cli/ ← CLI harness (bench, golden tests)
|
||||
```
|
||||
|
||||
**Why N-API first (vs Unix-socket sidecar):**
|
||||
- Every sidecar call pays serialize→socket→deserialize→compute→serialize→deserialize.
|
||||
- For a 2 ms token count, IPC overhead becomes a large fraction of the call.
|
||||
- N-API is in-process: zero serialization on the hot path, no process management.
|
||||
- Keep sidecar only if process isolation / multi-language integration is actually needed.
|
||||
|
||||
**Wire format if sidecar is later added:** `bincode` / `postcard` / MessagePack — NOT JSON. Messages are large; JSON round-trip is wasted work.
|
||||
|
||||
### Independence from OmniRoute
|
||||
|
||||
Make it a **standalone OSS project**: `ai-compression-engine` or `context-engine`.
|
||||
|
||||
```rust
|
||||
// core API surface
|
||||
pub fn compress(messages: &[Message], config: &CompressionConfig) -> CompressionResult;
|
||||
pub fn count_tokens(encoding: Encoding, text: &str) -> u64;
|
||||
```
|
||||
|
||||
No OmniRoute imports anywhere in `compression-core`. Consumers: OmniRoute, OpenCode, Cline, Roo, any AI proxy.
|
||||
|
||||
## 6. Phased Roadmap (revised)
|
||||
|
||||
| Phase | Work | Effort | Risk |
|
||||
|---|---|---|---|
|
||||
| 0 | Baseline benchmark (latency, event-loop blocking, compression %) | 1 day | — |
|
||||
| 1 | Extract `compression-core` crate + port **tiktoken**, golden tests | 2-3 days | Minimal |
|
||||
| 2 | Port **ionizer** | 1-2 days | Nearly zero |
|
||||
| 3 | Port **headroom** | 2-3 days | Low |
|
||||
| 4 | Port **caveman** | 2 days | Low |
|
||||
| 5 | Port **RTK** (biggest, do when harness proven) | 5-7 days | High |
|
||||
| 6 | N-API binding as primary path; feature-flag integration with fallback to JS | 2-3 days | Low |
|
||||
| 7 | Optional sidecar (isolation/multi-lang) | 2 days | Low |
|
||||
| 8 | **Second wave**: SSE parser + OpenAI/Claude/Gemini translators (per-chunk hot path) | TBD | High |
|
||||
|
||||
**Total to full compression replacement: ~2-3 weeks.** First measurable win (tiktoken): 2-3 days.
|
||||
|
||||
## 6a. Definition of Done (added per review)
|
||||
|
||||
| Phase | DoD |
|
||||
|---|---|
|
||||
| Tokenizer (tiktoken) | Full match with JS on golden tests (0% divergence, byte-in-byte); benchmark < 5 ms per 57K tokens |
|
||||
| Ionizer | Output matches JS; no perf regression (or ≥ 10x speedup) |
|
||||
| Headroom | Identical compression result (same % savings, same columnar blocks) |
|
||||
| RTK | Byte-in-byte equivalence on full dialogue set (500 fixtures); unit tests on rule patterns |
|
||||
| Integration (N-API) | JS↔Rust switch via one setting/env; JS fallback when unavailable; zero risk to current deployment |
|
||||
| Translation primitives (wave 2) | Byte-in-byte SSE chunk equivalence before/after; no TTFB increase |
|
||||
|
||||
Progress is measurable per phase and each phase is independently verifiable.
|
||||
|
||||
## 7. Golden Testing (mandatory)
|
||||
|
||||
```
|
||||
fixtures/
|
||||
conversation1.json
|
||||
...
|
||||
conversation500.json
|
||||
|
||||
JS run → output_a.json
|
||||
Rust run → output_b.json
|
||||
assert_eq(output_a, output_b) // byte-in-byte, 100% required
|
||||
```
|
||||
|
||||
- Until 100% match, **do not** switch the runtime to Rust.
|
||||
- This makes even the RTK rewrite safe.
|
||||
- Also validates tiktoken rank tables (JS vs `tiktoken-rs`) on 100+ varied texts.
|
||||
|
||||
## 8. Fallback Strategy (for OmniRoute integration)
|
||||
|
||||
- New env: `OMNIROUTE_COMPRESSION_SIDECAR` (optional, off by default) or N-API availability check.
|
||||
- Node code stays untouched; a client wrapper (`src/lib/compression-rust/`) tries Rust → falls back to existing JS path (`applyRtkCompression`, `applyCompression`).
|
||||
- Zero risk to the current deployment.
|
||||
|
||||
## 9. Deferred / Stay-in-JS
|
||||
|
||||
- Dashboard (Next.js App Router, ~200 pages) — never ported.
|
||||
- Skills, memory, MCP, guardrail management, quotas, combo config — not latency-critical.
|
||||
- `llm`/`llmlingua` compression engines — network/LLM-dependent, stay in JS.
|
||||
- SQLite usage persistence — DB-coupled, stays.
|
||||
|
||||
## 10. Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| JS↔Rust behavioral divergence | Golden tests (500 fixtures, byte-in-byte) |
|
||||
| RTK `tomlCompatibility.ts` / `learn.ts` / `filterLoader.ts` | Port whole submodules (deterministic); rules format 1:1 |
|
||||
| Rust toolchain on LXC 106 (aarch64?) | Check `rustup`/`cargo`; multi-stage Docker build or cross-compile on Mac |
|
||||
| N-API ABI mismatch (Node 26) | Use `napi-rs` (prebuilt binaries, Node-version tolerant) |
|
||||
| Sidecar JSON overhead | bincode/postcard if sidecar is adopted |
|
||||
|
||||
## 11. Open Questions
|
||||
|
||||
1. Cargo/rustup present on LXC 106, or cross-compile from Mac?
|
||||
2. Confirm N-API as primary integration path (vs sidecar)?
|
||||
3. Want the baseline benchmark included in the roadmap before porting?
|
||||
4. Repo home for `compression-core`: new repo (`ai-compression-engine`) or `crates/` inside OmniRoute fork first?
|
||||
252
docs/superpowers/plans/2026-07-27-devin-claude-bridge.md
Normal file
252
docs/superpowers/plans/2026-07-27-devin-claude-bridge.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# Devin Claude Bridge Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a fail-closed `devin-cli-agentic` provider that serves local Anthropic Messages requests through Devin CLI ACP stdio while preserving Claude Code tool-use semantics.
|
||||
|
||||
**Architecture:** Add a separate Claude-format provider and executor instead of changing the existing OpenAI-format `devin-cli` summarizer. Keep parsing, prompt serialization, Anthropic response rendering, and ACP process handling in focused files under `open-sse/executors/devin-agentic/`, then wire them into the existing provider and executor registries.
|
||||
|
||||
**Tech Stack:** TypeScript ES modules, Node child process stdio, Anthropic Messages JSON/SSE, JSON-RPC 2.0 ACP, Node test runner.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Agentic Bridge Core
|
||||
|
||||
**Files:**
|
||||
- Create: `open-sse/executors/devin-agentic/types.ts`
|
||||
- Create: `open-sse/executors/devin-agentic/serializer.ts`
|
||||
- Create: `open-sse/executors/devin-agentic/toolParser.ts`
|
||||
- Create: `open-sse/executors/devin-agentic/anthropicResponse.ts`
|
||||
- Test: `tests/unit/executor-devin-cli-agentic-core.test.ts`
|
||||
|
||||
- [ ] **Implement and prove serialization, parsing, validation, and Anthropic rendering**
|
||||
|
||||
Interfaces:
|
||||
|
||||
```ts
|
||||
export function serializeAnthropicForDevin(body: unknown): DevinPrompt;
|
||||
export function parseDevinToolRequest(text: string, tools: AnthropicTool[]): ParsedToolRequest | null;
|
||||
export function buildClaudeTextResponse(args: ClaudeResponseArgs): Record<string, unknown>;
|
||||
export function buildClaudeToolUseResponse(args: ClaudeToolUseArgs): Record<string, unknown>;
|
||||
export function buildClaudeSseFrames(message: Record<string, unknown>): string;
|
||||
```
|
||||
|
||||
Invariants:
|
||||
|
||||
- Preserve `text`, `tool_use`, `tool_result`, `thinking`, and `redacted_thinking`.
|
||||
- Reject `image` with a clear error.
|
||||
- Reject unknown content block types.
|
||||
- Allow only one tool request per model turn.
|
||||
- Validate tool arguments against object JSON Schema with `required`, `type`, `properties`, `additionalProperties`, `enum`, `items`, and scalar types.
|
||||
- Generate deterministic ids from tool name and canonicalized arguments.
|
||||
|
||||
Run: `node --import tsx/esm --test tests/unit/executor-devin-cli-agentic-core.test.ts`
|
||||
Expected: core tests pass after dependencies are installed.
|
||||
|
||||
### Task 2: ACP Executor And Provider Wiring
|
||||
|
||||
**Files:**
|
||||
- Create: `open-sse/executors/devin-cli-agentic.ts`
|
||||
- Modify: `open-sse/executors/index.ts`
|
||||
- Create: `open-sse/config/providers/registry/devin-cli-agentic/index.ts`
|
||||
- Modify: `open-sse/config/providers/index.ts`
|
||||
- Test: `tests/unit/executor-devin-cli-agentic-acp.test.ts`
|
||||
|
||||
- [ ] **Implement and prove fail-closed ACP execution**
|
||||
|
||||
Behavior:
|
||||
|
||||
- `buildUrl()` returns `devin://acp/stdio`.
|
||||
- `buildHeaders()` returns `{}`.
|
||||
- `execute()` spawns only `devin acp` by default or the explicit `CLI_DEVIN_AGENTIC_BIN`/`CLI_DEVIN_BIN` override.
|
||||
- The child environment removes Anthropic and Claude routing credentials before spawn.
|
||||
- The executor sends `initialize`, `session/new`, and `session/prompt`.
|
||||
- The executor collects `agent_message_chunk` text and `session/prompt` final result.
|
||||
- Non-streaming Claude clients receive native Anthropic JSON.
|
||||
- Streaming Claude clients receive native Anthropic SSE lifecycle frames.
|
||||
- Spawn failure, ACP error, timeout, and early exit produce non-2xx responses with sanitized messages.
|
||||
|
||||
Run: `node --import tsx/esm --test tests/unit/executor-devin-cli-agentic-acp.test.ts`
|
||||
Expected: ACP mock tests pass after dependencies are installed.
|
||||
|
||||
### Task 3: Isolation Scripts And Documentation
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/devin-bridge/verify-anthropic-isolation`
|
||||
- Create: `scripts/devin-bridge/test-unit`
|
||||
- Create: `scripts/devin-bridge/launch`
|
||||
- Create: `docs/DEVIN_CLAUDE_BRIDGE.md`
|
||||
- Modify: `.gitignore`
|
||||
|
||||
- [ ] **Implement offline guardrails and operator docs**
|
||||
|
||||
Behavior:
|
||||
|
||||
- `verify-anthropic-isolation` fails if `CLAUDE_CONFIG_DIR` is missing, points outside an isolated path, or if Anthropic routing env vars are present.
|
||||
- `test-unit` runs the focused unit tests.
|
||||
- `launch` refuses to start unless `ENABLE_LIVE_DEVIN_TESTS=1` for live Devin or `DEVIN_BRIDGE_OFFLINE=1` for offline mock mode.
|
||||
- Documentation distinguishes tested offline behavior from live Devin opt-in behavior.
|
||||
|
||||
Run: `./scripts/devin-bridge/verify-anthropic-isolation` with explicit isolated env.
|
||||
Expected: exits 0 with isolated env and non-zero without it.
|
||||
|
||||
### Task 4: Verification
|
||||
|
||||
**Files:**
|
||||
- No additional source files.
|
||||
|
||||
- [ ] **Run proportional checks and capture real output**
|
||||
|
||||
Commands:
|
||||
|
||||
```bash
|
||||
./scripts/devin-bridge/test-unit
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected in this workspace before installing dependencies: both commands fail with `ERR_MODULE_NOT_FOUND` for `tsx`. Expected after `npm install`: focused tests pass; `npm test` outcome must be reported from real output.
|
||||
|
||||
### Task 5: Close Core Security And Protocol Gaps
|
||||
|
||||
**Files:**
|
||||
- Modify: `open-sse/executors/devin-cli-agentic.ts`
|
||||
- Modify: `open-sse/executors/devin-agentic/*.ts`
|
||||
- Modify: `tests/unit/executor-devin-cli-agentic-*.test.ts`
|
||||
|
||||
- [ ] **Prove environment allowlisting, response-id correlation, strict standalone tool envelopes, unique ids, bounded repair, size limits, cancellation cleanup, sanitized errors, and explicit `devin://acp/stdio` validation**
|
||||
|
||||
Run with `HOME`, `DATA_DIR`, and `SQLITE_FILE` under `.sandbox`; expected: all focused tests pass and an outside-path test fails closed.
|
||||
|
||||
### Task 6: Build Reproducible Containers And Network Guard
|
||||
|
||||
**Files:**
|
||||
- Create: `docker/devin-bridge/Dockerfile`
|
||||
- Create: `docker/devin-bridge/compose.yml`
|
||||
- Create: `docker/devin-bridge/network-guard/*`
|
||||
- Create: `docker/devin-bridge/mock-devin/*`
|
||||
- Create: `.env.devin-bridge.example`
|
||||
|
||||
- [ ] **Pin Claude Code 2.1.220 and Devin CLI 3000.2.17, create non-root offline/live profiles, separate auth/config volumes, explicit env allowlist, no host credential mounts, and denied-domain telemetry**
|
||||
|
||||
Run: `docker compose -f docker/devin-bridge/compose.yml --profile offline config`; expected: no forbidden mounts/env inheritance and only internal runtime networks.
|
||||
|
||||
### Task 7: Deliver Isolation And Operator Scripts
|
||||
|
||||
**Files:**
|
||||
- Create/modify: `scripts/devin-bridge/{build,test-unit,test-contract,test-e2e-mock,verify-anthropic-isolation,login-devin,test-live-devin,launch,clean}`
|
||||
|
||||
- [ ] **Make every command idempotent, sandbox-scoped, fail-closed, and secret-safe**
|
||||
|
||||
Run: `./scripts/devin-bridge/verify-anthropic-isolation`; expected: positive offline proof passes and each deliberately removed guard returns non-zero.
|
||||
|
||||
### Task 8: Real Claude Code Offline E2E
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/fixtures/devin-bridge/e2e-workspace/*`
|
||||
- Create: `tests/e2e/devin-claude-bridge.e2e.*`
|
||||
|
||||
- [ ] **Run pinned Claude Code in the offline container through local `/v1/messages` and mock ACP, proving CLAUDE.md, skill, command, hook, Read/Edit/Bash, tests, multi-turn continuation, and no Anthropic traffic**
|
||||
|
||||
Run: `./scripts/devin-bridge/test-e2e-mock`; expected: workspace diff and tests prove Claude Code executed tools while mock Devin only requested them.
|
||||
|
||||
### Task 9: Regression, Documentation, Live Gate, And Delivery
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/DEVIN_CLAUDE_BRIDGE.md`
|
||||
- Create: `docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md`
|
||||
|
||||
- [ ] **Run focused suites, typecheck, lint, build, docs checks, offline E2E, and isolation proof with fresh output; then run live only after official in-container Devin login**
|
||||
|
||||
If login is unavailable, record live as not tested and expose exactly `./scripts/devin-bridge/login-devin` followed by `./scripts/devin-bridge/test-live-devin`. Commit each reversible unit; do not merge or publish until all offline critical checks are green.
|
||||
|
||||
### Task 10: Close The Authenticated Live Runtime
|
||||
|
||||
**Files:**
|
||||
- Modify: `open-sse/executors/devin-cli-agentic.ts`
|
||||
- Modify: `docker/devin-bridge/compose.yml`
|
||||
- Create: `docker/devin-bridge/network-guard/policy.mjs`
|
||||
- Modify: `docker/devin-bridge/network-guard/proxy.mjs`
|
||||
- Modify: `scripts/devin-bridge/select-live-model.mjs`
|
||||
- Modify: `scripts/devin-bridge/common`
|
||||
- Modify: `scripts/devin-bridge/login-devin`
|
||||
- Modify: `scripts/devin-bridge/test-live-devin`
|
||||
- Modify: `scripts/devin-bridge/verify-anthropic-isolation`
|
||||
- Modify: `tests/unit/executor-devin-cli-agentic-acp.test.ts`
|
||||
- Create: `tests/unit/devin-bridge-live-runtime.test.ts`
|
||||
|
||||
- [ ] **Implement and prove the authenticated network, auth, and catalog boundaries with block-level TDD**
|
||||
|
||||
Invariants:
|
||||
|
||||
- The ACP child receives proxy variables only when `DEVIN_BRIDGE_PROXY_URL` is exactly
|
||||
`http://network-guard:8080`; arbitrary inherited proxy and credential variables stay absent.
|
||||
- The guard permits suffixes `.devin.ai` and `.cognition.ai`, exact hosts
|
||||
`server.codeium.com` and `unleash.codeium.com`, and nothing else.
|
||||
- Claude services cannot mount `devin-auth`; non-Claude services cannot mount the Claude config.
|
||||
- A zero exit from `devin auth status` is insufficient when output contains a server-fetch failure.
|
||||
- `family_uid: swe-1.7-lightning` resolves to catalog id `swe-1-7-lightning`; unknown normalized
|
||||
values fail instead of becoming model ids.
|
||||
- Login uses the official manual-token flow so no container loopback callback is required.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./scripts/devin-bridge/test-unit
|
||||
node --import tsx/esm --test tests/unit/devin-bridge-live-runtime.test.ts
|
||||
./scripts/devin-bridge/verify-anthropic-isolation --static
|
||||
```
|
||||
|
||||
Expected: focused tests and static isolation pass; deliberate untrusted proxy, host, mount, auth
|
||||
status, and model fixtures fail closed.
|
||||
|
||||
- [ ] **Commit the reversible live-runtime repair**
|
||||
|
||||
```bash
|
||||
git add open-sse/executors/devin-cli-agentic.ts docker/devin-bridge \
|
||||
scripts/devin-bridge tests/unit/devin-bridge-live-runtime.test.ts \
|
||||
tests/unit/executor-devin-cli-agentic-acp.test.ts
|
||||
git commit -m "fix: close Devin bridge live runtime gaps"
|
||||
```
|
||||
|
||||
### Task 11: Prove Offline And Live Completion
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker/devin-bridge/run-claude-live-e2e.sh`
|
||||
- Modify: `docs/DEVIN_CLAUDE_BRIDGE.md`
|
||||
- Modify: `docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md`
|
||||
|
||||
- [ ] **Run the complete deterministic bridge proof before any paid request**
|
||||
|
||||
```bash
|
||||
./scripts/devin-bridge/test-unit
|
||||
./scripts/devin-bridge/test-contract
|
||||
./scripts/devin-bridge/test-e2e-mock
|
||||
./scripts/devin-bridge/verify-anthropic-isolation
|
||||
npm run typecheck:core
|
||||
npm run lint
|
||||
npm run build
|
||||
npm run check:docs-all
|
||||
```
|
||||
|
||||
Expected: all bridge-specific checks, typecheck, lint, build, and documentation checks pass with
|
||||
isolated data paths. Any unrelated full-suite infrastructure hang is recorded separately and is
|
||||
not converted into a pass.
|
||||
|
||||
- [ ] **Run exactly the three authorized live scenarios and the no-fallback failure probe**
|
||||
|
||||
```bash
|
||||
ENABLE_LIVE_DEVIN_TESTS=1 ./scripts/devin-bridge/test-live-devin
|
||||
```
|
||||
|
||||
Expected: dynamic discovery selects a returned Devin catalog model; Claude Code reads without
|
||||
editing, then edits and runs the fixture test, then executes the fixture command. Evidence shows
|
||||
native tool use by Claude Code, only `devin-cli-agentic` routing, no allowed non-Devin egress,
|
||||
and an Anthropic-shaped error after the Devin backend is deliberately made unavailable.
|
||||
|
||||
- [ ] **Update verified documentation and commit the evidence-backed delivery state**
|
||||
|
||||
```bash
|
||||
git add docker/devin-bridge/run-claude-live-e2e.sh docs/DEVIN_CLAUDE_BRIDGE.md \
|
||||
docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md
|
||||
git commit -m "docs: record verified Devin bridge live delivery"
|
||||
```
|
||||
134
docs/superpowers/specs/2026-07-27-devin-claude-bridge-design.md
Normal file
134
docs/superpowers/specs/2026-07-27-devin-claude-bridge-design.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Devin Claude Bridge Design
|
||||
|
||||
## Baseline
|
||||
|
||||
- Branch: `release/v3.8.49`
|
||||
- HEAD: `ed7db3ee5f89a144b2d931d8605534522f83de30`
|
||||
- Package version: `3.8.49`
|
||||
- Node: `v26.0.0`
|
||||
- npm: `11.12.1`
|
||||
- Pre-existing worktree state: `.tug/` untracked
|
||||
- Dependency state: `node_modules` is absent; the first focused test run failed before loading tests because `tsx` was not installed.
|
||||
- Tugline state: `tug` exists, but `tug search` failed with MCP connection closed and `tug doctor` hung; it was interrupted.
|
||||
- Upstream check: `git ls-remote` failed because GitHub DNS was unavailable. Web search of the public repository showed the existing `devin-cli` summarizer provider, but no evidence of `devin-cli-agentic`.
|
||||
|
||||
## Source Anchors
|
||||
|
||||
- `/v1/messages`: `src/app/api/v1/messages/route.ts`
|
||||
- Existing Devin provider: `open-sse/config/providers/registry/devin-cli/index.ts`
|
||||
- Existing Devin executor: `open-sse/executors/devin-cli.ts`
|
||||
- Executor registry: `open-sse/executors/index.ts`
|
||||
- Provider registry: `open-sse/config/providers/index.ts`
|
||||
- Format detection: `open-sse/services/provider.ts`
|
||||
- Claude non-streaming response conversion: `open-sse/handlers/responseTranslator.ts`
|
||||
- Existing Devin ACP unit test: `tests/unit/executor-devin-cli-acp-protocol-8406.test.ts`
|
||||
|
||||
## Findings
|
||||
|
||||
The existing `devin-cli` provider is intentionally OpenAI-format and summarizer-oriented. Its executor spawns `devin acp --agent-type summarizer`, flattens the message history into a single text prompt, and emits OpenAI SSE text chunks. It does not preserve Anthropic `tool_use` and `tool_result` blocks.
|
||||
|
||||
The safest implementation is a new provider id, `devin-cli-agentic`, with a separate executor. This leaves `devin-cli`, Anthropic OAuth, Claude OAuth, Claude Web, and all host Claude configuration code untouched. The new provider is fail-closed: it only resolves to `devin://acp/stdio`, uses the official Devin CLI ACP stdio path, and has no fallback provider.
|
||||
|
||||
## Architecture
|
||||
|
||||
Claude Code sends Anthropic Messages requests to local OmniRoute. OmniRoute resolves model ids prefixed with `devin-cli-agentic/` to a new Claude-format provider. The new executor translates the complete Anthropic request into an explicit text prompt for Devin ACP, including system text, structured message history, tool schemas, and prior tool results.
|
||||
|
||||
Devin remains a model backend. The executor starts the official fixed no-tools summarizer
|
||||
role with `devin acp --agent-type summarizer` and frames the serialized request as an
|
||||
execution trace. Devin must request client-owned tool execution by emitting a strict
|
||||
XML-wrapped JSON block:
|
||||
|
||||
```xml
|
||||
<tool>
|
||||
{"name":"Read","arguments":{"file_path":"src/index.ts"}}
|
||||
</tool>
|
||||
```
|
||||
|
||||
The bridge parses exactly one tool request per model turn, validates that the tool name was supplied in the incoming request, validates arguments against a minimal JSON Schema validator, generates a stable `tool_devin_...` id, and returns a native Anthropic `tool_use` block. If no valid tool request is present, the bridge returns text with `stop_reason: "end_turn"`.
|
||||
|
||||
## Error And Safety Rules
|
||||
|
||||
- Unsupported Anthropic content blocks fail explicitly; images are rejected.
|
||||
- Unknown tools fail explicitly.
|
||||
- Invalid tool arguments fail explicitly.
|
||||
- Invalid tool XML/JSON fails explicitly.
|
||||
- Narrative claims that a tool was executed are returned as text, not actions.
|
||||
- ACP spawn, timeout, early exit, and stderr-only failures return explicit Devin errors.
|
||||
- The executor never reads `~/.claude`, `~/.claude.json`, macOS Keychain paths, or host Claude config.
|
||||
- Live Devin is outside normal tests and remains opt-in via `ENABLE_LIVE_DEVIN_TESTS=1`.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
Focused unit tests cover serialization, tool parsing, validation, Anthropic JSON, Anthropic SSE, malformed tool output, unknown tools, invalid arguments, image rejection, timeout, and spawn failure. Environment scripts provide an offline isolation verifier without reading host Claude credentials.
|
||||
|
||||
## Mandatory Runtime Isolation
|
||||
|
||||
The bridge runs only through `docker/devin-bridge/compose.yml`. The runtime image is non-root, uses a private `/home/bridge`, and mounts only disposable workspaces, evidence, and bridge harness files. Application source is copied into the image. It never mounts the host home, Docker socket, SSH, cloud credentials, or global Claude configuration. The container receives an explicit environment allowlist; the executor also constructs an allowlisted child environment instead of copying `process.env`.
|
||||
|
||||
Build-time network access installs Claude Code `2.1.220` and Devin CLI `3000.2.17` with pinned integrity/checksum. Runtime profiles are separate: `offline` uses only an internal Compose network; `live-devin` exposes egress only through a proxy guard whose allowlist contains Devin/Cognition suffixes and whose default is denial. Devin authentication lives only in the named `devin-auth` volume. Claude configuration lives in a different named volume and is initialized empty.
|
||||
|
||||
## Fail-Closed Routing
|
||||
|
||||
`devin-cli-agentic` accepts only the synthetic `devin://acp/stdio` target and an explicit Devin binary path inside the container. It cannot use provider combos, auto routing, account fallback, fallback URLs, or an HTTP upstream. Model aliases resolve only to models returned by the Devin catalog or explicitly configured Devin model ids. An ACP failure, timeout, cancellation, invalid frame, unavailable model, or stopped sidecar becomes an Anthropic-shaped error response; no secondary provider is attempted.
|
||||
|
||||
## Agentic Contract
|
||||
|
||||
The serializer preserves request order, `system`, `tool_choice`, exact tool schemas, `text`, `tool_use`, `tool_result`, `thinking`, and `redacted_thinking`. It rejects unsupported blocks and caps large tool results with an explicit truncation marker and original size. The parser accepts exactly one standalone `<tool>` envelope, validates with Zod/JSON Schema infrastructure already present in OmniRoute, rejects unknown tools and mixed narrative/action output, and performs at most one bounded repair prompt. Tool ids combine a per-request nonce with canonical arguments so repeated identical calls remain unique while their association is stable within the turn.
|
||||
|
||||
## Required Proof
|
||||
|
||||
The offline profile must prove the ACP lifecycle, fragmented frames, stderr, early exit, hang/cancel, Anthropic JSON/SSE order, no fallback, and a real pinned Claude Code run that reads, edits, runs tests, observes `CLAUDE.md`, loads a skill and command, fires a hook, and completes at least one `tool_use -> tool_result -> continuation` loop. The isolation verifier checks env, mounts, UID, config paths, DNS/connection logs, local inference destination, selected provider, and fail-closed behavior. Live Devin is proved only by official in-container login and three isolated agentic scenarios.
|
||||
|
||||
## Safety Incident During Baseline
|
||||
|
||||
The first focused test was run without `DATA_DIR` isolation and initialized `/Users/lucasisrael/.omniroute/storage.sqlite`; logs reported schema-column additions. No Anthropic data was accessed. The external database will not be touched again or destructively rolled back. Every bridge command and test now must set `HOME`, `DATA_DIR`, `SQLITE_FILE`, and temporary directories inside `.sandbox`, and an automated guard must reject paths outside the task workspace.
|
||||
|
||||
## Live Completion Repair
|
||||
|
||||
The first authenticated live attempt disproved four assumptions in the initial container
|
||||
design. The official CLI reports a valid login even when its server-status request fails;
|
||||
that request uses the exact hosts `server.codeium.com` and `unleash.codeium.com`, which the
|
||||
guard denied. The OmniRoute executor also built a fresh allowlisted child environment that
|
||||
omitted the proxy, so `devin acp` could not leave the internal network. Model discovery emits
|
||||
family identifiers such as `swe-1.7`, while the OmniRoute catalog uses canonical ids such as
|
||||
`swe-1-7`. Finally, browser login redirects to a loopback listener inside the one-off
|
||||
container, which is not reachable from the host browser.
|
||||
|
||||
The repair keeps the fully containerized architecture and does not weaken the deny-by-default
|
||||
network. The guard gains an exact-host allowlist for the two Codeium control-plane hosts while
|
||||
retaining suffix-based access only for Devin and Cognition; telemetry destinations such as
|
||||
Sentry remain denied. Compose supplies `DEVIN_BRIDGE_PROXY_URL` with the single accepted value
|
||||
`http://network-guard:8080`, and the executor derives `HTTP_PROXY` and `HTTPS_PROXY` from that
|
||||
explicit bridge setting instead of inheriting arbitrary host proxy variables. Claude services
|
||||
mount only the Claude config volume, and only the OmniRoute live service mounts the Devin auth
|
||||
volume.
|
||||
|
||||
Fresh login uses the official `devin auth login --force-manual-token-flow`, which is intended
|
||||
for remote environments where localhost redirects cannot work. The credential is pasted only
|
||||
into the interactive CLI terminal and never appears in arguments, logs, evidence, or Git.
|
||||
Authentication validation requires both the logged-in marker and the absence of a server-fetch
|
||||
failure. Model discovery accepts the real `family_uid`/`model_uid` fields, maps punctuation to a
|
||||
catalog id only after an exact normalized match, and prefers the already-proved lightning model
|
||||
when available.
|
||||
|
||||
Tests first prove the trusted proxy boundary, exact host policy, volume separation, strict auth
|
||||
status gate, and catalog normalization. The live gate then runs three real Claude Code scenarios
|
||||
through the authenticated in-container Devin CLI and requires local Read/Edit/Bash activity,
|
||||
passing fixture tests, Devin-only routing, no allowed non-Devin egress, and an explicit error
|
||||
when the Devin backend is stopped.
|
||||
|
||||
## Final Live Result
|
||||
|
||||
The default-agent design was rejected after live evidence showed that `ask` mode can still
|
||||
emit Devin-owned ACP tool calls. The pinned CLI does not apply its top-level agent
|
||||
configuration to `devin acp`, so an `allowed-tools: []` configuration could not create a
|
||||
neutral backend. The fixed summarizer role is the only official ACP role in this version that
|
||||
is structurally no-tools.
|
||||
|
||||
The execution-trace adaptation passed the authenticated live gate with
|
||||
`swe-1-7-lightning`. Three Claude Code processes completed analysis, edit/test, and local
|
||||
command/skill scenarios. Structured evidence proved that Claude Code issued `Read`, `Edit`,
|
||||
and `Bash` tool calls; two client-owned `npm test` calls succeeded. The guard audit proved
|
||||
Devin-only outbound access and zero Claude egress. Intermediate summary-shaped responses and
|
||||
transient ACP timeouts remain explicit failure modes; the adapter performs one bounded repair
|
||||
and the harness spaces scenarios to avoid bursty session creation.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user