Skip to content

Commit dc40911

Browse files
diegosouzapwThinkscapenguyenxvotanminh3lunkerchenanki1kr
authored
Release v3.8.39 (#5164)
* chore(release): open v3.8.39 development cycle * docs(changelog): backfill 5 v3.8.38 bullets merged after release finalize These PRs squash-merged into release/v3.8.38 between the CHANGELOG finalize (ff57be3) and the merge-to-main (ae6e234), so they shipped in the v3.8.38 tag but had no bullet: - feat(compression): Ionizer engine (lossy JSON-array sampling + CCR) (#5148) - fix(sse): preserve non-stream reasoning fields (#5155, @rdself) - fix(i18n): add missing English UI labels (#5153, @rdself) - test(combo): gated live smoke (#5151) + release-expectations refresh (#5150, @KooshaPari) (#5129 exact-host Anthropic baseUrl is already covered by the #5130 bullet — same CodeQL #674.) Synced 41 i18n CHANGELOG mirrors. * feat(compression): TOON best-of-N candidate encoder + encoder A/B table (#5163) Integrated into release/v3.8.39. TOON best-of-N candidate encoder (GCF default, fail-open). 17/17 unit tests pass on merge result; CI reds were base-stale + Quality Ratchet DRIFT. * fix(zenmux): normalize vendor-prefixed GLM system roles (#5158) Integrated into release/v3.8.39. ZenMux vendor-prefixed GLM system-role normalization; 12/12 role-normalizer tests pass on merge result. CI reds base-stale. * [codex] fix xAI OAuth test and reasoning effort (#5157) Integrated into release/v3.8.39. xAI reasoning-effort normalization (max/xhigh→high) + OAuth test config; 46/46 xai-translator tests pass on merge result. CI reds base-stale. * docs(i18n): add Traditional Chinese (zh-TW) README and update zh-CN to latest (#5162) Integrated into release/v3.8.39. Traditional Chinese (zh-TW) README + zh-CN refresh; docs-only. * test(security): guard PII redaction stays opt-in (default off) + Hard Rule #20 (#5159) Integrated into release/v3.8.39. PII opt-in regression guard + Hard Rule #20; rebased to strip base-drift (+81/-1). 5/5 guard tests pass; flip-proof verified. * test(combo): deterministic context-relay universal-handoff coverage (closes phase-2 TODO) (#5168) Integrated into release/v3.8.39. Deterministic context-relay universal-handoff coverage (3 tests); 3/3 pass on merge result. * docs(i18n): full sync zh-TW and zh-CN README with canonical English v3.8.39 (#5171) Integrated into release/v3.8.39. Full zh-TW docs tree + zh-CN sync with canonical English v3.8.39; docs-only. * fix(serve): honour HOSTNAME from .env instead of hardcoding 0.0.0.0 (#5134) (#5170) Integrated into release/v3.8.39. HOSTNAME env override in serve (#5134) + regression test (4/4, TDD flip-proof verified). * fix(sse): resolve nameless deepseek-web tool blocks via parameter-schema match (#5154) (#5173) Integrated into release/v3.8.39. Schema-based nameless deepseek-web tool-block resolution (#5154); 6/6 tests pass on merge result (incl. ambiguous/no-match negatives + named-tag no-regression). * fix(sse): normalize array user content for Command Code to avoid upstream 400 (#5166) (#5174) Integrated into release/v3.8.39. Normalize array user content for Command Code (#5166, user-array/400 symptom); 4/4 tests pass on merge result. * fix(sse): defer </think> close so it never leaks before tool_calls (#5123) (#5175) Integrated into release/v3.8.39. Defer </think> close so it never leaks before tool_calls (#5123); 4/4 tests pass (incl. #4633 no-regression). CHANGELOG synced to keep all 3 v3.8.39 fixes. * fix(dashboard): use amber for home update-step warning icon (#5176) Integrated into release/v3.8.39. Amber for home update-step warning icon; 1/1 UI test. * fix(api): LAN/Tailscale dashboard — host-aware CSP + GET-exempt version route + combo field errors (#5083) (#5177) Integrated into release/v3.8.39. Host-aware CSP (ReDoS/injection-safe host validation) + GET-exempt /api/system/version (POST/spawn stays LOCAL_ONLY, exact-match safe-methods-only) + COMBO_002 firstField. 44/44 tests + route-guard membership gate green. CHANGELOG synced to keep all 4 v3.8.39 fixes. * fix(api): replace #5083 global middleware CSP with declarative ws: scheme (#5083) Follow-up to PR #5177 (merged): that version implemented the LAN-CSP fix (Bug 1) with a new global `src/middleware.ts` + `src/server/csp.ts`, which contradicts the project's documented architecture — 'No global Next.js middleware — interception is route-specific' (CLAUDE.md / AGENTS.md) — and was merged unverified (middleware vs next.config header precedence was never confirmed in a real build). This replaces that approach with the minimal, declarative equivalent: • next.config.mjs: connect-src now permits the bare `ws:` scheme (symmetric with the bare `wss:` already allowed) so the dashboard can reach its own Live WS server from a LAN/Tailscale host. No middleware. • Removes src/middleware.ts, src/server/csp.ts, and tests/unit/csp-host-aware.test.ts. • Adds tests/unit/csp-lan-ws-5083.test.ts (incl. a guard asserting src/middleware.ts does NOT exist, so the global-middleware approach cannot silently return). Bugs 2 (GET-exempt /api/system/version) and 3 (COMBO_002 field surfacing) from #5177 are unaffected and remain in place. Co-authored-by: KooshaPari <KooshaPari@users.noreply.github.com> * test(combo): end-to-end quota-share DRR routing-decision coverage (matrix parity) (#5179) Integrated into release/v3.8.39. Quota-share DRR routing-decision coverage (matrix parity); 2/2 pass on merge result. * feat(agent-bridge): graceful cert-install fallback with manual guide for containers (#4546) (#5178) Integrated into release/v3.8.39. Agent-bridge graceful cert-install fallback + manual guide (#4546); 6/6 tests pass on merge result. * fix(antigravity): family-scoped quota lockout (gemini/claude buckets) (#5180) Integrated into release/v3.8.39 — family-scoped antigravity quota lockout. Rebased from v3.8.37 + validated (vitest 5/5, typecheck clean, full combo-matrix green, model-lockout 99/0). Same-model cross-account retry (chat.ts) deferred pending live antigravity VPS validation. * fix(cli): force NODE_ENV to match dev/start run mode in custom Next server (#5189) Integrated into release/v3.8.39. Force NODE_ENV to match dev/start run mode in custom Next server; 2/2 source-scan+ordering tests pass on merge result. * feat(compression): CCR ranged/grep/stats retrieval (ReDoS-safe, backward-compat) (#5187) Integrated into release/v3.8.39. CCR ranged/grep/stats retrieval (safe-regex ReDoS guard + length/match caps); 17/17 tests pass on merge result. * docs(combo): sync all combo/routing-strategy docs to current state + document test coverage (#5185) Integrated into release/v3.8.39. Combo/routing-strategy docs sync; docs-only. * fix(mcp): return 404 (not 400) for unknown Streamable HTTP session id (#5169) (#5191) * fix(api): respect blocked Auto (Zero-Config) provider in /v1/models catalog (#5192) (#5194) * test(combo): deterministic context-relay codex quota-handoff coverage (closes last gap) (#5195) * test(ci): wire antigravity-quota-family under test:vitest (fix test-discovery orphan) (#5196) * fix(oauth): antigravity login no longer hangs — fire-and-forget onboarding + bounded post-exchange (#5193) Antigravity OAuth hang fix (no-PKCE/no-openid + bounded post-exchange + exchange-500 fix). Includes #5200 (Koosha) revert + owner rebaseline to keep documented comments. Integrated into release/v3.8.39. * feat(oauth): remote Antigravity login via local helper + paste-credentials (#5203) Remote Antigravity login: local helper (omniroute login antigravity) + paste-credentials. Integrated into release/v3.8.39. * fix(translator): accept Claude Messages shape in non-stream malformed-200 guard (#5156) Integrated into release/v3.8.39 * fix(cli): default dev bundler to Turbopack (16.2.x panic no longer reproduces) (#5206) Integrated into release/v3.8.39 * fix(cli): auto-calibrate server V8 heap from physical RAM (#5172) (#5213) The server was spawned with a fixed --max-old-space-size=512 (omniroute serve) or no heap flag at all (Electron), so RAM-rich boxes still OOM-crashed under load (Ineffective mark-compacts near heap limit ~500MB) with many providers/ accounts and large model catalogs. New calibrateHeapFallbackMb(os.totalmem()) defaults the heap to ~35% of RAM clamped [512,4096], wired into serve.mjs and electron/main.js. Explicit OMNIROUTE_MEMORY_MB still wins (#2939 unchanged). Also addresses #5160 (same OOM root); #5152 (docker) benefits via the same knob. Closes #5172 * fix(proxy): coalesce fast-fail health probes (#5208) Integrated into release/v3.8.39 * fix(proxy): close dispatchers when clearing cache (#5202) Integrated into release/v3.8.39 * fix(cli): raise dev server Node heap limit to 8GB to prevent OOM (#5198) Integrated into release/v3.8.39 * fix(auth): allow synthetic no-auth fallback for mimocode (#5205) Integrated into release/v3.8.39 * fix(oauth): preserve Antigravity refresh_token on empty/omitted upstream response (#3850) (#5214) Google's OAuth refresh tokens are non-rotating: the refresh response usually omits refresh_token and occasionally returns it as an empty string. The Antigravity executor used `typeof tokens.refresh_token === "string" ? ... ` which accepts "" (typeof "" === "string") and overwrote the stored token with empty, nulling it on first refresh. Now treats non-string OR empty as absent and preserves credentials.refreshToken, matching refreshGoogleToken semantics. Closes #3850 * fix(responses): normalize non-array input (#5204) Integrated into release/v3.8.39 * fix(stream): normalize safety finish reasons via shared helper (#5197) Integrated into release/v3.8.39 * fix(request-logger): never render negative '(-100%)' compression badge (#5201) Integrated into release/v3.8.39 * fix(combo): reject empty responses api output (#5207) Integrated into release/v3.8.39 — combo failover now rejects empty Responses API output (validateQuality). Baseline rebaseline dropped (main-measured drift; maintainer rebaselines at release). * fix(pwa): prefer cached navigation before offline page (#5209) Integrated into release/v3.8.39 — PWA service worker prefers cached navigation before offline page (#5165). * chore(release): v3.8.39 — 2026-06-28 * chore(release): rebaseline openapi+i18n coverage ratchet drift for v3.8.39 --------- Co-authored-by: Arthur Bodera <abodera@gmail.com> Co-authored-by: Nguyen Minh <lop123thcs@gmail.com> Co-authored-by: lunkerchen <labanchen@gmail.com> Co-authored-by: Ankit <177378174+anki1kr@users.noreply.github.com> Co-authored-by: KooshaPari <KooshaPari@users.noreply.github.com> Co-authored-by: Ardem2025 <ardemb22@gmail.com> Co-authored-by: backryun <bakryun0718@proton.me> Co-authored-by: Anton <39598727+NomenAK@users.noreply.github.com> Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Co-authored-by: Wilson <pedbookmed@gmail.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
1 parent 7b139fd commit dc40911

211 files changed

Lines changed: 26802 additions & 2420 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,19 @@ PORT=20128
111111
# Used by: src/app/api/v1/relay/chat/completions/route.ts
112112
# RELAY_IP_PER_MINUTE=30
113113

114-
# Bundler selection for `npm run dev`. Set to 1 to opt into Turbopack.
115-
# Default is 0 (webpack) because Turbopack 16.2.x panics on the OmniRoute
116-
# module graph with "internal error: entered unreachable code: there must be
117-
# a path to a root" (turbopack-core/module_graph/mod.rs:662). Same bug class
118-
# the production Docker build worked around in PR #4052. Webpack starts
119-
# slower but compiles cleanly. Re-enable once upstream Turbopack ships a fix.
120-
OMNIROUTE_USE_TURBOPACK=0
114+
# Bundler selection for `npm run dev`. Set to 0 to fall back to webpack.
115+
# Default is 1 (Turbopack). PR #4092 had forced webpack because earlier
116+
# Turbopack 16.2.x panicked on the OmniRoute module graph with "internal error:
117+
# entered unreachable code: there must be a path to a root"
118+
# (turbopack-core/module_graph/mod.rs:662). That panic no longer reproduces on
119+
# the pinned Next 16.2.9 — verified across a broad cold-compile sweep (36
120+
# dashboard routes + open-sse-heavy API routes incl. /api/v1/chat/completions,
121+
# /api/v1/models, /api/mcp) and repeated HMR rebuilds: zero panics. Turbopack
122+
# also keeps dev memory far lower on the edit→rebuild loop (HMR rebuild RSS stays
123+
# ~flat vs webpack's monotonic growth), which mitigates the dev-server OOM on
124+
# this 60+ route app. The production build still uses webpack (build pipeline is
125+
# unaffected by this dev-only flag).
126+
OMNIROUTE_USE_TURBOPACK=1
121127

122128
# Skip the SQLite integrity health check on startup (faster boot on large DBs).
123129
# Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to skip.

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ Request middleware including `promptInjectionGuard.ts`.
489489

490490
### Guardrails (`src/lib/guardrails/`)
491491

492-
Hot-reloadable guardrails framework (3 built-in: pii-masker, prompt-injection, vision-bridge). Fail-open; per-request opt-out via header. See [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md).
492+
Hot-reloadable guardrails framework (3 built-in: pii-masker, prompt-injection, vision-bridge). Fail-open. The `pii-masker` guardrail is registered and runs on every request, but its data-mutating logic is **opt-in** and OFF by default — it only redacts when `PII_REDACTION_ENABLED` (request) / `PII_RESPONSE_SANITIZATION` (response + streaming) are enabled (both `defaultValue: "false"`); with them off, payloads pass through untouched. A request can additionally opt OUT of any guardrail via header (`x-omniroute-disabled-guardrails`). Never make PII default-on (Hard Rule #20). See [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md).
493493

494494
### Cloud Agents (`src/lib/cloudAgent/`)
495495

CHANGELOG.md

Lines changed: 57 additions & 0 deletions
Large diffs are not rendered by default.

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,7 @@ the stale-enforcement added in Fase 6A.3.
513513
17. Never expose routes under `/api/services/` or `/dashboard/providers/services/*/embed/` without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. These routes can spawn child processes (`npm install`, `node`). Loopback enforcement happens unconditionally before any auth check — a leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
514514
18. Every bug fix must be validated before shipping: a failing-then-passing unit/integration test (TDD) OR a documented live test on the production VPS (192.168.0.15). A fix without either is not merged. See Testing → "Bug fix / issue triage protocol" for the full decision tree.
515515
19. Never develop on the shared main checkout. Every development task runs in its own git worktree on its own dedicated branch, and you MUST confirm the base branch with the operator (e.g. via `AskUserQuestion`) before creating the worktree/branch — never assume `main` or the currently checked-out branch. A `git checkout` in the shared checkout silently destroys other sessions' uncommitted work. Tear down only the worktrees/branches you created (by name, never `fix/*`/`feat/*` wildcards), leave other sessions' worktrees untouched, and end on the branch you started on (the active `release/vX.Y.Z`, never `main`). See Git Workflow → "Worktree isolation".
516+
20. PII redaction/sanitization is **opt-in — never on by default**. OmniRoute proxies for self-hosted/local LLMs where the operator owns the data, so mutating request/response payloads by default would silently corrupt legitimate traffic. The two data-mutating PII feature flags **MUST** keep `defaultValue: "false"` in `src/shared/constants/featureFlagDefinitions.ts`: `PII_REDACTION_ENABLED` (request-side) and `PII_RESPONSE_SANITIZATION` (response + streaming). All three application points — `src/lib/guardrails/piiMasker.ts` (request guardrail), `src/lib/piiSanitizer.ts` (response), `src/lib/streamingPiiTransform.ts` (SSE) — are gated on these flags; with both off the `pii-masker` guardrail still runs but never mutates payloads (data passes through untouched). Flipping either default to `"true"` requires explicit operator approval. The regression guard is `tests/unit/pii-opt-in-default.test.ts` (asserts both definition defaults + behavioral pass-through). Opt-in is per-operator via env or the settings/DB override (`src/lib/db/featureFlags.ts`), never a silent default. See `docs/security/GUARDRAILS.md`.
516517

517518
---
518519

README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@
6666
</div>
6767

6868
<div align="center">
69-
<b>🌐 Available in 40+ languages</b>
70-
<table>
69+
<b>🌐 Available in 41+ languages</b>
70+
<table>
7171
<tr>
7272
<td align="center"><a href="README.md">🇺🇸</a></td>
7373
<td align="center"><a href="docs/i18n/pt-BR/README.md">🇧🇷</a></td>
@@ -76,6 +76,7 @@
7676
<td align="center"><a href="docs/i18n/it/README.md">🇮🇹</a></td>
7777
<td align="center"><a href="docs/i18n/ru/README.md">🇷🇺</a></td>
7878
<td align="center"><a href="docs/i18n/zh-CN/README.md">🇨🇳</a></td>
79+
<td align="center"><a href="docs/i18n/zh-TW/README.md">🇹🇼</a></td>
7980
<td align="center"><a href="docs/i18n/de/README.md">🇩🇪</a></td>
8081
<td align="center"><a href="docs/i18n/ja/README.md">🇯🇵</a></td>
8182
<td align="center"><a href="docs/i18n/ko/README.md">🇰🇷</a></td>
@@ -289,13 +290,13 @@ Result: 4 layers of fallback = zero downtime
289290

290291
</div>
291292

292-
> Recent highlights from **v3.8.20 → v3.8.38**. Full history in [`CHANGELOG.md`](CHANGELOG.md).
293+
> Recent highlights from **v3.8.20 → v3.8.39**. Full history in [`CHANGELOG.md`](CHANGELOG.md).
293294
294295
- **⚖️ Quota-Share routing** — a dedicated combo strategy that spreads load across accounts by _available quota_: Deficit-Round-Robin scheduling, per-connection `max_concurrent` with cooldown-wait queueing, multi-window usage buckets (5h / 7d / per-model), per-(key,model) caps, session stickiness for prompt-cache integrity, and proactive saturation from upstream token-usage headers. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)
295296
- **🤖 One-command CLI/agent setup** — a dedicated `setup-*` command configures each coding tool to route through OmniRoute (Claude Code, Codex, Cline, Continue, Cursor, Roo Code, Kilo Code, Crush, Goose, Qwen Code, Aider, OpenCode, Gemini CLI); `omniroute launch` / `omniroute launch-codex` are zero-config launchers. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
296-
- **🛰️ Remote mode** — drive a remote OmniRoute from any machine with scoped access tokens (`omniroute connect` / `omniroute contexts` / `omniroute tokens`). → [Remote Mode](docs/guides/REMOTE-MODE.md)
297-
- **🧭 Smarter auto-routing** — OpenRouter-style `auto/<category>:<tier>` combos (e.g. `auto/coding:fast`, `auto/reasoning:pro`), a **Fusion** strategy (16th — fan out to a panel of models in parallel, then synthesize via a judge), **task-aware routing** (best-fit connection per task type), per-request `X-Route-Model` override, live Arena-ELO + models.dev model intelligence, per-step account allowlists, provider-wildcard combo steps, nested combo-ref execution, sticky weighted selection, and `web_search`-aware routing. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
298-
- **🗜️ Pluggable compression** — an async pipeline of **9 composable engines** with Compression Studios, an LLMLingua-2 ONNX engine and a heuristic/SLM two-tier **Ultra**, RTK, delegated Anthropic Context Editing, **Output Styles** (output-axis steering: terse-prose / less-code / terse-CJK), an **adaptive context-budget dial** (escalate only as far as needed to fit the context window), per-request `x-omniroute-compression` control, an opt-in offline eval harness, one-click **Headroom** proxy lifecycle management from the dashboard (Docker sidecar supported), a synthetic **compression playground** (Play lanes + A/B Compare with USD-capped fidelity verdicts), an opt-in **per-step fidelity gate** that rejects a lossy engine before it degrades the prompt, and a unified panel with named profiles + an active-profile selector. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
297+
- **🛰️ Remote mode** — drive a remote OmniRoute from any machine with scoped access tokens (`omniroute connect` / `omniroute contexts` / `omniroute tokens`), plus an `omniroute login antigravity` helper that runs Google "native/desktop" OAuth on your own machine and pastes a credential blob into a remote/VPS install (where the loopback redirect is unreachable). → [Remote Mode](docs/guides/REMOTE-MODE.md)
298+
- **🧭 Smarter auto-routing** — OpenRouter-style `auto/<category>:<tier>` combos (e.g. `auto/coding:fast`, `auto/reasoning:pro`), a **Fusion** strategy (fan out to a panel of models in parallel, then synthesize via a judge), **task-aware routing** (best-fit connection per task type), per-request `X-Route-Model` override, live Arena-ELO + models.dev model intelligence, per-step account allowlists, provider-wildcard combo steps, nested combo-ref execution, sticky weighted selection, and `web_search`-aware routing. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
299+
- **🗜️ Pluggable compression** — an async pipeline of **9 composable engines** with Compression Studios, an LLMLingua-2 ONNX engine and a heuristic/SLM two-tier **Ultra**, RTK, delegated Anthropic Context Editing, **Output Styles** (output-axis steering: terse-prose / less-code / terse-CJK), an **adaptive context-budget dial** (escalate only as far as needed to fit the context window), per-request `x-omniroute-compression` control, an opt-in offline eval harness, one-click **Headroom** proxy lifecycle management from the dashboard (Docker sidecar supported), a synthetic **compression playground** (Play lanes + A/B Compare with USD-capped fidelity verdicts), an opt-in **per-step fidelity gate** that rejects a lossy engine before it degrades the prompt, a **best-of-N candidate encoder** (GCF vs TOON — keep whichever is shorter, with an A/B bytes/token table in the studio), **CCR ranged/grep/stats retrieval** (pull an exact byte/line slice or summary of a stored block instead of re-expanding it), and a unified panel with named profiles + an active-profile selector. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
299300
- **🕵️ Transparent MITM decrypt (TPROXY)** — capture & translate traffic from CLIs that ignore proxy env vars, with a per-SNI certificate authority and a trust-store installer. → [MITM/TPROXY](docs/security/MITM-TPROXY-DECRYPT.md)
300301
- **💸 Cost telemetry everywhere**`X-OmniRoute-*` cost/usage headers on every endpoint (including media), a non-token cost engine, a cache-HIT `X-OmniRoute-Cost-Saved` header, and per-key USD spend quotas. → [API Reference](docs/reference/API_REFERENCE.md)
301302
- **🧠 Memory you control** — opt-in int8 vector quantization (Qdrant + sqlite-vec), memory off by default, and a per-request `x-omniroute-no-memory` header. → [Memory](docs/frameworks/MEMORY.md)

bin/cli/commands/login.mjs

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { createServer } from "node:http";
2+
import { randomUUID } from "node:crypto";
3+
4+
/**
5+
* `omniroute login antigravity` — local OAuth helper for remote installs.
6+
*
7+
* Why this exists: Google's `firstparty/nativeapp` consent for the embedded
8+
* Antigravity desktop client only releases the authorization code when the
9+
* loopback redirect (127.0.0.1:<port>) is REACHABLE. On a remote VPS install the
10+
* loopback is unreachable, so the consent hangs forever and never emits a code —
11+
* the dashboard's "paste the callback URL" fallback has nothing to paste. (The
12+
* same flow works locally and over an SSH tunnel, where the loopback IS reachable.)
13+
*
14+
* This command runs the OAuth on the user's OWN machine — where 127.0.0.1 works —
15+
* captures the code on a local loopback server, exchanges it for tokens, and
16+
* prints a single-line credential blob. The user pastes that blob into the remote
17+
* dashboard (Antigravity → "Paste credentials"), which decodes it, finalizes the
18+
* onboarding server-side, and persists the connection.
19+
*
20+
* It talks ONLY to Google (no OmniRoute server needed locally), so it works even
21+
* if the remote VPS is firewalled from the user's machine.
22+
*/
23+
24+
const PROVIDER = "antigravity";
25+
26+
/** Open the system browser; no-op if the optional `open` dependency is missing. */
27+
async function defaultOpenBrowser(url) {
28+
try {
29+
const { default: open } = await import("open");
30+
await open(url);
31+
} catch {
32+
// `open` not available — the caller already printed the URL to paste manually.
33+
}
34+
}
35+
36+
/**
37+
* Start a loopback HTTP server bound to 127.0.0.1 (NOT 0.0.0.0 — we never want to
38+
* expose the callback to the LAN). Resolves to { port, waitForCallback, close }.
39+
*/
40+
function defaultStartServer(preferredPort) {
41+
return new Promise((resolve, reject) => {
42+
let resolveCallback;
43+
const callbackPromise = new Promise((r) => {
44+
resolveCallback = r;
45+
});
46+
47+
const server = createServer((req, res) => {
48+
const url = new URL(req.url, "http://127.0.0.1");
49+
if (url.pathname !== "/callback" && url.pathname !== "/auth/callback") {
50+
res.writeHead(404).end();
51+
return;
52+
}
53+
const params = Object.fromEntries(url.searchParams.entries());
54+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
55+
res.end(
56+
"<!doctype html><meta charset=utf-8><title>OmniRoute</title>" +
57+
"<body style=\"font-family:system-ui;padding:2rem\">" +
58+
"<h2>✅ Authorization received</h2>" +
59+
"<p>Return to your terminal — you can close this tab.</p></body>"
60+
);
61+
resolveCallback(params);
62+
});
63+
64+
server.on("error", reject);
65+
server.listen(preferredPort || 0, "127.0.0.1", () => {
66+
const { port } = server.address();
67+
resolve({
68+
port,
69+
waitForCallback: () => callbackPromise,
70+
close: () => new Promise((r) => server.close(() => r())),
71+
});
72+
});
73+
});
74+
}
75+
76+
/** Lazy-load the antigravity provider + blob codec (TS source via tsx). */
77+
async function loadDeps() {
78+
const { antigravity } = await import("../../../src/lib/oauth/providers/antigravity.ts");
79+
const { encodeCredentialBlob } = await import("../../../src/lib/oauth/credentialBlob.ts");
80+
return { antigravity, encodeCredentialBlob };
81+
}
82+
83+
/**
84+
* Build the Google authorization request for a given loopback port. Uses a plain
85+
* authorization_code grant (NO PKCE code_challenge) — matching the working flow:
86+
* a code_challenge here would force the exchange to require a code_verifier.
87+
*/
88+
export async function buildAntigravityAuthRequest(port, makeState = randomUUID) {
89+
const { antigravity } = await loadDeps();
90+
const redirectUri = `http://127.0.0.1:${port}/callback`;
91+
const state = makeState();
92+
const authUrl = antigravity.buildAuthUrl(antigravity.config, redirectUri, state);
93+
return { redirectUri, state, authUrl };
94+
}
95+
96+
/** Exchange the captured code for raw Google tokens (no code_verifier — no PKCE). */
97+
export async function exchangeAntigravityCode(code, redirectUri) {
98+
const { antigravity } = await loadDeps();
99+
return antigravity.exchangeToken(antigravity.config, code, redirectUri);
100+
}
101+
102+
/**
103+
* Orchestrate the local login. Dependencies are injectable for testing; the real
104+
* path uses a 127.0.0.1 loopback server, the system browser, and a live token
105+
* exchange against Google. Returns the credential blob string.
106+
*/
107+
export async function runAntigravityLogin(opts = {}, deps = {}) {
108+
const startServer = deps.startServer ?? defaultStartServer;
109+
const openBrowser = deps.openBrowser ?? defaultOpenBrowser;
110+
const exchange = deps.exchange ?? exchangeAntigravityCode;
111+
const makeState = deps.makeState ?? randomUUID;
112+
const print = deps.print ?? ((s) => process.stdout.write(s));
113+
const log = deps.log ?? ((s) => process.stderr.write(s));
114+
const { encodeCredentialBlob } = await loadDeps();
115+
116+
const server = await startServer(opts.port);
117+
const { redirectUri, state, authUrl } = await buildAntigravityAuthRequest(server.port, makeState);
118+
119+
log(`\nOpen this URL to authorize Antigravity (it will open automatically):\n\n ${authUrl}\n\n`);
120+
if (opts.browser !== false) await openBrowser(authUrl);
121+
log("Waiting for Google to redirect back to the local loopback...\n");
122+
123+
const timeoutMs = opts.timeout ?? 300000;
124+
let timer;
125+
let params;
126+
try {
127+
params = await Promise.race([
128+
server.waitForCallback(),
129+
new Promise((_, reject) => {
130+
timer = setTimeout(
131+
() => reject(new Error("Timed out waiting for the OAuth callback")),
132+
timeoutMs
133+
);
134+
// Don't keep the event loop alive solely for this timer.
135+
if (typeof timer.unref === "function") timer.unref();
136+
}),
137+
]);
138+
} finally {
139+
clearTimeout(timer);
140+
await server.close();
141+
}
142+
143+
if (params.error) {
144+
throw new Error(`Authorization failed: ${params.error_description || params.error}`);
145+
}
146+
if (params.state !== state) {
147+
throw new Error("State mismatch — aborting (possible CSRF). Please retry the login.");
148+
}
149+
if (!params.code) {
150+
throw new Error("No authorization code returned by Google.");
151+
}
152+
153+
const tokens = await exchange(params.code, redirectUri);
154+
const blob = encodeCredentialBlob({ provider: PROVIDER, tokens });
155+
156+
print(
157+
"\n" +
158+
"Antigravity authorized. Copy the line below and paste it into your remote\n" +
159+
"OmniRoute dashboard: Providers → Antigravity → Connect → \"Paste credentials\".\n" +
160+
"(This contains a refresh token — treat it like a password.)\n\n" +
161+
blob +
162+
"\n\n"
163+
);
164+
return blob;
165+
}
166+
167+
async function runLoginAntigravity(opts) {
168+
try {
169+
await runAntigravityLogin({
170+
browser: opts.browser,
171+
timeout: opts.timeout,
172+
port: opts.port,
173+
});
174+
} catch (err) {
175+
process.stderr.write(`\nLogin failed: ${err?.message || err}\n`);
176+
process.exit(1);
177+
}
178+
}
179+
180+
export function registerLogin(program) {
181+
const login = program
182+
.command("login")
183+
.description("Local OAuth helpers for remote OmniRoute installs (run on your own machine)");
184+
185+
login
186+
.command("antigravity")
187+
.description("Authorize Antigravity locally and print a credential blob to paste remotely")
188+
.option("--no-browser", "Do not auto-open the browser; print the URL instead")
189+
.option("--port <n>", "Fixed loopback port (default: OS-assigned)", (v) => parseInt(v, 10))
190+
.option("--timeout <ms>", "How long to wait for the callback", (v) => parseInt(v, 10), 300000)
191+
.action(runLoginAntigravity);
192+
}

bin/cli/commands/registry.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { registerMemory } from "./memory.mjs";
22
import { registerSkills } from "./skills.mjs";
33
import { registerAudit } from "./audit.mjs";
44
import { registerOAuth } from "./oauth.mjs";
5+
import { registerLogin } from "./login.mjs";
56
import { registerCloud } from "./cloud.mjs";
67
import { registerEval } from "./eval.mjs";
78
import { registerWebhooks } from "./webhooks.mjs";
@@ -83,6 +84,7 @@ export function registerCommands(program) {
8384
registerSkills(program);
8485
registerAudit(program);
8586
registerOAuth(program);
87+
registerLogin(program);
8688
registerCloud(program);
8789
registerEval(program);
8890
registerWebhooks(program);

0 commit comments

Comments
 (0)