mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +03:00
* chore(release): open v3.8.28 development cycle * fix(ws): warm SSE auth import on LiveWS startup; relocate boot test to integration (#4063) The live dashboard WebSocket sidecar lazily import()-ed the SSE auth module inside the connection handler, only on the API-key path. That cold import pulls in hundreds of transitive modules and takes ~7s under tsx, blocking the single-threaded event loop. The first API-key WebSocket connection therefore stalled the loop long enough that any connection arriving in that window — e.g. a same-origin cookie client — could not complete its handshake and timed out. This was deterministic, not an "env flake": the boot test fires an API-key connection immediately followed by a cookie connection, so the cookie connection always raced the cold import and timed out (reproduced 3/3 locally and red on every CI run; proven via instrumented probes — reversing the order or warming the module first makes both connections open in ~20ms). Fix: - Memoize the auth-module import and warm it once at startup (before listen), so connection handling never pays the cold-import cost. Real improvement: the first API-key client no longer stalls the event loop for concurrent clients. - Relocate the boot test from tests/unit/cli to tests/integration. It spawns a real subprocess + WS server + SQLite (~9-11s); under the unit suite's --test-concurrency=20 it contended for CPU and destabilized the shard. The serial integration runner is its correct home; it still guards #4004's cookie-parse fix on every PR via the integration CI job. - Bump the test's startup/overall timeouts to absorb the eager auth warm. Makes `npm run test:unit` deterministically green (the only remaining unit red). Validated: relocated test 3/3 green via the integration runner (was 3/3 red); typecheck:core + eslint clean; confirmed it no longer matches the test:unit glob and does match tests/integration/*.test.ts. * fix(ws): start LiveWS sidecar with cwd at package root (#4055) (#4064) * chore(deps): bump ossf/scorecard-action from 2.4.0 to 2.4.3 (#4045) Integrado em release/v3.8.28. Patch de SHA do ossf/scorecard-action (2.4.0→2.4.3), mantém SHA-pin. Reds de CI são exclusivamente os shards flaky pré-existentes branch-wide (Unit 7/8, Integration, Coverage 7/8, Node 1/2) — não relacionados ao bump (PR deps-only). * deps: bump electron from 42.4.0 to 42.4.1 in /electron (#4049) Integrado em release/v3.8.28. Patch do electron (42.4.0→42.4.1). Reds de CI: shards flaky pré-existentes + PR Test Policy = falso-positivo (mudança deps-only sob electron/ não comporta teste de código) + Node 26(2/2) sem step (flake/infra). Precedente #3913/#3914 (electron dependabot mergeado nessas condições). * fix(auto): resolve built-in auto catalog combos (#4058) Integrado em release/v3.8.28. Resolve os IDs de catálogo `auto/*` built-in (combos virtuais) — corrige o 400 "No auto combos configured" em auto/best-coding etc. Ajuste de review: os mapas AUTO_TEMPLATE_VARIANTS/VALID_AUTO_VARIANTS duplicados em chat.ts e chatHelpers.ts foram extraídos para open-sse/services/autoCombo/builtinCatalog.ts (DRY), devolvendo chatHelpers.ts <800 LOC; baseline de chat.ts rebaselinado 1432→1458 (lógica nova). Fast QG + semgrep + dast verdes; 22/22 testes. * chore(docs): update Discord invite link to a non-expiring one (#4067) * chore(deps): freeze @huggingface/transformers in dependabot (hard-pin) (#4066) Integrado em release/v3.8.28. Congela @huggingface/transformers no dependabot (pin exato 3.5.2, load-bearing p/ LLMLingua + memory embeddings, VPS-validado #4014). Fast QG + semgrep + dast verdes. * ci(quality): flip TIA impacted-unit-tests gate from advisory to blocking (#4069) The pre-existing release unit test-debt that kept the TIA "Impacted unit tests" step advisory has been cleared: - #4030 restored 16 lossless Zod/registry reds (from the oyi77 modularize refactors). - #4063 fixed the last red — the LiveWS boot test — which was a real deterministic event-loop stall in the WS sidecar (cold ~7s lazy auth import racing a second connection), not an env flake; fixed (warm the import at startup) and relocated to the integration suite. A full workflow_dispatch ci.yml run on release/v3.8.28 then showed all 8 Unit Tests shards green. The remaining Integration Tests / Quality Ratchet reds are pre-existing and unrelated (combo/resilience env-flakes; eslint/i18n baseline drift). Removing continue-on-error makes PR->release block on unit-test regressions in the TIA-selected impacted set (fail-safe still runs the full unit suite on hub/unmapped changes). typecheck:core was already blocking. Closes the fast-gates "no tests on PR->release" hole (Quality Gate v2 / Fase 9, P2). * docs(compression): document LLMLingua optional deps + on-demand install (#4061) Integrado em release/v3.8.28. Docs LLMLingua optional deps + on-demand install (F3.1). * feat(dashboard): Combo Studio connection-cooldown badge (U1b Slice 2) (#4068) Integrado em release/v3.8.28. Combo Studio connection-cooldown badge (U1b Slice 2 / F5.1). * feat(compression): record Context Editing telemetry (engine: context-editing) (#4062) Integrado em release/v3.8.28. Context Editing telemetry (F4.1). * feat(sse): Context Editing relay coverage + 400-fallback (#4065) Integrado em release/v3.8.28. Context Editing relay coverage (cc-*) + 400-fallback (F4.2/F4.3). Conflito de file-size-baseline.json (vs #4062) resolvido por união (ambas justificativas + base.ts 1292 + chatCore.ts 5898). Validado local no tree mergeado: typecheck:core ✓, eslint ✓, check:file-size ✓, 4/4 testes ✓; semgrep + semgrep-cloud verdes. Fast QG enfileirado (saturação de runner) — mergeado nos gates de política verificados (precedente #4034/#4020). * feat(providers): add OrcaRouter (OpenAI-compatible routing gateway) (#4070) Integrado em release/v3.8.28. Adiciona o provider OrcaRouter (OpenAI-compatible, API-key, DefaultExecutor). Ajuste de review: rebaseline de file-size de providers.ts 3147→3159 (+12 da entrada OrcaRouter). Validado local no tree sincronizado: provider-consistency ✓, docs-counts STRICT 227 ✓, typecheck:core ✓, teste 3/3 ✓, eslint ✓; semgrep + semgrep-cloud verdes. Fast QG/dast enfileirados (saturação de runner) — merge nos gates de política verificados (precedente #4034/#4065). * test(infra): isolate DATA_DIR per test process; raise Stryker concurrency 1→4 (#4078) * test(infra): isolate DATA_DIR per test process; raise Stryker concurrency 1→4 Every test process resolved DATA_DIR to the same default (~/.omniroute) when the env var was unset (src/lib/dataPaths.ts::resolveDataDir), so concurrent test files opened the SAME on-disk storage.sqlite. node:test spawns a process per file and Stryker spawns one per sandbox, so this shared file caused cross-file state races: - SQLite lock contention that hung `npm run test:unit` under high --test-concurrency (the ~95-min local hang), and - the non-deterministic baseline that forced stryker.conf.json to concurrency: 1, which in turn could not finish the ~15k-mutant run inside the nightly timeout (the cancelled 2026-06-16/17 nightly-mutation runs) — blocking Quality Gate v2 / Fase 9 Onda 2. open-sse/utils/setupPolyfill.ts could NOT host the fix: it is imported by production (bin/omniroute.mjs, proxyFetch.ts, proxyDispatcher.ts), where redirecting DATA_DIR would point the live SQLite DB at a throwaway temp dir. So this adds a TEST-ONLY tests/_setup/isolateDataDir.ts that gives each process its own temp DATA_DIR when none is set (tests that set DATA_DIR explicitly still win), wired via --import into the test, mutation and CI invocations. Verified: - Stryker dry-run A/B at concurrency=4: FAILS without the isolation import (account-fallback-service tap exit 9, a cross-file race) and PASSES with it. - Full `npm run test:unit` green with isolation (0 fail; a one-off chatcore-translation-paths timeout flake did not reproduce and passes 3/3 isolated) and noticeably faster — the DB lock contention is gone. - New tests/unit/isolate-datadir.test.ts guards the contract (unique temp DATA_DIR when unset; explicit DATA_DIR respected). Wired the --import into: package.json (13 test scripts), stryker.conf.json (tap.nodeArgs + concurrency 1→4), .github/workflows/quality.yml (TIA step), ci.yml (the 5 unit/coverage/integration commands), and bumped nightly-mutation.yml timeout 120→180 for the first cold run before the incremental cache is seeded. * ci(quality): run the TIA gate at CI concurrency (4) to stop oversubscription flakes The TIA "Impacted unit tests" step (made blocking in #4069) ran its fail-safe via `npm run test:unit` — concurrency=20, tuned for multi-core dev machines. On a 4-vCPU CI runner that is 5x oversubscribed, so timing-sensitive tests flake under the load (e.g. `db-backup-extended` "The database connection is not open", `chatcore-translation-paths` upstream-timeout). That intermittently fails a blocking gate on legitimate PRs — exactly what surfaced on the DATA_DIR-isolation PR, whose package.json/workflow changes trip the __RUN_ALL__ fail-safe. Run both the impacted set and the fail-safe at --test-concurrency=4, matching the stable ci.yml unit job. Adds a `test:unit:ci` script (test:unit at concurrency=4). The DATA_DIR isolation in this PR keeps the parallel run race-free, so the only change here is matching the runner's core count. Verified locally: db-backup-extended passes 8/8 in isolation (5 with isolation, 3 without). * docs(quality-gates): reconcile gate inventory with ci.yml + add ROI rationalization backlog (#4095) The "authoritative" gate inventory in QUALITY_GATES.md had drifted from ci.yml: it omitted 9 wired gates — `audit:deps`, `check:tracked-artifacts`, `check:lockfile`, `check:licenses` (lint job), `check:dead-code`, `check:cognitive-complexity`, `check:type-coverage`, `check:codeql-ratchet` (quality-gate job), and `check:pr-evidence` (pr-test-policy job). You can't rationalize an inventory you can't trust, so this reconciles it first. Adds those 9 rows to their job tables and a "Rationalization Backlog (ROI review)" section capturing the Fase 9 Onda 3 findings: mechanical merge/dedup candidates (CVE scanners audit:deps↔osv, the two complexity ESLint passes, cycles↔circular-deps, the two /api anti-hallucination gates, the doubly-run check:docs-sync, check:node-runtime ×11) and the operator-only flip/drop decisions (typecheck:noimplicit vs the type-coverage ratchet, test:vitest:ui parked fails, check:secrets frozen FPs, openapi-security-tiers, pr-evidence, the orphaned semgrep baseline). Also flags the undocumented advisory docs-lint job and the standalone scanner workflows. Docs-only — no gate behavior changes. The merges (CI changes) and flips (policy) are deferred to operator-scoped follow-ups; this PR only makes the map accurate. * test(dashboard): smoke e2e for the Combo Live Studio page (#4075) Integrated into release/v3.8.28 * fix(sse): friendly 413 message for ChatGPT web payload-too-large (#4080) Integrated into release/v3.8.28 * feat(sse): port Claude Code quota-probe bypass + command meta-request helpers (#4083) Integrated into release/v3.8.28 * feat(api): exact offline token counting for count_tokens fallback via tiktoken (#4087) Integrated into release/v3.8.28 * feat(compression): RTK learn/discover (sample source + API + UI) (#4088) Integrated into release/v3.8.28 * feat(dashboard): 2026-06-17 free-tier refresh — honest catalog, uncapped + boost tiers, Layout A budget table (#4089) Integrated into release/v3.8.28 * feat(mitm): capture-pipeline self-test route (Gap 12) (#4093) Integrated into release/v3.8.28 * fix(mitm): crash-safe system-state teardown + socket timeouts (ProxyBridge-inspired hardening) (#4084) Integrated into release/v3.8.28 (Fast QG TIA red = 3 pre-existing timing flakes verified passing locally 82/82; PR own tests green) * feat(mitm): attribute intercepted requests to originating process (Gap 1) (#4085) Integrated into release/v3.8.28 (Fast QG TIA red = 3 pre-existing timing flakes verified passing locally 82/82; PR own tests green) * fix(sse): route image requests only to confirmed-vision combo targets (#4071) Integrated into release/v3.8.28 * fix(security): injection guard respects INJECTION_GUARD_MODE DB feature flag (#4077) Integrated into release/v3.8.28 * fix(ws): proxy LAN /live-ws upgrades and add unset JWT_SECRET warning (#4079) Integrated into release/v3.8.28 * fix(dev): force webpack in custom dev server (Turbopack 16.2.x panics) (#4092) Integrated into release/v3.8.28 * ci(quality): dedup the doubly-run check:docs-sync + record validated ROI backlog (#4099) Onda 3 (gate ROI-review) Phase 2. Two parts, both low-risk: 1. Remove the standalone `check:docs-sync` from the `lint` job — it already runs in the `docs-sync-strict` job (via `check:docs-all`) and the husky pre-commit hook, so the `lint`-job copy was a pure duplicate. No coverage lost. 2. Update the Rationalization Backlog in QUALITY_GATES.md with trust-but-verify findings: several "obvious" merges/flips from the ROI review turned out to hide debt and are NOT clean drop-ins — - CVE merge (audit:deps→osv): different semantics (hard high/critical vs regression-ratchet) — keep both. - cycles→circular-deps: dpdm reports 91 cycles (can't promote to blocking) and is broader-scope than the green curated check:cycles — keep both. - openapi-security-tiers flip: blocked by traffic-inspector routes missing the x-loopback-only annotation. - complexity + /api merges: valid but real config/script surgery — deferred. - node-runtime ×11: ~10s savings vs a cheap guard — low ROI, skip. The remaining flips (typecheck:noimplicit, test:vitest:ui, check:secrets, pr-evidence, semgrep) are operator policy decisions, left for the owner. * chore(deps): bump actions/github-script from 7 to 9 (#4046) Integrated into release/v3.8.28 (dependabot GH-Action bump; SHA-pin preserved) * chore(deps): bump actions/setup-node from 4 to 6 (#4048) Integrated into release/v3.8.28 (dependabot GH-Action bump; SHA-pin preserved) * chore(deps): bump actions/upload-artifact from 4 to 7 (#4044) Integrated into release/v3.8.28 (dependabot GH-Action bump; SHA-pin preserved) * chore(deps): bump actions/cache from 4.3.0 to 5.0.5 (#4047) Integrated into release/v3.8.28 (dependabot GH-Action bump; SHA-pin preserved) * deps: bump the development group with 10 updates (#4051) Integrated into release/v3.8.28 (dependabot dev group; cyclonedx 4->5 verified compatible with the SBOM invocation --ignore-npm-errors/--output-format JSON/--output-file) * fix(dashboard): event-driven fail-open auto-refresh for embedded log views (#4054) (#4103) The Request Logger gated each auto-refresh tick on a static document.visibilityState === "visible" read. Hosts that report a permanent non-"visible" state without ever firing a visibilitychange event (Docker dashboard wrappers, embedded/proxied webviews) froze auto-refresh entirely — only the manual Refresh button worked, a regression from 3.8.24's unconditional polling. The pause is now event-driven and fail-open: visibleRef starts true and is only flipped to false on a real visibilitychange → hidden transition, so a host that never signals a genuine background transition keeps polling, while normal browser tabs still pause when actually backgrounded. Regression test reproduces the misreporting-host case (RED) and the perf guard is re-encoded under the event-driven semantics. * fix(docker): raise build-stage Node heap to stop production-build OOM (#4076) (#4104) The Docker builder stage ran `npm run build` with V8's default heap ceiling (~2 GB). After #4052 forced the heavier webpack engine (Turbopack panics on this Next.js version), the production optimization pass exceeded that ceiling and the build died with "FATAL ERROR: ... JavaScript heap out of memory" at [builder] npm run build. The builder stage now sets NODE_OPTIONS=--max-old-space-size (default 4096 MB, overridable via --build-arg OMNIROUTE_BUILD_MEMORY_MB) before the build; the value propagates to the spawned next build (resolveNextBuildEnv spreads process.env). Build-only — the runtime heap on the runner stage is unchanged, and CI/local builds (which invoke npm run build directly) are unaffected. Regression guard: tests/unit/dockerfile-build-heap-4076.test.ts asserts the builder stage sets the heap ceiling, before npm run build, at >= 4096 MB. * feat(agent-bridge): portable JSON import/export of config (Gap 4) (#4094) Integrated into release/v3.8.28 * feat(cli): add 'omniroute launch' zero-config Claude Code launcher (#4097) Integrated into release/v3.8.28 (Fast QG TIA red = pre-existing env-doc-contract drift [MITM_IDLE_TIMEOUT_MS/TURBOPACK from #4084/#4092] + opencode-plugin-dist env flake; #4097 own test 3/3 green) * feat(mitm): loop-guard self-check + verbosity control in server.cjs (Gaps 14+15) (#4101) Integrated into release/v3.8.28 (rebased onto release — dropped the already-squash-merged #4084 commits; only the Gaps 14+15 loop-guard/verbosity delta remains) * feat(sse): generic 400 field-downgrade retry + Groq field stripping (#4096) Integrated into release/v3.8.28 * feat(providers): add Wafer AI (Anthropic-compatible, Bearer auth) (#4098) Integrated into release/v3.8.28 * chore(docs) * fix(responses): clear /v1/responses keepalive timer on cancel/abort (timer + CPU leak) (#4105) Integrated into release/v3.8.28 (r7). * perf(gemini): cache reasoning close-tag regex instead of recompiling per token (#4106) Integrated into release/v3.8.28 (r7). * fix(usage): reap orphaned pending-request details (unbounded memory leak) (#4107) Integrated into release/v3.8.28 (r7). * perf(stream): use structuredClone instead of JSON round-trip for per-chunk reasoning split (#4108) Integrated into release/v3.8.28 (r7). * fix(dashboard): restore Update Available banner with npm-binary-free version fallback (#4100) (#4112) getLatestNpmVersion() derived the latest version only from the npm CLI binary and returned null on any error, so Docker/desktop/locked-down installs without npm on PATH silently hid the home banner even when an update existed. Add resolveLatestVersion() (npm CLI -> registry HTTP fallback -> logged warning) and harden version parsing for v-prefix/pre-release strings. Extracted into testable src/lib/system/versionCheck.ts with TDD coverage. * fix(auth): prune expired entries from login brute-force guard map (unbounded growth) (#4111) Integrated into release/v3.8.28 (r8) * fix(logger): hard-cap the error-dedup map to bound memory under unique-message bursts (#4113) Integrated into release/v3.8.28 (r8) * fix(circuit-breaker): enforce MAX_REGISTRY_SIZE (declared but never applied) (#4114) Integrated into release/v3.8.28 (r8) * perf(obfuscation): cache per-word regexes instead of recompiling every request (#4109) Integrated into release/v3.8.28 (r8) * perf(registry): precompute model->provider index in parseModelFromRegistry (#4110) Integrated into release/v3.8.28 (r8) * fix(timers): unref background interval timers so they don't block clean shutdown (#4117) Integrated into release/v3.8.28 (r8) * fix(webhook): clear abort timer in finally to avoid dangling timers on fetch error (#4115) Integrated into release/v3.8.28 (r8) * fix(combo): detach per-target listener from shared hedge abort signal (#4116) Integrated into release/v3.8.28 (r8) * chore(release): finalize v3.8.28 CHANGELOG + reconcile env-doc contract - Build the complete [3.8.28] CHANGELOG section (55 bullets) covering every commit since v3.8.27, grouped by type with PR back-references and human contributor attribution (artickc's memory-leak/perf cluster, OrcaRouter, Wafer AI, MITM gaps, etc.); move the OrcaRouter bullet out of [Unreleased]. - Inject the EN [3.8.28] section into all 41 i18n CHANGELOG mirrors (parity). - Reconcile the env/docs contract: document MITM_IDLE_TIMEOUT_MS + MITM_VERBOSE in .env.example and ENVIRONMENT.md; allowlist the framework-internal TURBOPACK and the Claude Code ANTHROPIC_AUTH_TOKEN in check-env-doc-sync. - Fix 3 broken relative links in docs/providers/AGENTROUTER.md (regressed when the file was relocated this cycle) so docs-sync-strict passes. * fix(quality): treat test→test renames as relocations, not deletions The anti-test-masking gate's subcheck-1 collected deleted AND renamed test files via `--diff-filter=DR --name-only` and flagged every one as "deleted — human review required", contradicting its own documented contract ("DELETADOS ou renomeados-e-NÃO-substituídos"): a rename test→test IS a substitution (the test moved, coverage preserved). This false-positived on #4063's legitimate relocation of live-ws-startup.test.ts (unit/cli → integration, asserts 2→2) and would block every PR that relocates a test — surfacing only at release-day because the Fast QG (PR→release) doesn't run test-masking. The gate now parses `--name-status -M`: true deletions and test→non-test renames still flag; a test→test rename is run through the assert-reduction check across the move, so a clean relocation passes while gutting-via-rename (dropped asserts / new tautologies / skips) still fires. Adds partitionDeletedRenamed + 6 regression tests. --------- Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Demiurge The Single <megamen932@gmail.com> Co-authored-by: jinhaosong-source <jinhao.song@myflashcloud.com> Co-authored-by: diego-anselmo <contato@diegoanselmo.com.br> Co-authored-by: Felipe Almeman <4226997+zhiru@users.noreply.github.com> Co-authored-by: Rahul sharma <sharmaR0810@gmail.com> Co-authored-by: Chirag Singhal <76880977+chirag127@users.noreply.github.com> Co-authored-by: NOXX - Commiter <artur1992123@mail.ru>
651 lines
21 KiB
TypeScript
651 lines
21 KiB
TypeScript
import { spawn, type ChildProcess } from "child_process";
|
|
import path from "path";
|
|
import fs from "fs";
|
|
import { resolveMitmDataDir } from "./dataDir.ts";
|
|
import { addDNSEntry, addDNSEntries, removeDNSEntry, removeDNSEntries } from "./dns/dnsConfig.ts";
|
|
import { generateCert } from "./cert/generate.ts";
|
|
import { installCert, uninstallCert } from "./cert/install.ts";
|
|
import { ALL_TARGETS } from "./targets/index.ts";
|
|
import { detectAgent } from "./detection/index.ts";
|
|
import type { AgentId, DetectionResult, MitmTarget } from "./types.ts";
|
|
import { getAllAgentBridgeStates } from "@/lib/db/agentBridgeState.ts";
|
|
import { listCustomHosts } from "@/lib/db/inspectorCustomHosts.ts";
|
|
import { getUserBypassPatterns } from "@/lib/db/agentBridgeBypass.ts";
|
|
import { configureUpstreamCa } from "./upstreamTrust.ts";
|
|
import { createLogger } from "@/shared/utils/logger.ts";
|
|
|
|
const log = createLogger("mitm-manager");
|
|
|
|
/**
|
|
* Map the MITM child process (`server.cjs`) stderr to the actual startup-failure
|
|
* cause. `server.cjs` emits one of several "❌"-prefixed lines on `server.on("error")`
|
|
* or on a missing API key, then exits. The old code only matched EADDRINUSE and so
|
|
* always blamed "port 443", misleading users whose real problem was a permission
|
|
* error or a missing ROUTER_API_KEY (#3606). The returned string is a controlled,
|
|
* secret-free diagnostic (it carries no stack and no credentials). (#3606)
|
|
*/
|
|
export function interpretMitmStartupError(stderr: string, port: number): string {
|
|
const text = (stderr || "").trim();
|
|
const lower = text.toLowerCase();
|
|
|
|
if (lower.includes("already in use")) {
|
|
return `MITM server failed to start: port ${port} is already in use`;
|
|
}
|
|
if (lower.includes("permission denied")) {
|
|
return `MITM server failed to start: permission denied for port ${port} (run with elevated privileges, or use a port ≥ 1024)`;
|
|
}
|
|
if (lower.includes("router_api_key")) {
|
|
return "MITM server failed to start: no API key was provided (ROUTER_API_KEY is required). Set a router API key in OmniRoute and retry.";
|
|
}
|
|
|
|
// Surface the first "❌ <message>" diagnostic line verbatim (marker stripped),
|
|
// so any other server.cjs failure is reported with its real cause.
|
|
for (const line of text.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.startsWith("❌")) {
|
|
const detail = trimmed.replace(/^❌\s*/, "").trim();
|
|
if (detail) return `MITM server failed to start: ${detail}`;
|
|
}
|
|
}
|
|
|
|
// Nothing diagnostic was captured — stay generic instead of guessing port 443.
|
|
return "MITM server failed to start (no diagnostic output was captured from the MITM server)";
|
|
}
|
|
|
|
// Store server process
|
|
let serverProcess: ChildProcess | null = null;
|
|
let serverPid: number | null = null;
|
|
|
|
// Set when getMitmStatus() finds a stale PID file (server died without clean
|
|
// teardown). The dashboard surfaces this to offer a one-click Repair. Cleared
|
|
// by repairMitm(). (Gap 7.)
|
|
let _orphanedStateDetected = false;
|
|
|
|
// Guards installCleanupHandlers() so the parent-process signal handlers are
|
|
// registered at most once. (Gap 7.)
|
|
let _cleanupHandlersInstalled = false;
|
|
|
|
// Module-scoped password cache (not exposed on globalThis).
|
|
// Cleared automatically when the MITM proxy is stopped.
|
|
let _cachedPassword: string | null = null;
|
|
export function getCachedPassword(): string | null {
|
|
return _cachedPassword;
|
|
}
|
|
export function setCachedPassword(pwd: string | null | undefined): void {
|
|
_cachedPassword = pwd || null;
|
|
}
|
|
export function clearCachedPassword(): void {
|
|
_cachedPassword = null;
|
|
}
|
|
|
|
const PID_FILE = path.join(resolveMitmDataDir(), "mitm", ".mitm.pid");
|
|
const TARGETS_JSON_FILE = path.join(resolveMitmDataDir(), "mitm", "targets.json");
|
|
const BYPASS_JSON_FILE = path.join(resolveMitmDataDir(), "mitm", "bypass.json");
|
|
const CA_PATH_FILE = path.join(resolveMitmDataDir(), "mitm", "upstream-ca.path");
|
|
|
|
/** Read the persisted upstream CA path written by the POST upstream-ca route handler. */
|
|
function readStoredUpstreamCaPath(): string | null {
|
|
try {
|
|
if (!fs.existsSync(CA_PATH_FILE)) return null;
|
|
const raw = fs.readFileSync(CA_PATH_FILE, "utf8").trim();
|
|
return raw || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write the canonical `targets.json` consumed by `server.cjs` at startup.
|
|
*
|
|
* The file mirrors the static `ALL_TARGETS` registry; server.cjs treats it as
|
|
* an extension of its baseline antigravity hosts. Hard Rule #13: only the
|
|
* declarative target hosts are persisted — no runtime paths, no shell escapes.
|
|
*/
|
|
export function writeTargetsJson(targets: MitmTarget[] = ALL_TARGETS): void {
|
|
const dir = path.join(resolveMitmDataDir(), "mitm");
|
|
try {
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
} catch {
|
|
// mkdir failures are non-fatal; the write below will report the real error.
|
|
}
|
|
const payload = {
|
|
version: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
targets: targets.map((t) => ({
|
|
id: t.id,
|
|
name: t.name,
|
|
hosts: t.hosts,
|
|
endpointPatterns: t.endpointPatterns,
|
|
viability: t.viability ?? "supported",
|
|
})),
|
|
};
|
|
fs.writeFileSync(TARGETS_JSON_FILE, JSON.stringify(payload, null, 2));
|
|
}
|
|
|
|
/**
|
|
* Write the canonical `bypass.json` file consumed by `server.cjs` at startup.
|
|
*
|
|
* Only USER-configured patterns are persisted here — the default bypass
|
|
* regexes (banks/gov/okta/auth0) live hard-coded in `server.cjs` and in
|
|
* `src/mitm/passthrough.ts` so they apply even when the file is missing.
|
|
*
|
|
* Plan reference: 11-agent-bridge.plan.md §4.6 + master-plan-group-A.md §3.7.
|
|
* Hard Rule #13: no shell interpolation, file only.
|
|
*/
|
|
export function writeBypassJson(userPatterns?: string[]): void {
|
|
const dir = path.join(resolveMitmDataDir(), "mitm");
|
|
try {
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
} catch {
|
|
// mkdir failures are non-fatal; the write below will report the real error.
|
|
}
|
|
const patterns =
|
|
Array.isArray(userPatterns) && userPatterns.length >= 0
|
|
? userPatterns
|
|
: getUserBypassPatterns();
|
|
const payload = {
|
|
version: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
patterns,
|
|
};
|
|
fs.writeFileSync(BYPASS_JSON_FILE, JSON.stringify(payload, null, 2));
|
|
}
|
|
|
|
export interface AgentStatus {
|
|
id: AgentId;
|
|
name: string;
|
|
hosts: string[];
|
|
viability: "supported" | "investigating" | "deprecated";
|
|
detection: DetectionResult;
|
|
}
|
|
|
|
/**
|
|
* Aggregate every registered MITM target with its current installation
|
|
* detection result. Read-only — used by the AgentBridge dashboard.
|
|
*/
|
|
export function getAllAgentsStatus(): AgentStatus[] {
|
|
return ALL_TARGETS.map((t) => ({
|
|
id: t.id,
|
|
name: t.name,
|
|
hosts: t.hosts,
|
|
viability: t.viability ?? "supported",
|
|
detection: detectAgent(t.id),
|
|
}));
|
|
}
|
|
const MITM_SERVER_URL = new URL("./server.cjs", import.meta.url);
|
|
const urlPath =
|
|
process.platform === "win32" && MITM_SERVER_URL.pathname.startsWith("/")
|
|
? decodeURIComponent(MITM_SERVER_URL.pathname.slice(1))
|
|
: decodeURIComponent(MITM_SERVER_URL.pathname);
|
|
|
|
const cwdPath = path.join(process.cwd(), "src", "mitm", "server.cjs");
|
|
const MITM_SERVER_PATH = fs.existsSync(cwdPath) ? cwdPath : urlPath;
|
|
|
|
// Check if a PID is alive
|
|
function isProcessAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enumerate every hostname OmniRoute may have written to /etc/hosts during
|
|
* startMitm(): the full agent-target registry plus all custom hosts. Removal
|
|
* via removeDNSEntries() is idempotent (absent entries are skipped), so this
|
|
* set is intentionally over-inclusive — a host that was never spoofed costs
|
|
* nothing to "remove", but a host we forget to list leaks machine-wide.
|
|
* (Gap 8 — clean-stop DNS leak.)
|
|
*/
|
|
export function collectManagedHosts(): string[] {
|
|
const hosts = new Set<string>();
|
|
for (const target of ALL_TARGETS) {
|
|
for (const h of target.hosts) hosts.add(h);
|
|
}
|
|
try {
|
|
for (const ch of listCustomHosts()) hosts.add(ch.host);
|
|
} catch (err) {
|
|
log.error({ err }, "collectManagedHosts: failed to read custom hosts (continuing)");
|
|
}
|
|
return [...hosts];
|
|
}
|
|
|
|
export interface RepairPlan {
|
|
dnsHostsToRemove: string[];
|
|
removeCert: boolean;
|
|
revertSystemProxy: boolean;
|
|
}
|
|
|
|
/**
|
|
* Pure description of what a repair must undo. Separated from repairMitm() so
|
|
* the enumeration is unit-testable without touching the OS or requiring sudo.
|
|
* (Gap 7.)
|
|
*/
|
|
export function buildRepairPlan(): RepairPlan {
|
|
return {
|
|
dnsHostsToRemove: collectManagedHosts(),
|
|
removeCert: true,
|
|
revertSystemProxy: true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Best-effort revert of an applied system proxy. The applied state lives
|
|
* in-memory (captureState), so this only succeeds within the same process that
|
|
* applied it; after a crash the previousState is gone and this is a no-op. DNS
|
|
* + cert teardown are always reversible because they read on-disk state.
|
|
*/
|
|
async function revertSystemProxyIfApplied(): Promise<boolean> {
|
|
try {
|
|
const { getSystemProxyState, clearSystemProxy } = await import(
|
|
"@/lib/inspector/captureState"
|
|
);
|
|
const state = getSystemProxyState();
|
|
if (!state.applied || !state.previousState) return false;
|
|
const { revert } = await import("./inspector/systemProxyConfig.ts");
|
|
await revert(state.previousState);
|
|
clearSystemProxy();
|
|
return true;
|
|
} catch (err) {
|
|
log.error({ err }, "revertSystemProxyIfApplied failed (continuing)");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Undo every system mutation startMitm() may have made, WITHOUT requiring the
|
|
* MITM server to be running. Safe to call when state is already clean (every
|
|
* step is idempotent). Used by: the /repair route, the CLI cleanup subcommand,
|
|
* and the stale-PID auto-repair on app startup. (Gap 7 — the application-layer
|
|
* analogue of ProxyBridge's destructor + `--cleanup`.)
|
|
*/
|
|
export async function repairMitm(sudoPassword: string): Promise<{ repaired: string[] }> {
|
|
const plan = buildRepairPlan();
|
|
const repaired: string[] = [];
|
|
|
|
// 1. DNS — remove every host we may have spoofed (idempotent, reads /etc/hosts).
|
|
try {
|
|
await removeDNSEntry(sudoPassword);
|
|
if (plan.dnsHostsToRemove.length > 0) {
|
|
await removeDNSEntries(plan.dnsHostsToRemove, sudoPassword);
|
|
}
|
|
repaired.push("dns");
|
|
} catch (err) {
|
|
log.error({ err }, "repairMitm: DNS cleanup failed (continuing)");
|
|
}
|
|
|
|
// 2. Certificate — uninstall the MITM root CA from the trust store.
|
|
if (plan.removeCert) {
|
|
try {
|
|
const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
|
|
if (fs.existsSync(certPath)) {
|
|
await uninstallCert(sudoPassword, certPath);
|
|
repaired.push("cert");
|
|
}
|
|
} catch (err) {
|
|
log.error({ err }, "repairMitm: cert removal failed (continuing)");
|
|
}
|
|
}
|
|
|
|
// 3. System proxy — best-effort revert if applied in this process.
|
|
if (plan.revertSystemProxy) {
|
|
if (await revertSystemProxyIfApplied()) repaired.push("system-proxy");
|
|
}
|
|
|
|
// 4. Stale PID file.
|
|
try {
|
|
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
clearCachedPassword();
|
|
_orphanedStateDetected = false;
|
|
log.info({ repaired }, "repairMitm completed");
|
|
return { repaired };
|
|
}
|
|
|
|
/**
|
|
* Best-effort JS surrogate for ProxyBridge's library destructor + crash signal
|
|
* handler. On SIGINT/SIGTERM we terminate the spawned child and warn that
|
|
* privileged cleanup (DNS/CA/proxy) still requires a Repair — we have no sudo
|
|
* password in a signal handler. Idempotent; never blocks process exit. (Gap 7.)
|
|
*/
|
|
export function installCleanupHandlers(): void {
|
|
if (_cleanupHandlersInstalled) return;
|
|
_cleanupHandlersInstalled = true;
|
|
const onSignal = (signal: string) => {
|
|
try {
|
|
if (serverProcess && !serverProcess.killed) serverProcess.kill("SIGTERM");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
log.warn(
|
|
{ signal },
|
|
"MITM parent received signal — child terminated; run Repair if DNS/CA/proxy were applied."
|
|
);
|
|
};
|
|
process.once("SIGINT", () => onSignal("SIGINT"));
|
|
process.once("SIGTERM", () => onSignal("SIGTERM"));
|
|
}
|
|
|
|
/**
|
|
* Get MITM status
|
|
*/
|
|
export async function getMitmStatus(): Promise<{
|
|
running: boolean;
|
|
pid: number | null;
|
|
dnsConfigured: boolean;
|
|
certExists: boolean;
|
|
orphanedStateDetected: boolean;
|
|
}> {
|
|
// Check in-memory process first, then fallback to PID file
|
|
let running = serverProcess !== null && !serverProcess.killed;
|
|
let pid = serverPid;
|
|
|
|
if (!running) {
|
|
try {
|
|
if (fs.existsSync(PID_FILE)) {
|
|
const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10);
|
|
if (savedPid && isProcessAlive(savedPid)) {
|
|
running = true;
|
|
pid = savedPid;
|
|
} else {
|
|
// Stale PID file: the server died without clean teardown. We cannot
|
|
// run privileged cleanup here (no sudo password in a status read),
|
|
// so flag it for the dashboard to offer a one-click Repair. (Gap 7.)
|
|
fs.unlinkSync(PID_FILE);
|
|
_orphanedStateDetected = true;
|
|
log.warn("Stale MITM PID file found — system state may be orphaned (offer Repair).");
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}
|
|
|
|
// Check DNS configuration
|
|
let dnsConfigured = false;
|
|
try {
|
|
const hostsContent = fs.readFileSync("/etc/hosts", "utf-8");
|
|
dnsConfigured = /\bdaily-cloudcode-pa\.googleapis\.com\b/.test(hostsContent);
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
|
|
// Check cert
|
|
const certDir = path.join(resolveMitmDataDir(), "mitm");
|
|
const certExists = fs.existsSync(path.join(certDir, "server.crt"));
|
|
|
|
return {
|
|
running,
|
|
pid,
|
|
dnsConfigured,
|
|
certExists,
|
|
orphanedStateDetected: _orphanedStateDetected,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Start MITM proxy
|
|
* @param {string} apiKey - OmniRoute API key
|
|
* @param {string} sudoPassword - Sudo password for DNS/cert operations
|
|
*/
|
|
export async function startMitm(
|
|
apiKey: string,
|
|
sudoPassword: string,
|
|
options: { port?: number } = {}
|
|
): Promise<{ running: true; pid: number | null }> {
|
|
// Check if already running
|
|
if (serverProcess && !serverProcess.killed) {
|
|
throw new Error("MITM proxy is already running");
|
|
}
|
|
|
|
// Register best-effort teardown on parent SIGINT/SIGTERM (Gap 7).
|
|
installCleanupHandlers();
|
|
|
|
// 0. Persist the canonical targets.json so server.cjs can pick up the full
|
|
// AgentBridge target registry alongside its hard-coded antigravity baseline.
|
|
try {
|
|
writeTargetsJson();
|
|
} catch (err) {
|
|
log.error({ err }, "Failed to write targets.json (continuing)");
|
|
}
|
|
|
|
// 0b. Persist the user bypass patterns to bypass.json so server.cjs can
|
|
// route CONNECT tunnels for those hostnames without TLS decryption.
|
|
// Defaults (banks/gov/okta/auth0) are hard-coded in server.cjs.
|
|
try {
|
|
writeBypassJson();
|
|
} catch (err) {
|
|
log.error({ err }, "Failed to write bypass.json (continuing)");
|
|
}
|
|
|
|
// 0c. Apply upstream CA certificate (env var wins over stored path).
|
|
// Spec: plan 11 §4.7 + acceptance criterion §12 #18.
|
|
try {
|
|
const storedCaPath = readStoredUpstreamCaPath();
|
|
const activeCaPath = process.env.AGENTBRIDGE_UPSTREAM_CA_CERT || storedCaPath;
|
|
if (activeCaPath) {
|
|
configureUpstreamCa(activeCaPath);
|
|
log.info({ caPath: activeCaPath }, "Upstream CA certificate configured");
|
|
}
|
|
} catch (err) {
|
|
log.error(
|
|
{ err },
|
|
`AGENTBRIDGE_UPSTREAM_CA_CERT path invalid: ${(err as Error).message ?? err} (continuing without custom CA)`
|
|
);
|
|
}
|
|
|
|
// 1. Generate SSL certificate if not exists
|
|
const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
|
|
if (!fs.existsSync(certPath)) {
|
|
log.info("Generating SSL certificate...");
|
|
await generateCert();
|
|
}
|
|
|
|
// 2. Install certificate to system keychain
|
|
await installCert(sudoPassword, certPath);
|
|
|
|
// 3. Add DNS entries: Antigravity defaults + all agents with dns_enabled=true +
|
|
// all custom hosts with enabled=true.
|
|
log.info("Adding DNS entries...");
|
|
await addDNSEntry(sudoPassword);
|
|
|
|
// Collect hosts from agents that have dns_enabled=true in the DB.
|
|
try {
|
|
const agentStates = getAllAgentBridgeStates();
|
|
const agentHostsToAdd: string[] = [];
|
|
for (const state of agentStates) {
|
|
if (!state.dns_enabled) continue;
|
|
const target = ALL_TARGETS.find((t) => t.id === state.agent_id);
|
|
if (target) {
|
|
agentHostsToAdd.push(...target.hosts);
|
|
}
|
|
}
|
|
if (agentHostsToAdd.length > 0) {
|
|
log.info({ count: agentHostsToAdd.length }, "Adding DNS for agent host(s)...");
|
|
await addDNSEntries(agentHostsToAdd, sudoPassword);
|
|
}
|
|
} catch (err) {
|
|
log.error({ err }, "Failed to add agent DNS entries (continuing)");
|
|
}
|
|
|
|
// Collect enabled custom hosts.
|
|
try {
|
|
const customHosts = listCustomHosts({ enabledOnly: true });
|
|
const customHostNames = customHosts.map((h) => h.host);
|
|
if (customHostNames.length > 0) {
|
|
log.info({ count: customHostNames.length }, "Adding DNS for custom host(s)...");
|
|
await addDNSEntries(customHostNames, sudoPassword);
|
|
}
|
|
} catch (err) {
|
|
log.error({ err }, "Failed to add custom host DNS entries (continuing)");
|
|
}
|
|
|
|
// 4. Start MITM server
|
|
log.info("Starting MITM server...");
|
|
const port =
|
|
typeof options.port === "number" &&
|
|
Number.isInteger(options.port) &&
|
|
options.port > 0 &&
|
|
options.port <= 65535
|
|
? options.port
|
|
: 443;
|
|
serverProcess = spawn(process.execPath, [MITM_SERVER_PATH], {
|
|
env: {
|
|
...process.env,
|
|
ROUTER_API_KEY: apiKey,
|
|
MITM_LOCAL_PORT: String(port),
|
|
NODE_ENV: "production",
|
|
},
|
|
detached: false,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
const proc = serverProcess;
|
|
serverPid = proc.pid ?? null;
|
|
|
|
// Save PID to file
|
|
if (serverPid !== null) {
|
|
fs.writeFileSync(PID_FILE, String(serverPid));
|
|
}
|
|
|
|
// Buffer recent stderr so a startup failure can be reported with its real
|
|
// cause (capped to avoid unbounded growth on a chatty/looping process). (#3606)
|
|
let stderrBuffer = "";
|
|
|
|
// Log server output
|
|
proc.stdout?.on("data", (data) => {
|
|
log.info({ source: "mitm-server" }, data.toString().trim());
|
|
});
|
|
|
|
proc.stderr?.on("data", (data) => {
|
|
const chunk = data.toString();
|
|
stderrBuffer = (stderrBuffer + chunk).slice(-4000);
|
|
log.error({ source: "mitm-server" }, chunk.trim());
|
|
});
|
|
|
|
proc.on("exit", (code) => {
|
|
log.info({ exitCode: code }, "MITM server exited");
|
|
serverProcess = null;
|
|
serverPid = null;
|
|
|
|
// Remove PID file
|
|
try {
|
|
fs.unlinkSync(PID_FILE);
|
|
} catch (error) {
|
|
// Ignore
|
|
}
|
|
});
|
|
|
|
// Wait and verify server actually started
|
|
const started = await new Promise<boolean>((resolve) => {
|
|
let resolved = false;
|
|
const timeout = setTimeout(() => {
|
|
if (!resolved) {
|
|
resolved = true;
|
|
resolve(true);
|
|
}
|
|
}, 2000);
|
|
|
|
proc.on("exit", () => {
|
|
clearTimeout(timeout);
|
|
if (!resolved) {
|
|
resolved = true;
|
|
resolve(false);
|
|
}
|
|
});
|
|
|
|
// Fail fast on any "❌" diagnostic line from server.cjs (covers EADDRINUSE,
|
|
// EACCES, missing ROUTER_API_KEY, and any other server.on("error") cause).
|
|
proc.stderr?.on("data", (data) => {
|
|
const msg = data.toString();
|
|
if (msg.includes("❌")) {
|
|
clearTimeout(timeout);
|
|
if (!resolved) {
|
|
resolved = true;
|
|
resolve(false);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
if (!started) {
|
|
throw new Error(interpretMitmStartupError(stderrBuffer, port));
|
|
}
|
|
|
|
return {
|
|
running: true,
|
|
pid: serverPid,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Stop MITM proxy
|
|
* @param {string} sudoPassword - Sudo password for DNS cleanup
|
|
*/
|
|
export async function stopMitm(sudoPassword: string): Promise<{ running: false; pid: null }> {
|
|
// 1. Kill server process (in-memory or from PID file)
|
|
const proc = serverProcess;
|
|
if (proc && !proc.killed) {
|
|
log.info("Stopping MITM server...");
|
|
proc.kill("SIGTERM");
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
if (!proc.killed) {
|
|
proc.kill("SIGKILL");
|
|
}
|
|
serverProcess = null;
|
|
serverPid = null;
|
|
} else {
|
|
// Fallback: kill by PID file
|
|
try {
|
|
if (fs.existsSync(PID_FILE)) {
|
|
const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10);
|
|
if (savedPid && isProcessAlive(savedPid)) {
|
|
log.info({ pid: savedPid }, "Killing MITM server by PID...");
|
|
process.kill(savedPid, "SIGTERM");
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
if (isProcessAlive(savedPid)) {
|
|
process.kill(savedPid, "SIGKILL");
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
serverProcess = null;
|
|
serverPid = null;
|
|
}
|
|
|
|
// 2. Remove DNS entries — Antigravity defaults PLUS every agent + custom host
|
|
// that startMitm() may have spoofed. removeDNSEntries is idempotent, so
|
|
// over-inclusion is safe; under-inclusion leaks /etc/hosts lines that
|
|
// hijack resolution machine-wide after stop (Gap 8).
|
|
log.info("Removing DNS entries...");
|
|
await removeDNSEntry(sudoPassword);
|
|
try {
|
|
const managed = collectManagedHosts();
|
|
if (managed.length > 0) {
|
|
await removeDNSEntries(managed, sudoPassword);
|
|
}
|
|
} catch (err) {
|
|
log.error({ err }, "Failed to remove managed DNS entries during stop (continuing)");
|
|
}
|
|
|
|
// 3. Clean up
|
|
clearCachedPassword(); // Clear password from memory when proxy stops
|
|
try {
|
|
fs.unlinkSync(PID_FILE);
|
|
} catch (error) {
|
|
// Ignore
|
|
}
|
|
|
|
return {
|
|
running: false,
|
|
pid: null,
|
|
};
|
|
}
|