Compare commits

..

79 Commits

Author SHA1 Message Date
diegosouzapw
e52842b926 Merge branch 'compression-core' of github.com:Egorich-print/OmniRoute into fix/combine-9115 2026-08-04 08:33:32 -03:00
Egor
3d444c2209 fix(memory): enable agent memory save/update via MCP tools + builtin stream guard
- memoryTools: apiKeyId now optional; falls back to caller principal
  (HTTP auth headers / OMNIROUTE_API_KEY env) so agents can store memory
  without knowing their key id
- memorySkillsInjection: server-side memory_* builtins only injected for
  non-stream requests (stream clients execute tools client-side via MCP)
- memoryBuiltins: memory_save/update/search/delete builtin tools with
  per-provider schemas + interception dispatch
- retrieval: fix toFts5MatchQuery import (ReferenceError on FTS5 path)
- tests: MCP auto-owner fallback, cross-principal isolation, stream guard
2026-08-01 10:56:07 +03:00
Egor
944178475c chore: backup point - package-lock sync 2026-08-01 03:00:49 +03:00
Egor
2a3adca1cb feat(compression-core): initial workspace with stable API + tokenizer golden tests
Scaffold the standalone Rust compression core (no OmniRoute deps):
- crates/core-api: stable traits (TokenCounter, Compressor) + types
  (Message, Encoding, CompressionConfig/Result) — the contract for all
  adapters (N-API, sidecar, CLI)
- crates/tokenizer: tiktoken-rs cl100k_base + o200k_base port
- crates/tests: golden tests reading fixtures/ (byte-equality vs JS)
- crates/bench: criterion harness (21.4ms vs JS 37.9ms on large input)
- crates/ffi: empty N-API adapter placeholder (integration phase)
- scripts/generate-fixtures.ts: JS reference output (source of truth)
- scripts/verify-golden.ts: regen + run golden tests
- fixtures/: 13 tokenizer samples incl. 405K-char stress case

Golden status: 100% match JS vs Rust on all fixtures.
2026-07-31 16:15:09 +03:00
Egor
52775bc958 docs: add Definition of Done criteria per phase (review feedback) 2026-07-31 16:02:10 +03:00
Egor
a076376490 docs: add Rust port feasibility study and deployment infrastructure notes
- rust-port-research.md: hot-path map (10 latency-critical ops), measured
  tiktoken baseline (37.9ms/57K tokens), engine profiles (RTK/headroom/
  ionizer/caveman), architecture decision (compression-core crate + N-API,
  revised per review), phased roadmap, golden-test strategy, risks
- infrastructure.md: Proxmox/LXC topology, component table, push flows for
  Forgejo/OpenHands/OmniRoute/project-history, access notes, do-not-touch
- docs/README.md: index links
2026-07-31 15:59:41 +03:00
Egor
1c3f6dcc90 fix(skills): warm registry cache before skill injection in chat path
injectSkills() lists the in-memory skillRegistry, which is empty after a
cold start until something calls loadFromDatabase(). The interception path
already warms the cache (#2815); the injection path did not, so skills
were silently skipped (no_enabled_skills) for the first requests after
restart. Warm the cache for the chat owner before injection.
2026-07-31 14:30:05 +03:00
Egor
9d495f7c44 fix(skills): normalize flat skill schemas to object schema for Gemini/Claude
Stored skill schemas are flat property maps ({ text: { type: string } }),
which OpenAI-compatible providers tolerate but Gemini
(function_declarations[].parameters) rejects with 'Unknown name ... Cannot
find field'. Wrap bare maps into { type: 'object', properties: {...} } for
all three tool formats.
2026-07-31 14:21:31 +03:00
Egor
904d45e011 fix(combos): include DB id column in combo records for dashboard links
getCombos() selected only data/sort_order/context_cache_protection, so
combos whose JSON blob lacked an id field returned id: undefined. The
dashboard then linked to /dashboard/combos/undefined and Combo Control
Center failed with 'Combo not found'. Merge the id column into parsed
rows (authoritative, only when the blob has no id).
2026-07-31 12:50:29 +03:00
Egor
3eca8d9c0c fix(skills): encode tool names with @ and . for providers rejecting them
Skill tools were advertised as 'name@version' (e.g. test-fr2@1.0.0), but
DeepSeek/Groq/OpenAI reject function names not matching ^[a-zA-Z0-9_-]+$.
Names already valid are left untouched; invalid ones are reversibly encoded
as omr_skill_<base64url> and decoded in interception before registry lookup.
2026-07-31 12:30:29 +03:00
Egor
aefab44503 fix(skills+memory): builtin handler fallback in executor, skip vector upsert for deleted memories
- skills: Next.js compiles SkillExecutor into multiple chunks (own singleton
  each); route chunk lacked builtin handlers registered at startup via
  instrumentation. execute() now falls back to builtinSkills registry, so
  POST /api/skills/executions works for file_read/web_fetch/etc.
- memory: scheduleVectorUpsert is fire-and-forget and embeddings are slow;
  health-check verify (create->delete test memory) left queued upserts
  failing with 'memory not found' every 30s. Check existence before embedding
  and skip quietly.
2026-07-31 11:52:31 +03:00
Egor
c605cabf08 feat(skills): add Ponytail minimalism skill as external catalog entry
- Add 'external' SkillCategory + SkillArea
- Register ponytail (MIT, DietrichGebert/ponytail) in CURATED_SKILLS
- Generator: external skills carry content in custom block, no api/cli body
- Generate skills/ponytail/SKILL.md with original content preserved
- Update catalog test counts 45 -> 46
2026-07-31 11:10:20 +03:00
Egor
9f67e5cc74 fix(memory): sanitize FTS5 MATCH in vector hybrid search too
searchHybrid in vectorStore.ts runs its own raw FTS5 MATCH — the earlier
retrieval.ts fix missed it, so hybrid queries with punctuation still threw
'fts5: syntax error'. Extracted the sanitizer to src/lib/memory/ftsQuery.ts
(no import cycle) and applied it at all three MATCH sites; punctuation-only
queries now yield a non-matching '""' phrase instead of an error.
2026-07-31 10:29:15 +03:00
Egor
f579f9b096 fix: quota API field mapping + FTS5 query sanitization
- /api/v1/quotas read conn.id/provider/name — the lazy row proxy does not
  expose connectionId/providerId, so every connection was skipped and the
  endpoint always returned an empty list.
- retrieveMemories/buildFtsRows now sanitize the query into a quoted FTS5
  MATCH expression — natural-language queries with ? ! : ( ) etc. no longer
  throw 'fts5: syntax error' and silently degrade to empty results.
- new unit test tests/unit/memory/fts5-query-sanitize.test.ts (5 cases).
2026-07-31 10:19:06 +03:00
Egor
17c76cf5d6 fix(memory): load embedding vocab from tokenizer.json
potion-base-8M has no vocab.json on HuggingFace (404) — the download
silently failed and vector retrieval always fell back to FTS5-only.

Load the token→id map from tokenizer.json (already cached at
<DATA_DIR>/embeddings/potion-base-8M/tokenizer.json) with fallbacks to
vocab.json and line-indexed vocab.txt.
2026-07-31 10:08:32 +03:00
Egor
0975bc8ca9 feat(openhands): add @omniroute/openhands-plugin — config generator + skill
New package that wires OpenHands agent-server to OmniRoute:
- env.ts: generates OpenHands .env (LLM_MODEL, LLM_BASE_URL, LLM_API_KEY,
  OH_PERSISTENCE_DIR, PERMITTED_CORS_ORIGINS)
- docker.ts: Docker Compose + docker run generators with the field-proven
  fixes baked in (privileged sandbox, host.docker.internal:host-gateway,
  persistence volume, CORS)
- model-map.ts: OpenHands model names → OmniRoute model/combo IDs
- cli.ts: omniroute-openhands <env|compose|docker-run|models>
- tests: 8 cases (env, model map, compose, docker run) — all passing
- skills/omni-openhands/SKILL.md + README catalog entry
2026-07-31 09:40:53 +03:00
Egor
2baf2f4820 feat(nvidia): forward quota headers + add quota check API
- responseHeaders: promote NVIDIA NIM quota/usage headers (x-nvcf-*,
  x-quota-*, x-ratelimit-*) to priority 2 so they survive the 768-byte
  upstream header forwarding budget (were previously dropped as priority 3)
- new GET /api/v1/quotas: lists every provider connection with saturation
  (0..1), remaining percent, and data source using existing saturation
  signals — lets operators check key budgets via API instead of live monitoring
2026-07-31 09:32:10 +03:00
Egor
0be4243d2e fix(combos): normalize hand-written model shapes + survive malformed JSON
Root cause (reproduced by tests): combo steps edited by an agent in text/SQLite
format used field names the normalizer didn't recognize, so every step was
dropped and the combo became empty in the WebUI builder.

- steps.ts: extractModelField() accepts model/target/name/modelName variants,
  used by both getComboStepTarget and normalizeComboStep (fixes {name,provider}
  and legacy {id,target,weight} shapes)
- combos.ts: withSortOrder/parseComboRow now return null on malformed stored
  JSON instead of throwing, so the WebUI combo list never crashes on a broken
  row
- new test: combo-editability-paths.test.ts (7 cases covering baseline, SQLite
  INSERT, API text-edit, legacy shapes, malformed JSON)
2026-07-31 09:22:56 +03:00
Egor
4826385f19 feat(resilience): enable retry for all providers + attempt header + memory by default
- chatCore: maxAttempts 1→2 for all providers (previously only model-scope and codex got retries)
- combo.ts: add x-omniroute-attempt response header showing how many attempts were made
- memory/settings: enable memory injection by default (maxTokens 1000) — auto-extraction already wired in chatCore
2026-07-31 09:13:29 +03:00
Egor
988f76c9bf feat(model-alias): add runtime Model Alias Resolver middleware 2026-07-31 08:41:47 +03:00
Korostelev Egor
439f13b210 i18n(ru): complete Russian locale — fill 25 missing keys and 80 placeholders 2026-07-31 08:41:41 +03:00
Egor
0515e69a3f Merge branch 'pr/8949' into feat/personal-build
# Conflicts:
#	tests/unit/providers-constants-split.test.ts
2026-07-31 08:40:42 +03:00
Egor
96b31e3142 Merge branch 'pr/8930' into feat/personal-build 2026-07-31 08:39:55 +03:00
Egor
00059f7bdd Merge branch 'pr/8914' into feat/personal-build 2026-07-31 08:39:49 +03:00
Egor
71750799eb Merge branch 'pr/9013' into feat/personal-build 2026-07-31 08:39:43 +03:00
Egor
b4f0d97f10 Merge branch 'pr/9006' into feat/personal-build 2026-07-31 08:39:36 +03:00
Egor
bf83381794 Merge branch 'pr/9014' into feat/personal-build 2026-07-31 08:39:30 +03:00
Egor
71a8f6f98e Merge branch 'pr/9015' into feat/personal-build
# Conflicts:
#	open-sse/translator/response/gemini-to-claude.ts
2026-07-31 08:39:24 +03:00
Egor
64dba26398 Merge branch 'pr/9016' into feat/personal-build 2026-07-31 07:24:17 +03:00
Will Gordon
19b58a99ab test: register vertex-passthrough-model-lockout in stryker tap.testFiles 2026-07-30 18:52:36 -04:00
backryun
26b058207b chore(ci): fold Vitest into fast quality gates 2026-07-31 07:49:03 +09:00
Will Gordon
ea801bbca2 fix(sse): extract Vertex error classifier and rebaseline frozen file sizes 2026-07-30 18:41:25 -04:00
Will Gordon
77eb184f9d fix(sse): correlate reason and resource within the same ErrorInfo detail 2026-07-30 17:53:00 -04:00
backryun
869f07b3b0 chore(ci): adopt Ubuntu 26.04 runners 2026-07-31 06:34:42 +09:00
Will Gordon
3780d45d62 docs: document Vertex 403 disambiguation in changelog fragment 2026-07-30 17:34:34 -04:00
Will Gordon
da3c3c9f67 fix(sse): disambiguate Vertex connection-wide vs per-model 403s 2026-07-30 17:34:00 -04:00
Will Gordon
a48f256f51 fix(sse): clarify effort-variant strip comment and add cross-module drift guard 2026-07-30 17:33:48 -04:00
Prudhvivuda
b2ce078c92 fix(sse): preserve Claude Code tool-name casing via Gemini/Antigravity (#9008)
Stop blindly lowercasing PascalCase tool_use names on the Gemini→Claude path so Claude Code no longer rejects Read/WebSearch as missing tools.
2026-07-30 16:32:11 -04:00
Prudhvivuda
8a3888e510 fix(sse): preserve Gemini thought_signature on Claude Desktop tool turns
Claude→Gemini direct translators dropped thoughtSignature, so Gemini 3
tool follow-ups returned 400. Store on the response path, re-attach (or
context-fallback) on the request path, and thread signatureNamespace.

Closes #8979
2026-07-30 16:31:44 -04:00
Prudhvivuda
368d1c0e87 fix(sse): route Poe API-key traffic through DefaultExecutor (#8969)
Stop aliasing canonical `poe` to PoeWebExecutor so API-key requests hit
api.poe.com Chat Completions / Responses / Claude-only Messages instead of
the web GraphQL path that returned HTTP 405.
2026-07-30 16:31:41 -04:00
千乘妍 (Xiaoyaner)
b21f2d91e7 docs(changelog): add fragment for #9013 2026-07-31 04:29:17 +08:00
Will Gordon
c2c622ad82 fix(sse): align regex naming and changelog formatting 2026-07-30 16:07:34 -04:00
Will Gordon
796b4fefd1 docs: add changelog fragment for the Claude catalog/dispatch fix 2026-07-30 15:39:20 -04:00
Will Gordon
cf2055ce3e fix(sse): scope Vertex 404s to a per-model lockout via passthroughModels 2026-07-30 15:10:47 -04:00
Will Gordon
4ef44a53a7 fix(dashboard): re-qualify no-think playground model ids correctly 2026-07-30 15:04:26 -04:00
Will Gordon
2a1c946aa6 fix(sse): keep no-think and CC-discovery catalog variant roots unprefixed 2026-07-30 15:00:26 -04:00
Will Gordon
0d2678360f fix(sse): strip Claude effort-suffix ids for any provider serving a real Claude model 2026-07-30 14:51:53 -04:00
Will Gordon
a8fb526e9c refactor(sse): extract shared Claude effort-model predicate 2026-07-30 14:46:45 -04:00
千乘妍 (Xiaoyaner)
dcddbe11cd fix(dashboard): serialize cross-row param-filter saves (#8910) 2026-07-31 02:26:10 +08:00
Will Gordon
0e66f7e566 Merge remote-tracking branch 'upstream/release/v3.8.50' into fix/vertex-claude-catalog-dispatch 2026-07-30 14:15:46 -04:00
千乘妍 (Xiaoyaner)
4f97f84dea fix(dashboard): drain midflight param-filter drafts (#8910) 2026-07-30 23:58:05 +08:00
Jan Leon
8aa9c6f710 Document and test ChatGPT Web integration 2026-07-30 06:56:19 +02:00
Jan Leon
5ead198eb4 Add ChatGPT Web setup and doctor UI 2026-07-30 06:56:09 +02:00
Jan Leon
a0f9ad852d Add managed browser and tunnel deployment 2026-07-30 06:56:00 +02:00
Jan Leon
8941eafcb9 Add native ChatGPT Web provider pipeline 2026-07-30 06:55:41 +02:00
Jan Leon
b02c586cb4 Bypass proxy compaction for native Codex context 2026-07-30 05:03:35 +02:00
千乘妍 (Xiaoyaner)
e26fc0a774 fix(dashboard): keep param-filter fields and drafts bound to their own target (#8910)
Two remaining defects of the #8910 silent-data-loss family, both reached through
the re-point path of a live ModelCompatPopover.

1. The inputs render blockText/allowText, whose only writer was the load effect —
   and that effect early-returned whenever a draft was dirty for the target. So
   re-pointing A -> B -> A left B's server values on screen under A, and the next
   keystroke snapshotted them into A's draft, persisting B's content into A's
   entry. The fields are now a function of the target: on return to a target with
   a pending draft the draft is restored into the inputs, and on a target with no
   draft the previous target's values are cleared instead of being left behind.
   An edit also no longer trusts the counterpart field unless the values on
   screen belong to the target being edited.

2. The pending draft lived in a single slot that every edit overwrote, so typing
   into a newly pointed target destroyed the previous target's unsaved work while
   the new target's successful save cleared the failure indicator — a green UI
   over data that was never written. Drafts are now keyed by provider/model; the
   save drains every pending draft against its own target, and the indicator
   reflects unsaved work across all targets rather than the last write.

Regression tests: modelCompatPopover-param-filter-target-repoint.test.tsx
(3 cases, RED at ecd111489, GREEN here). Scope limited to this component.
2026-07-30 07:13:52 +08:00
千乘妍 (Xiaoyaner)
ecd1114894 fix(dashboard): bind the param-filter save to the draft's own target (#8910)
saveModelParamFilters guarded on paramDirtyRef alone and read the
providerId/modelId it closed over, never the target the draft was typed
for. ModelCompatPopover is not always keyed by a stable identity
(CompatibleModelsSection keys by `${alias}:${modelId}`,
PassthroughModelsSection by the full model string, and providerId is
threaded from route/page state), so a re-render can re-point a live,
mounted popover at a different provider/model. If the old target's save
had failed or never ran, the still-dirty draft was then PUT into the NEW
target — writing a filter list under a model/provider the user never
edited and destroying that target's real config.

Replace the dirty flag / revision counter / dirty-key trio with a single
ParamFilterDraft ref that carries the provider, model and both field
values captured at edit time. The save drives its GET, PUT and payload
from that draft instead of the current props, re-reads the ref after
each await (restarting the attempt if the draft was replaced by one for
another target), and only clears it when the exact draft object it wrote
is still pending. Object identity replaces the revision counter, keeping
the existing lost-update protection.

A load no longer clears the draft or the failure indicator: a draft
pending here belongs to another target and is still owed a write to it.
An orphaned draft is therefore neither dropped nor redirected — it keeps
its own provider/model, keeps the failure marker visible, and is retried
by the next blur/close/unmount save. The cleanup effect also depends on
the target key so re-pointing the popover flushes the old draft.
2026-07-30 06:23:25 +08:00
千乘妍 (Xiaoyaner)
53066a592a fix(dashboard): protect dirty param-filter drafts from load-effect clobber (#8910)
The retained-draft guard in the param-filters load effect required
paramLoadedKeyRef to match the current target, but that ref was only
assigned after a successful GET. Any draft typed before a successful load
for that target was therefore unguarded, and the clean-slate write
overwrote both the text and the dirty flag:

- a draft typed while the INITIAL load GET was still in flight was
  overwritten and its dirty flag cleared, so the close-path save became a
  no-op and the keystrokes vanished with no feedback;
- after a FAILED initial load, the retained draft was destroyed by the next
  successful reopen load — the exact moment the user reopens to retry —
  and the failure indicator was cleared as if the save had succeeded.

Track the target on the dirty flag itself (paramDirtyKeyRef, set when the
draft is marked dirty) instead of deriving it from a completed load, and
re-check the guard after the GET await so a load result never overwrites
text, clears dirty, or clears the failure indicator for a draft that is
not on the server.
2026-07-30 05:49:32 +08:00
Emmanuel Frimpong Asante
6c309c5e5f fix(i18n): localize SubscriptionTab UI strings instead of hardcoded Chinese
The proxy subscription tab (System -> Proxy -> Subscriptions) displayed
Chinese text regardless of the selected language. The component called
useTranslations("settings") but bypassed t() for all ~50 UI strings.

- Replace every hardcoded Chinese string in SubscriptionTab.tsx with
  t("proxySubscription.<key>") calls
- Add 53 new keys under settings.proxySubscription to en.json (English)
  and zh-CN.json (Chinese) with full manual translations
- Propagate to all 41 other locales via generate-multilang.mjs (Google
  Translate), per docs/guides/I18N.md workflow

All 42 locales at 100% i18n coverage with zero __MISSING__ markers.
2026-07-29 21:21:51 +00:00
千乘妍 (Xiaoyaner)
59d5f43d7b fix(dashboard): avoid lost update and surface failed param-filter saves (#8910)
The close-time save could clear the dirty flag for a payload snapshotted
before the PUT resolved, silently discarding any keystroke that landed in
that window. Track a monotonic draft revision and only acknowledge the
revision that was actually written, re-running the save (bounded) otherwise.

A failed save previously stayed dirty to 'retry on a later close', but
reopening the popover reloaded server state and silently reverted the
draft. Keep a dirty draft for the same provider/model on reopen and show a
failure marker next to the saving indicator instead.
2026-07-30 03:43:52 +08:00
千乘妍 (Xiaoyaner)
152a3e13f7 fix(dashboard): persist model param filters on popover close (#8910)
ModelCompatPopover declared providerId/modelId in its props type but never
destructured them, so both param-filter fetches referenced undefined
identifiers (TS2304, frozen in the dashboard-typecheck baseline) and threw
into a silent catch. CustomModelsSection also never passed the two props.

- Destructure providerId/modelId; pass them from CustomModelsSection.
- Save pending block/allow drafts when the popover closes or unmounts, so an
  outside mousedown no longer discards them.
- Read drafts from refs at save time and guard concurrent saves, avoiding
  stale-closure payloads and duplicate PUTs.
- Keep dirty state and drafts on non-OK/failed GET or PUT instead of silently
  clearing them; skip state updates after unmount.
- Provider-level block/allow, autoLearn, and other model entries are preserved;
  an empty block+allow still removes only the selected model entry.
- Ratchet the three now-clean dashboard-typecheck baseline entries.

Compat-toggle and upstream-header paths are unchanged.
2026-07-30 03:14:41 +08:00
千乘妍 (Xiaoyaner)
ae3d026ad0 test: reproduce model param filter close persistence 2026-07-30 00:48:39 +08:00
Lucas Israel
347bfe257c fix: validate live Claude Devin bridge 2026-07-29 10:02:02 -03:00
Lucas Israel
0dcac8bfc3 docs: record Devin ACP live compatibility blocker 2026-07-29 10:02:02 -03:00
Lucas Israel
71af74bee4 fix: fail closed on incompatible Devin ACP behavior 2026-07-29 10:02:02 -03:00
Lucas Israel
ccbe6bc288 fix: harden Devin bridge runtime boundaries 2026-07-29 10:02:02 -03:00
Lucas Israel
98c98856b8 test: enforce Claude egress audit 2026-07-29 10:02:02 -03:00
Lucas Israel
3899495f60 fix: close Devin bridge live runtime gaps 2026-07-29 10:02:02 -03:00
Lucas Israel
35797deeab docs: plan Devin bridge live completion 2026-07-29 10:02:02 -03:00
Lucas Israel
c3a024d3be docs: document Devin Claude bridge operations 2026-07-29 10:02:02 -03:00
Lucas Israel
b0d4d64e6c test: add isolated Claude Devin bridge harness 2026-07-29 10:02:02 -03:00
Lucas Israel
aa5856376e feat: harden Devin ACP bridge contracts 2026-07-29 10:02:01 -03:00
Lucas Israel
a52d5fb31f fix: fail closed around Devin ACP execution 2026-07-29 10:02:01 -03:00
Lucas Israel
9ca5a1ad42 docs: expand Devin bridge isolation plan 2026-07-29 10:02:01 -03:00
Lucas Israel
44ba571521 feat: add initial Devin agentic provider 2026-07-29 10:02:01 -03:00
Lucas Israel
cc8d790262 docs: design isolated Devin Claude bridge 2026-07-29 10:02:01 -03:00
Will Gordon
ad94ec491e docs: add changelog fragment for #8909 2026-07-29 08:21:57 -04:00
Will Gordon
1e629721b9 fix(executors): route Claude-via-Vertex through native rawPredict with real streaming
Claude models on Vertex AI were being sent through the generic OpenAI-
compatible partner endpoint, which 404s/errors for Claude on at least
some projects. Route them through Vertex's native Anthropic Messages
API (publishers/anthropic/.../rawPredict) instead, stripping the
body-level model field rawPredict rejects and injecting the required
anthropic_version field.

rawPredict only ever returns a complete JSON body, never real SSE
framing, so streaming requests now get a genuine Anthropic-format SSE
stream synthesized from that JSON (message_start/content_block_*/
message_delta/message_stop), which the existing claude-to-openai
response translator already knows how to parse.

Also fixes two response-format resolution bugs that silently dropped
a custom model's DB-stored targetFormat override whenever the model
id also existed in the static provider registry (as claude-sonnet-4-6
and claude-opus-4-7 do under vertex): resolveModelOrError had its own
ad-hoc resolution that never consulted the override, and even once
fixed, executeChatWithBreaker discarded the correctly-resolved format
before handleChatCore's own resolution ran a second time.
2026-07-28 19:18:44 -04:00
449 changed files with 33236 additions and 9523 deletions

View File

@@ -18,6 +18,7 @@ coverage
# Runtime data and logs
data
logs
.sandbox
# Local env files (inject at runtime via --env-file or -e)
.env

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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) || '' }}

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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
View File

@@ -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

View File

@@ -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"

View File

@@ -0,0 +1,5 @@
node_modules
dist
*.log
.DS_Store
.env

View 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.

View 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.

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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);
}

View 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";
}

View 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";
}

View 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";

View 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;
}

View 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\""));
});

View 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"]
}

View 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,
});

View File

@@ -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
View 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.

View 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);
});
}

View File

@@ -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

View 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))

View 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))

View File

@@ -1 +0,0 @@
- **fix(i18n):** completed the French UI catalog by adding all missing keys and replacing every placeholder translation ([#9235](https://github.com/diegosouzapw/OmniRoute/pull/9235)) — thanks @alex-jordan547

View File

@@ -1 +0,0 @@
- **fix(i18n):** localized hardcoded web UI copy across public pages, dashboard views, and shared components, with complete French and Vietnamese coverage ([#9245](https://github.com/diegosouzapw/OmniRoute/pull/9245)) — thanks @alex-jordan547

2
compression-core/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
Cargo.lock

View 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

View 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

View 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

View 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);

View 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 }

View 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;
}

View 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.

View 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;

View 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 }

View 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");
}

View 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 }

View 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);
}
}

View 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
}
]

View File

@@ -0,0 +1,6 @@
{
"id": "000",
"text": "Hello world! This is a test.",
"cl100k_tokens": 8,
"o200k_tokens": 8
}

View File

@@ -0,0 +1,6 @@
{
"id": "001",
"text": "The quick brown fox jumps over the lazy dog.",
"cl100k_tokens": 10,
"o200k_tokens": 10
}

View File

@@ -0,0 +1,6 @@
{
"id": "002",
"text": "🎉🎊 party time! emoji heavy sentence 🚀",
"cl100k_tokens": 15,
"o200k_tokens": 12
}

View File

@@ -0,0 +1,6 @@
{
"id": "003",
"text": "JSON:\n{\"name\":\"test\",\"values\":[1,2,3]}",
"cl100k_tokens": 15,
"o200k_tokens": 15
}

View File

@@ -0,0 +1,6 @@
{
"id": "004",
"text": "Code:\n```rust\nfn main() { println!(\"hi\"); }\n```",
"cl100k_tokens": 15,
"o200k_tokens": 15
}

View File

@@ -0,0 +1,6 @@
{
"id": "005",
"text": "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀",
"cl100k_tokens": 100,
"o200k_tokens": 50
}

View File

@@ -0,0 +1,6 @@
{
"id": "006",
"text": "Поддерживается ли русский текст корректно? Проверяем длинное предложение с кириллицей и пунктуацией!",
"cl100k_tokens": 41,
"o200k_tokens": 23
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,6 @@
{
"id": "008",
"text": "t",
"cl100k_tokens": 1,
"o200k_tokens": 1
}

View File

@@ -0,0 +1,6 @@
{
"id": "009",
"text": "",
"cl100k_tokens": 0,
"o200k_tokens": 0
}

View File

@@ -0,0 +1,6 @@
{
"id": "010",
"text": "Mixed 🎯 unicode 中文 한국어 + english + numbers 12345",
"cl100k_tokens": 18,
"o200k_tokens": 14
}

View 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
}

File diff suppressed because one or more lines are too long

View 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/`);

View 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 ✅");

View File

@@ -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
},

View File

@@ -121,6 +121,8 @@
"tailwind-merge",
"tailwindcss",
"tls-client-node",
"turndown",
"turndown-plugin-gfm",
"tsup",
"tsx",
"type-coverage",

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View 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"]

View 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");

View 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"]

View 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: {}

View 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" } });
}
});

View 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" };
}

View 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);
}

View 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

View 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'

View 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
View 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.

View 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.

View File

@@ -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
View 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

View 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`.

View File

@@ -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
View 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?

Some files were not shown because too many files have changed in this diff Show More