mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 15:22:12 +03:00
* chore(release): open v3.8.35 development cycle
* fix db vacuum scheduler settings (#4726)
Scheduled VACUUM now follows Storage page settings (scheduledVacuum/vacuumHour) as single source of truth; env-flag control path removed. 11/11 vacuum-scheduler tests pass against release/v3.8.35 tip; no orphaned env refs. Integrated into release/v3.8.35.
* fix(tier): noAuth providers count as free; free filter returns empty … (#4753)
noAuth providers now classified free (union of legacy list + NOAUTH_PROVIDERS chat-tier derivation), -free arena_elo alias, and auto/<cat>:free returns an empty pool when no free candidate matches (opt-in legacy fallback via OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL). New env var documented in .env.example + ENVIRONMENT.md; CHANGELOG bullet added (maintainer co-author). 46/46 node + 56/56 vitest tests pass on release tip; env-doc-sync, docs-sync, typecheck:core, lint, file-size all green. Integrated into release/v3.8.35.
* refactor(chatCore): extrai 11 helpers de nível superior para 6 leaves puros (#3501) (#4571)
chatCore god-file decomposition (#3501): extract 6 pure leaves (cacheUsageMeta, executorClientHeaders, nonStreamingResponseBody, skillsFormat, streamErrorResult, streamFinalize) from chatCore.ts. Rebased onto release/v3.8.35 tip (resolved single chatCore.ts conflict — removed now-extracted inline buildExecutorClientHeaders). 265/265 chatcore tests, 26/26 new leaf tests, typecheck:core, cycles, file-size all green. Integrated into release/v3.8.35.
* refactor(chatCore): extrai resolveExecutorWithProxy + getExecutionCredentials para leaves (#3501) (#4646)
chatCore #3501: extract resolveExecutorWithProxy + getExecutionCredentials to leaves (executorProxy.ts, executionCredentials.ts). Clean cherry-pick onto release tip post-#4571. 12/12 new leaf tests, typecheck:core, cycles, file-size green. Integrated into release/v3.8.35.
* refactor(chatCore): extrai transforms de mensagens Claude p/ leaf (#3501) (#4708)
chatCore #3501: extract Claude upstream-message transforms to leaf (claudeUpstreamMessages.ts + claudeMessageTypes.ts). Clean cherry-pick post-#4646. 8/8 new leaf tests, typecheck/cycles/file-size green. Integrated into release/v3.8.35.
* refactor(chatCore): extrai persistAttemptLogs para leaf (#3501) (#4717)
chatCore #3501: extract persistAttemptLogs to leaf (attemptLogging.ts). Rebased onto release tip post-#4708 (resolved imports conflict: kept tip's resolveCompressionHeader from compression Phase 3, dropped now-unused logTruncation import moved into the leaf). 288/288 chatcore tests, typecheck/cycles/file-size green. Integrated into release/v3.8.35.
* refactor(chatCore): extrai stageTrace + compressionUsageReceipt para leaves (#3501) (#4721)
chatCore #3501: extract stageTrace + compressionUsageReceipt to leaves. Clean cherry-pick post-#4717. 6/6 new leaf tests, typecheck/cycles/file-size green. Integrated into release/v3.8.35.
* refactor(chatCore): extrai prepareUpstreamBody (1ª sub-fatia do executeProviderRequest, #3501) (#4730)
chatCore #3501: extract prepareUpstreamBody (first sub-slice of executeProviderRequest) to leaf (upstreamBody.ts). Clean cherry-pick post-#4721. 7/7 new leaf tests, full 301/301 chatcore suite, typecheck/cycles/file-size green. Completes the 6-PR chatCore decomposition stack into release/v3.8.35.
* fix(db): make db-backup import size cap configurable (#4719) (#4757)
Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
* chore(quality): expand check:release-green to the FULL release-PR gate set (#4758)
The release-green pre-flight (Solution C) previously covered only a subset of the
gates that run exclusively on the release PR (PR→main), so reds still accrued
silently on release/** and surfaced in ~40-min layers at release time (v3.8.34:
3 CI rounds — CodeQL sanitization, then the fail-fast Quality Ratchet revealing
openapi then cyclomatic-complexity one push at a time, plus zizmor/integration).
Now check:release-green reproduces the COMPLETE release-PR gate set and reports
EVERY red in one pass (collected, not fail-fast):
- New DRIFT ratchets (report-only, rebaselined at release, never block):
cyclomatic complexity, dead-code, type-coverage, compression-budget,
openapi-coverage, workflow-lint (zizmor), codeql-ratchet.
- New HARD gates (real defects): docs-all (fabricated-docs strict + i18n mirror
sync) and the integration test suite (gated behind !--quick).
The only release-PR gates it still cannot reproduce locally are GitHub-side CodeQL
semantic analysis and SonarQube/SonarCloud (external services).
The nightly-release-green workflow and /green-prs inherit the expanded coverage
automatically (they invoke this script), so cycle drift is now surfaced
continuously and the release PR is green on its first CI run.
Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
* fix(dashboard): add missing onboarding.tiers step title (#4698) (#4755)
Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
* feat(compression): Output Styles registry + D0 telemetry (Phase 4A) (#4694)
Phase 4A: Output Styles registry + D0 telemetry. Integrated into release/v3.8.35.
* feat(compression): SLM tier for ultra (Phase 4B) [stacked on #4694] (#4707)
Phase 4B: SLM tier for ultra. Integrated into release/v3.8.35.
* feat(compression): context-budget adaptive compression (Phase 4C) [stacked on #4707] (#4716)
Phase 4C: adaptive context-budget compression. Integrated into release/v3.8.35.
* feat(compression): offline evaluation harness (Phase 4 D1) [stacked on #4716] (#4720)
Phase 4 D1: offline evaluation harness. Integrated into release/v3.8.35.
* fix(sse): deepseek-web folds role:tool results into prompt transcript (#4712) (#4756)
Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
* fix(dashboard): remove dead unconditional useLiveRequests call in HomePageClient (#4759, #4745, #4596) (#4761)
Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
* fix(dashboard): dedupe provider nodes by id on compatible-provider add (#4746) (#4768)
Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
* chore(db): re-export compressionRunTelemetry from localDb to satisfy db-rules (#4775)
Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
* docs(security): add canonical STRIDE-based threat model (#4783)
Canonical STRIDE threat model. Integrated into release/v3.8.35.
* test(dashboard): add smoke test for home client dashboard (#4793)
Smoke test guarding the dashboard home client render (regression #4745/#4759). Code fix already landed via #4761; this PR's jsdom smoke test is the net-new regression guard. Integrated into release/v3.8.35.
* fix(combos): auto-promote zeroLatencyOptimizationsEnabled so legacy configs (pre-3.8.33 fallbackCompressionMode="lite") round-trip on the first GUI edit (#4774)
Auto-promote zeroLatencyOptimizationsEnabled + strip v3.8.31-era removed keys so legacy combo configs round-trip through PUT /api/combos/{id} on first GUI edit (closes #4382 followup). Pre-merge: rewrote the now-stale reject test to assert auto-promotion + added passthrough/round-trip regression guards; reconciled combos/page.tsx file-size baseline. Integrated into release/v3.8.35.
* refactor(chatCore): extrai parse + usage-stats não-streaming do executeProviderRequest (#3501) (#4762)
chatCore #3501: extract parseNonStreamingResponseBody + recordNonStreamingUsageStats. Integrated into release/v3.8.35.
* refactor(chatCore): extrai recordContextEditingTelemetryHook (#3501) (#4779)
chatCore #3501: extract recordContextEditingTelemetryHook. Integrated into release/v3.8.35.
* refactor(chatCore): extrai recordCompressionCacheStats (#3501) (#4792)
chatCore #3501: extract recordCompressionCacheStats. Integrated into release/v3.8.35.
* refactor(chatCore): extrai writeCavemanOutputAnalytics (#3501) (#4794)
chatCore #3501: extract writeCavemanOutputAnalytics. Integrated into release/v3.8.35.
* refactor(chatCore): extrai scheduleQuotaShareConsumption (POST-hook não-streaming, #3501) (#4780)
chatCore #3501: extract scheduleQuotaShareConsumption (non-streaming POST-hook). Integrated into release/v3.8.35.
* refactor(chatCore): extrai emitRequestGamificationEvent (helper compartilhado DRY, #3501) (#4776)
chatCore #3501: extract emitRequestGamificationEvent (DRY streaming/non-streaming). Integrated into release/v3.8.35.
* refactor(chatCore): extrai runPluginOnResponseHook (#3501) (#4782)
chatCore #3501: extract runPluginOnResponseHook. Integrated into release/v3.8.35.
* refactor(chatCore): extrai scheduleStreamingQuotaShareConsumption (POST-hook streaming, #3501) (#4784)
chatCore #3501: extract scheduleStreamingQuotaShareConsumption (streaming POST-hook). Integrated into release/v3.8.35.
* refactor(chatCore): extrai recordStreamingUsageStats (analytics de usage streaming, #3501) (#4791)
chatCore #3501: extract recordStreamingUsageStats. Integrated into release/v3.8.35.
* refactor(chatCore): extrai recordStreamingCost (custo por-request streaming, #3501) (#4790)
chatCore #3501: extract recordStreamingCost (per-request streaming cost). Integrated into release/v3.8.35.
* docs(readme): credit ponytail + OmniCompress; restore env-doc-sync release-green (#4799)
README compression credits (ponytail/OmniCompress) + env-doc-sync ignore for eval-only OMNIROUTE_EVAL_CREDENTIALS (restores release-green after #4720). Integrated into release/v3.8.35.
* chore(quality): trim combo-config.test.ts comments under file-size cap (#4774 follow-up) (#4800)
Restore file-size release-green. Integrated into release/v3.8.35.
* feat(api-docs): Redoc-rendered /api/docs + consolidate OpenAPI spec to docs/openapi.yaml (#4781)
Redoc /api/docs + OpenAPI spec consolidated to docs/openapi.yaml (canonical 201-path complete spec; old path → legacy fallback). All refs/gates/tests/CI updated. Integrated into release/v3.8.35.
* docs(compression): declare Phase 4 layers — Output Styles, adaptive dial, per-request control (#4801)
The README compression section listed the 9 input engines but not the Phase 4
layers now in production:
- Output Styles (output-axis steering: terse-prose / less-code / terse-cjk, lite/full/ultra)
- adaptive context-budget dial (reserve-output|percentage|absolute · floor|replace-autotrigger|off)
- per-request x-omniroute-compression precedence + the offline eval harness
Also bumped the highlights range to v3.8.35, expanded the compression feature bullet,
and marked the GUIDE's Phase 4 row Shipped (was 'Planned' — it's merged on v3.8.35).
Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(release): finalize v3.8.35 CHANGELOG + docs reconciliation
- CHANGELOG: complete 3.8.35 section (all 35 commits since v3.8.34,
contributor attribution: @rdself @megamen32 @KooshaPari @JxnLexn)
- docs(security): align THREAT_MODEL.md refs with real code
(routeGuard.ts, tokenLimits.ts, /api/monitoring/health) — fabricated-docs gate
- check:fabricated-docs: skip docs/superpowers/specs (dated research reports)
- i18n: sync 3.8.35 section into 41 CHANGELOG mirrors (docs-sync size gate)
- ratchet rebaseline: cyclomatic 1916->1920, eslintWarnings 3907->3912
(inherited cycle drift; release-finalize diff is docs-only)
* fix(release): resolve inherited base-reds surfaced by v3.8.35 release CI
Cycle base-reds that only run on PR→main (not the PR→release fast-path):
- test(autoCombo): suffixComposition-4517 used node:test in a vitest-only dir
(#4753) → vitest found no suite. Switch to the vitest API. (Vitest job)
- test(agentSkills): openapiParser fixture wrote docs/reference/openapi.yaml;
parser reads docs/openapi.yaml since #4781 → point fixture at the new path.
(Unit/Coverage/Node24/Node26 shard 4)
- test(integration): proxy-pipeline source-scan expected inline streaming-cost
code that #4790/#3501 extracted to the recordStreamingCost leaf → assert the
delegation instead. (Integration 1/2)
- fix(chatCore): derive the log trace id from crypto, not Math.random
(CodeQL js/insecure-randomness — log-correlation id, not a secret).
- test(resilience): circuit-breaker invalid-cooldown fallback asserted t>29000,
flaking on slow CI where ~1.6s elapsed gave t=28401 → tolerate wall-clock
drift (t>25000). (Unit 6/8)
* fix(usage): derive pending-request id from crypto, not Math.random
CodeQL js/insecure-randomness (#669): the pending-request id generated in
trackPendingRequest (usageHistory.ts) flows into attempt logging and was flagged
as insecure randomness in a security context. It's a log-correlation id, not a
secret — switch to crypto RNG to clear the alert. Pairs with the chatCore traceId
fix in 37c49781a (same sink).
---------
Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Demiurge The Single <megamen932@gmail.com>
Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com>
Co-authored-by: Jan Leon <Jan.gaschler@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
796 lines
34 KiB
Markdown
796 lines
34 KiB
Markdown
---
|
||
title: "Embedded Services"
|
||
description: "Reference for 9Router and CLIProxyAPI"
|
||
---
|
||
|
||
# Embedded Services
|
||
|
||
> **Version:** v3.8.4
|
||
> **Last updated:** 2026-05-25
|
||
> **Audience:** Engineers adding, maintaining, or debugging embedded services (9Router, CLIProxyAPI).
|
||
|
||
Embedded services are locally-installed process sidecar tools that OmniRoute installs, supervises, and
|
||
exposes as first-class routing targets. Unlike external providers (which are reached over the internet
|
||
via API keys), embedded services run on the same machine as OmniRoute and communicate over loopback.
|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
|
||
1. [Overview](#1-overview)
|
||
2. [Architecture — 4 layers](#2-architecture--4-layers)
|
||
3. [Lifecycle state machine](#3-lifecycle-state-machine)
|
||
4. [API reference](#4-api-reference)
|
||
5. [Security](#5-security)
|
||
6. [Adding a new embedded service](#6-adding-a-new-embedded-service)
|
||
7. [Troubleshooting](#7-troubleshooting)
|
||
8. [FAQ](#8-faq)
|
||
|
||
---
|
||
|
||
## 1. Overview
|
||
|
||
### Why embedded services?
|
||
|
||
Two services are embedded as of v3.8.4:
|
||
|
||
| Service | npm package | Default port | Purpose |
|
||
| --------------- | ---------------------------------------------- | :----------: | ---------------------------------------------------------------------------------------------------- |
|
||
| **9Router** | `9router` | 20130 | AI router that OmniRoute can use as a sub-provider. Models exposed as `9router/{sub}/{model}` |
|
||
| **CLIProxyAPI** | `@anthropic/cli-proxy` (via `cliproxy` binary) | auto | Local proxy adapter for Anthropic CLI auth flows. Provides fallback routing when OAuth tokens expire |
|
||
|
||
Both follow the same supervisory model:
|
||
|
||
- OmniRoute installs them under `DATA_DIR/services/{name}/` (isolated from OmniRoute's own `package.json`)
|
||
- OmniRoute spawns and monitors them as child processes
|
||
- OmniRoute injects an ephemeral API key into the child's environment and rotates it without downtime
|
||
- All management routes (`/api/services/*`) are **LOCAL_ONLY** — accessible only from loopback (hard rule #17)
|
||
|
||
### Key decisions (from design plan)
|
||
|
||
| Decision | Value |
|
||
| ------------------------------------- | ------------------------------------------------------------------------ |
|
||
| Dashboard access to 9Router native UI | Reverse proxy at `/dashboard/providers/services/9router/embed/*` |
|
||
| Installation mechanism | `npm install {package}` via `execFile` (no shell interpolation) |
|
||
| Consumption mode | Provider registered as `9router/{sub}/{model}` in routing engine |
|
||
| API key management | OmniRoute generates, encrypts at-rest (AES-256-GCM), and injects via env |
|
||
| Dashboard location | `/dashboard/providers/services` (two tabs) |
|
||
| Auto-start | Toggle per service, default OFF |
|
||
|
||
---
|
||
|
||
## 2. Architecture — 4 layers
|
||
|
||
```
|
||
┌────────────────────────────────────────────────────────────────────┐
|
||
│ Layer 1 — UI │
|
||
│ /dashboard/providers/services (tabs: CLIProxyAPI | 9Router) │
|
||
│ Logs live (SSE), Start/Stop/Restart/Update, Settings, Install │
|
||
│ │
|
||
│ src/app/(dashboard)/dashboard/providers/services/ │
|
||
│ ├── page.tsx Shell + tab routing by ?tab= │
|
||
│ ├── tabs/ CliproxyServiceTab, NinerouterServiceTab│
|
||
│ └── components/ ServiceStatusCard, ServiceLifecycleButtons,│
|
||
│ ServiceLogsPanel, ApiKeyCard, ... │
|
||
└──────────────────────┬─────────────────────────────────────────────┘
|
||
│ HTTP (Next.js fetch)
|
||
┌──────────────────────▼─────────────────────────────────────────────┐
|
||
│ Layer 2 — API (LOCAL_ONLY — loopback only) │
|
||
│ │
|
||
│ /api/services/9router/{install|start|stop|restart|update| │
|
||
│ rotate-key|status|auto-start|logs} │
|
||
│ /api/services/cliproxy/{install|start|stop|restart|update| │
|
||
│ status|auto-start|logs} │
|
||
│ /dashboard/providers/services/9router/embed/[...path] │
|
||
│ (reverse HTTP + WebSocket proxy → 9Router upstream) │
|
||
│ │
|
||
│ Gate: LOCAL_ONLY_API_PREFIXES includes "/api/services/" and │
|
||
│ "/dashboard/providers/services/*/embed/" │
|
||
└──────────────────────┬─────────────────────────────────────────────┘
|
||
│ in-process calls
|
||
┌──────────────────────▼─────────────────────────────────────────────┐
|
||
│ Layer 3 — ServiceSupervisor (src/lib/services/) │
|
||
│ │
|
||
│ ServiceSupervisor.ts Generic supervisor (child_process.spawn) │
|
||
│ ├── install: execFile('npm', ['install', pkg, '--prefix']) │
|
||
│ ├── start: spawn(node, [entrypoint], {env, cwd}) │
|
||
│ ├── api_key: crypto.randomBytes(32) → env NINEROUTER_API_KEY │
|
||
│ ├── port: 20130 for 9Router (configurable) │
|
||
│ ├── logs: stdio ring buffer 5 MB → SSE events │
|
||
│ ├── health: HTTP GET /health every 2–5 s, lazy recovery │
|
||
│ └── lifecycle: SIGTERM 15 s → SIGKILL │
|
||
│ │
|
||
│ registry.ts getSupervisor(name) / registerSupervisor() │
|
||
│ bootstrap.ts Bootstraps all SERVICES[] at process start │
|
||
│ apiKey.ts getOrCreateApiKey(), generateServiceApiKey() │
|
||
│ modelSync.ts Periodic GET /v1/models → service_models table │
|
||
│ ringBuffer.ts Circular log buffer (5 MB per service) │
|
||
│ healthCheck.ts Polling HTTP health probe │
|
||
│ installers/ ninerouter.ts, cliproxy.ts (installer adapters)│
|
||
└──────────────────────┬─────────────────────────────────────────────┘
|
||
│ OpenAI-compatible HTTP (loopback)
|
||
┌──────────────────────▼─────────────────────────────────────────────┐
|
||
│ Layer 4 — Provider / Routing │
|
||
│ │
|
||
│ open-sse/executors/ninerouter.ts │
|
||
│ Re-looks up port and API key per-request (no caching). │
|
||
│ Strips "9router/" prefix from model id before proxying. │
|
||
│ Returns 503 service_not_running if supervisor not in "running". │
|
||
│ │
|
||
│ src/shared/constants/providers.ts │
|
||
│ Entry for "9router": isEmbeddedService: true │
|
||
│ │
|
||
│ open-sse/config/providerRegistry.ts │
|
||
│ Models stored as "9router/{sub}/{model}" (prefixed). │
|
||
│ Synced every 5 min by modelSync.ts. │
|
||
└────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### Key source files
|
||
|
||
| File | Role |
|
||
| ------------------------------------------- | ------------------------------------------------ |
|
||
| `src/lib/services/ServiceSupervisor.ts` | Core class: lifecycle, lock, health, ring buffer |
|
||
| `src/lib/services/bootstrap.ts` | Process-level registration and auto-start |
|
||
| `src/lib/services/registry.ts` | Singleton map `tool → supervisor` |
|
||
| `src/lib/services/apiKey.ts` | Key generation, AES-256-GCM encryption at-rest |
|
||
| `src/lib/services/modelSync.ts` | Periodic model sync (5 min) + on-demand |
|
||
| `src/lib/services/ringBuffer.ts` | 5 MB circular log buffer with SSE subscribe |
|
||
| `src/lib/services/healthCheck.ts` | HTTP health probe (configurable interval) |
|
||
| `src/lib/services/installers/ninerouter.ts` | npm install/update/uninstall for 9Router |
|
||
| `src/lib/services/installers/cliproxy.ts` | npm install/update/uninstall for CLIProxyAPI |
|
||
| `src/app/api/services/9router/_lib.ts` | `getOrInitSupervisor()` helper |
|
||
| `src/app/api/services/[name]/logs/route.ts` | Shared SSE logs endpoint |
|
||
| `open-sse/executors/ninerouter.ts` | Provider executor (Layer 4) |
|
||
|
||
---
|
||
|
||
## 3. Lifecycle state machine
|
||
|
||
```
|
||
install()
|
||
┌─────────────┐ ──────────► ┌─────────────┐
|
||
│ not_installed│ │ stopped │◄──────────────────┐
|
||
└─────────────┘ └──────┬──────┘ │
|
||
│ start() │
|
||
▼ │ stop()
|
||
┌──────────┐ │
|
||
│ starting │ │
|
||
└────┬─────┘ │
|
||
health probe ok │ crash / SIGTERM │
|
||
┌────▼─────┐ (exit within 5s) │
|
||
│ running │──── crash ──────────►┤
|
||
└────┬─────┘ ┌─▼────┐
|
||
stop() │ │error │
|
||
▼ └──────┘
|
||
┌──────────┐
|
||
│ stopping │
|
||
└──────────┘
|
||
```
|
||
|
||
States stored in the `version_manager` DB table (`status` column) and mirrored
|
||
in `ServiceSupervisor` in-memory state. The in-memory state is authoritative for
|
||
a running process; the DB state is the durable fallback at boot.
|
||
|
||
### State transitions
|
||
|
||
| From | Event | To |
|
||
| --------------- | ---------------------------------- | ---------------------- |
|
||
| `not_installed` | `install()` succeeds | `stopped` |
|
||
| `stopped` | `start()` called | `starting` |
|
||
| `starting` | health probe returns 200 | `running` |
|
||
| `starting` | process exits before healthy | `error` |
|
||
| `running` | `stop()` called | `stopping` → `stopped` |
|
||
| `running` | process exits unexpectedly (< 5 s) | `error` (fast crash) |
|
||
| `running` | process exits unexpectedly (> 5 s) | `error` |
|
||
| `error` | `start()` called | `starting` |
|
||
| any | `stop()` while `stopping` | no-op |
|
||
|
||
### Operation lock
|
||
|
||
`ServiceSupervisor` serializes lifecycle operations through an async operation lock
|
||
(`withLock()`). Concurrent `start()` calls on the same supervisor result in exactly
|
||
one spawn; the second caller waits and returns the existing status. This prevents
|
||
race conditions when, for example, auto-start and a UI button fire simultaneously.
|
||
|
||
---
|
||
|
||
## 4. API reference
|
||
|
||
All routes under `/api/services/` are **LOCAL_ONLY** (loopback only, hard rule #17).
|
||
Non-loopback requests receive `403 LOCAL_ONLY` regardless of auth token.
|
||
|
||
### 4.1 9Router endpoints (8 routes)
|
||
|
||
#### `POST /api/services/9router/install`
|
||
|
||
Install 9Router from npm. Creates `DATA_DIR/services/9router/` with its own
|
||
`package.json` and `node_modules/`. Does not conflict with OmniRoute's own deps.
|
||
|
||
**Request body** (all optional):
|
||
|
||
```json
|
||
{ "version": "latest" }
|
||
```
|
||
|
||
| Field | Type | Default | Description |
|
||
| --------- | -------- | ---------- | ------------------------------------ |
|
||
| `version` | `string` | `"latest"` | npm version tag or semver to install |
|
||
|
||
**Responses:**
|
||
|
||
| Status | Description |
|
||
| ------ | ------------------------------------------------------ |
|
||
| `200` | `{ ok: true, installedVersion: "x.y.z", path: "..." }` |
|
||
| `400` | Invalid request body (Zod validation failure) |
|
||
| `409` | Already installing (lock held) |
|
||
| `500` | npm install failed — see `message` for friendly error |
|
||
|
||
**Notes:** Uses `execFile('npm', [...])` — no shell, no interpolation (hard rule #13).
|
||
EACCES errors are surfaced as friendly messages.
|
||
|
||
---
|
||
|
||
#### `POST /api/services/9router/start`
|
||
|
||
Start 9Router. Registers a supervisor if not already registered, then calls
|
||
`supervisor.start()`. Idempotent when already running.
|
||
|
||
**Request body:** none
|
||
|
||
**Responses:**
|
||
|
||
| Status | Description |
|
||
| ------ | ---------------------------------------------------- |
|
||
| `200` | `ServiceStatus` object (see schema below) |
|
||
| `409` | 9Router is not installed (`status: "not_installed"`) |
|
||
| `503` | Start failed (process error — see `lastError`) |
|
||
|
||
**ServiceStatus schema:**
|
||
|
||
```json
|
||
{
|
||
"tool": "9router",
|
||
"state": "running",
|
||
"pid": 12345,
|
||
"port": 20130,
|
||
"health": "healthy",
|
||
"startedAt": "2026-05-25T10:00:00.000Z",
|
||
"lastError": null
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
#### `POST /api/services/9router/stop`
|
||
|
||
Gracefully stop 9Router. Sends SIGTERM, waits 15 s, then SIGKILL if still alive.
|
||
Idempotent when already stopped.
|
||
|
||
**Request body:** none
|
||
|
||
**Responses:**
|
||
|
||
| Status | Description |
|
||
| ------ | ---------------------------------- |
|
||
| `200` | `ServiceStatus` (state: "stopped") |
|
||
| `503` | Stop failed unexpectedly |
|
||
|
||
---
|
||
|
||
#### `POST /api/services/9router/restart`
|
||
|
||
Equivalent to `stop()` then `start()` under the operation lock.
|
||
|
||
**Request body:** none
|
||
|
||
**Responses:** same as `start` (returns final `ServiceStatus`).
|
||
|
||
---
|
||
|
||
#### `POST /api/services/9router/update`
|
||
|
||
Updates 9Router to a newer npm version. If the service is running, it is stopped
|
||
first, npm install is run (installing the newer version in-place), and then the
|
||
service is restarted.
|
||
|
||
**Request body** (all optional):
|
||
|
||
```json
|
||
{ "version": "latest" }
|
||
```
|
||
|
||
**Responses:**
|
||
|
||
| Status | Description |
|
||
| ------ | --------------------------------------------------------------- |
|
||
| `200` | `{ ok: true, previousVersion: "...", installedVersion: "..." }` |
|
||
| `400` | Invalid body |
|
||
| `500` | npm update failed |
|
||
|
||
---
|
||
|
||
#### `POST /api/services/9router/rotate-key`
|
||
|
||
Generates a new API key for 9Router, encrypts it at-rest, and restarts the service
|
||
(if running) so it picks up the new key from its environment. The old key is
|
||
invalidated immediately.
|
||
|
||
**Request body:** none
|
||
|
||
**Responses:**
|
||
|
||
| Status | Description |
|
||
| ------ | ------------------------------------------ |
|
||
| `200` | `{ keyRotated: true, restarted: boolean }` |
|
||
| `500` | Rotation failed |
|
||
|
||
**Security:** The new key is never returned in the response (no credential leak).
|
||
It is stored encrypted (AES-256-GCM) in the `version_manager` table.
|
||
|
||
---
|
||
|
||
#### `GET /api/services/9router/status`
|
||
|
||
Returns combined live + DB status including version metadata and API key preview.
|
||
|
||
**Responses:**
|
||
|
||
| Status | Description |
|
||
| ------ | ------------------ |
|
||
| `200` | See schema below |
|
||
| `500` | Status read failed |
|
||
|
||
**Response schema:**
|
||
|
||
```json
|
||
{
|
||
"tool": "9router",
|
||
"state": "running",
|
||
"pid": 12345,
|
||
"port": 20130,
|
||
"health": "healthy",
|
||
"startedAt": "2026-05-25T10:00:00.000Z",
|
||
"lastError": null,
|
||
"installedVersion": "1.2.3",
|
||
"latestVersion": "1.2.4",
|
||
"updateAvailable": true,
|
||
"apiKeyMasked": "nr_****abcd",
|
||
"autoStart": false,
|
||
"providerExpose": false
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
#### `POST /api/services/9router/auto-start`
|
||
|
||
Toggle the auto-start flag. When `enabled: true`, the service starts automatically
|
||
the next time OmniRoute boots (if the service is installed).
|
||
|
||
**Request body:**
|
||
|
||
```json
|
||
{ "enabled": true }
|
||
```
|
||
|
||
**Responses:**
|
||
|
||
| Status | Description |
|
||
| ------ | --------------------- |
|
||
| `200` | `{ autoStart: true }` |
|
||
| `400` | Invalid body |
|
||
|
||
---
|
||
|
||
#### `GET /api/services/9router/logs`
|
||
|
||
SSE stream of live logs from 9Router's stdout/stderr ring buffer.
|
||
|
||
**Query parameters:**
|
||
|
||
| Param | Type | Default | Description |
|
||
| -------- | --------- | ------- | --------------------------------------------------------- |
|
||
| `tail` | `integer` | 200 | How many historical lines to send first (max 1000) |
|
||
| `filter` | `string` | none | Case-insensitive substring filter (no regex — ReDoS-safe) |
|
||
|
||
**SSE events:**
|
||
|
||
| Event | Data | Description |
|
||
| ----------- | ----------- | ----------------------- |
|
||
| `snapshot` | `LogLine[]` | Initial historical tail |
|
||
| `log` | `LogLine` | Live log line |
|
||
| `heartbeat` | `{}` | Keep-alive every 15 s |
|
||
|
||
**LogLine schema:**
|
||
|
||
```json
|
||
{ "ts": 1716633600000, "stream": "stdout", "line": "[9router] Listening on :20130" }
|
||
```
|
||
|
||
**Responses:**
|
||
|
||
| Status | Description |
|
||
| ------ | --------------------------------------------- |
|
||
| `200` | `text/event-stream` |
|
||
| `400` | `filter` parameter too long (> 200 chars) |
|
||
| `404` | Service not found (supervisor not registered) |
|
||
|
||
---
|
||
|
||
### 4.2 CLIProxyAPI endpoints (7 routes)
|
||
|
||
CLIProxyAPI has the same endpoint shape as 9Router minus `rotate-key` (CLIProxyAPI
|
||
does not require an injected API key; it authenticates via the host's existing CLI
|
||
config) and `status` includes fewer fields.
|
||
|
||
| Method | Path | Description |
|
||
| ------ | ----------------------------------- | ------------------------------------ |
|
||
| `POST` | `/api/services/cliproxy/install` | Install CLIProxyAPI from npm |
|
||
| `POST` | `/api/services/cliproxy/start` | Start CLIProxyAPI |
|
||
| `POST` | `/api/services/cliproxy/stop` | Stop CLIProxyAPI |
|
||
| `POST` | `/api/services/cliproxy/restart` | Restart CLIProxyAPI |
|
||
| `POST` | `/api/services/cliproxy/update` | Update to newer version |
|
||
| `GET` | `/api/services/cliproxy/status` | Live + DB status (no `apiKeyMasked`) |
|
||
| `POST` | `/api/services/cliproxy/auto-start` | Toggle auto-start |
|
||
|
||
The shared `GET /api/services/{name}/logs` endpoint (see §4.1) works for both
|
||
services using the `[name]` dynamic segment.
|
||
|
||
---
|
||
|
||
### 4.3 Reverse proxy (9Router dashboard embed)
|
||
|
||
The dashboard embeds the 9Router web UI inside an iframe via an internal reverse
|
||
proxy at:
|
||
|
||
```
|
||
GET|POST|... /dashboard/providers/services/9router/embed/[...path]
|
||
```
|
||
|
||
This proxy:
|
||
|
||
- Forwards the request to `http://127.0.0.1:{port}/{path}` (loopback only)
|
||
- Strips incoming `cookie` and `authorization` headers (no leakage of OmniRoute session)
|
||
- Injects `Authorization: Bearer {apiKey}` for 9Router authentication
|
||
- Strips `set-cookie`, `content-security-policy`, `x-frame-options`, `cross-origin-*` from the response
|
||
- Rewrites HTML responses to inject `<base href>` and normalize absolute paths (`/foo` → `/dashboard/.../embed/foo`)
|
||
|
||
WebSocket upgrades for the embedded dashboard are handled by a companion server on a
|
||
dedicated port (see `src/lib/services/embedWsProxy.ts`).
|
||
|
||
**Security:** The embed proxy routes are classified under `LOCAL_ONLY_API_PREFIXES`
|
||
and can only be reached from loopback. An attacker who obtains a JWT via a
|
||
Cloudflare/Ngrok tunnel cannot proxy into embedded services.
|
||
|
||
---
|
||
|
||
## 5. Security
|
||
|
||
### LOCAL_ONLY enforcement (hard rule #17)
|
||
|
||
All routes under `/api/services/` and `/dashboard/providers/services/*/embed/` are
|
||
classified as LOCAL_ONLY in `src/server/authz/routeGuard.ts`. The loopback check
|
||
runs unconditionally before any auth branch:
|
||
|
||
```
|
||
request arrives
|
||
→ isLocalOnlyPath(path)?
|
||
→ non-loopback → 403 LOCAL_ONLY (always, before auth check)
|
||
→ loopback → fall through to normal auth
|
||
```
|
||
|
||
This prevents a leaked JWT (e.g., via a tunnel) from triggering `npm install` or
|
||
process spawning. See `docs/security/ROUTE_GUARD_TIERS.md` for the full tier
|
||
matrix.
|
||
|
||
### API key injection
|
||
|
||
9Router requires an API key for its own HTTP endpoints. OmniRoute:
|
||
|
||
1. Generates a key via `crypto.randomBytes(32).toString("base64url")` with a
|
||
service-specific prefix (`nr_` for 9Router).
|
||
2. Encrypts it at-rest using AES-256-GCM (same cipher used for provider credentials).
|
||
3. Decrypts and injects it as `NINEROUTER_API_KEY` environment variable at spawn time.
|
||
4. Never returns the plaintext key in any HTTP response.
|
||
|
||
### SSRF defense
|
||
|
||
The reverse HTTP proxy (`/dashboard/.../embed/[...path]`) is hardcoded to forward
|
||
only to `http://127.0.0.1:{port}`. It never follows redirects to non-loopback
|
||
destinations. The `ssrf-req-filter` library is used to reject any upstream URL that
|
||
resolves outside the loopback range.
|
||
|
||
### Shell safety (hard rule #13)
|
||
|
||
`npm install` is invoked via `execFile('npm', ['install', pkg, '--prefix', dir])` —
|
||
no template literals, no shell, no interpolation of external paths into the command
|
||
string. Runtime values (ports, API keys) are passed via the child's `env` object.
|
||
|
||
### Error sanitization (hard rule #12)
|
||
|
||
All error responses from `/api/services/*` go through `buildErrorBody()` or
|
||
`sanitizeErrorMessage()`. Raw `err.stack` and `err.message` are never returned
|
||
verbatim to the caller.
|
||
|
||
---
|
||
|
||
## 6. Adding a new embedded service
|
||
|
||
Follow these 8 steps. Read the existing implementations in `src/lib/services/installers/`
|
||
and `src/app/api/services/` as the canonical reference.
|
||
|
||
### Step 1 — Create the installer
|
||
|
||
Create `src/lib/services/installers/{name}.ts` modeled on `ninerouter.ts`:
|
||
|
||
```typescript
|
||
export const NAME_PACKAGE = "your-npm-package";
|
||
export const NAME_DEFAULT_PORT = 20132; // pick a free port
|
||
|
||
export async function install(version = "latest"): Promise<InstallResult> { ... }
|
||
export async function update(version = "latest"): Promise<InstallResult> { ... }
|
||
export async function uninstall(): Promise<void> { ... }
|
||
export function resolveSpawnArgs(apiKey: string, port: number): SpawnArgs { ... }
|
||
export async function getInstalledVersion(): Promise<string | null> { ... }
|
||
export async function getLatestVersion(): Promise<string | null> { ... }
|
||
```
|
||
|
||
Use `runNpm(['install', NAME_PACKAGE, '--prefix', dir])` from `installers/utils.ts`
|
||
— never `execSync` or shell interpolation.
|
||
|
||
### Step 2 — Register in bootstrap
|
||
|
||
Add a `ServiceEntry` to the `SERVICES` array in `src/lib/services/bootstrap.ts`:
|
||
|
||
```typescript
|
||
{
|
||
tool: "myservice",
|
||
port: NAME_DEFAULT_PORT,
|
||
healthPath: "/health",
|
||
healthIntervalMs: 5_000,
|
||
stopTimeoutMs: 15_000,
|
||
logsBufferBytes: 5_242_880,
|
||
needsApiKey: true, // false if no API key needed
|
||
}
|
||
```
|
||
|
||
Extend `buildSpawnArgsFactory()` to handle `cfg.tool === "myservice"`.
|
||
|
||
### Step 3 — Add migration and DB seed
|
||
|
||
Ensure the service has a row in `version_manager` via a migration in
|
||
`src/lib/db/migrations/`. The row should have:
|
||
|
||
```sql
|
||
INSERT OR IGNORE INTO version_manager (tool, status, auto_start, provider_expose)
|
||
VALUES ('myservice', 'not_installed', 0, 0);
|
||
```
|
||
|
||
### Step 4 — Create the 7 API endpoints
|
||
|
||
Under `src/app/api/services/{name}/`:
|
||
|
||
```
|
||
_lib.ts getOrInitSupervisor() helper
|
||
install/route.ts POST — calls installer.install()
|
||
start/route.ts POST — calls supervisor.start()
|
||
stop/route.ts POST — calls supervisor.stop()
|
||
restart/route.ts POST — calls supervisor.restart()
|
||
update/route.ts POST — calls installer.update()
|
||
status/route.ts GET — merges live + DB status
|
||
auto-start/route.ts POST — toggles auto_start flag
|
||
```
|
||
|
||
The shared `GET /api/services/[name]/logs` route is already wired — no changes
|
||
needed there.
|
||
|
||
Delegate all error responses through `createErrorResponse()` / `buildErrorBody()`.
|
||
|
||
### Step 5 — Add to LOCAL_ONLY_API_PREFIXES
|
||
|
||
In `src/server/authz/routeGuard.ts`, verify that `/api/services/` is already listed.
|
||
If you introduce a new prefix (e.g., `/api/tools/`), add it to both
|
||
`LOCAL_ONLY_API_PREFIXES` and, if it spawns processes, to `SPAWN_CAPABLE_PREFIXES`.
|
||
Add a test in `tests/unit/authz/routeGuard.test.ts`.
|
||
|
||
### Step 6 — Add the UI tab
|
||
|
||
Create `src/app/(dashboard)/dashboard/providers/services/tabs/{Name}ServiceTab.tsx`.
|
||
Reuse shared components:
|
||
|
||
- `ServiceStatusCard` — live state + health badge
|
||
- `ServiceLifecycleButtons` — Start / Stop / Restart / Update
|
||
- `ServiceLogsPanel` — SSE log tail (connects to `/api/services/{name}/logs`)
|
||
- `ApiKeyCard` — key reveal + rotate (if `needsApiKey: true`)
|
||
|
||
Register the tab in `ServicesPageShell.tsx`.
|
||
|
||
### Step 7 — Add the provider entry (if the service is a routing target)
|
||
|
||
If the embedded service exposes an OpenAI-compatible `/v1/chat/completions` endpoint:
|
||
|
||
1. Add a provider entry in `src/shared/constants/providers.ts` with `isEmbeddedService: true`.
|
||
2. Create `open-sse/executors/{name}.ts` extending `BaseExecutor`. Re-lookup port and
|
||
API key per-request (never cache in the constructor). Return a `503 service_not_running`
|
||
response when the supervisor state is not `"running"`.
|
||
3. Register models in `open-sse/config/providerRegistry.ts` with the service prefix
|
||
(e.g., `myservice/sub/model`). `modelSync.ts` will keep them updated.
|
||
|
||
### Step 8 — Document and test
|
||
|
||
1. Update `docs/frameworks/EMBEDDED-SERVICES.md` (this file) — add the service to the
|
||
table in §1 and any new endpoints to §4.
|
||
2. Add unit tests in `tests/unit/services/` (lifecycle, installer, API shape).
|
||
3. Add integration test in `tests/integration/services/` (behind `RUN_SERVICES_INT=1`).
|
||
4. Update `docs/openapi.yaml` with the new endpoints.
|
||
|
||
---
|
||
|
||
## 7. Troubleshooting
|
||
|
||
### Service does not start
|
||
|
||
**Symptoms:** Start button returns 503, state stays `"error"` or `"starting"`.
|
||
|
||
**Checklist:**
|
||
|
||
1. Check `GET /api/services/{name}/logs` (or the Logs panel in the dashboard). Look
|
||
for lines like `Error: ENOENT`, `address already in use`, or `Cannot find module`.
|
||
2. Verify `npm` is in PATH: `which npm` from the same user account that runs OmniRoute.
|
||
3. Verify the service is installed: check `GET /api/services/{name}/status` for
|
||
`installedVersion`. If `null`, run install first.
|
||
4. Check `DATA_DIR/services/{name}/node_modules/` exists and is not empty.
|
||
5. Check the `lastError` field in the status response for the sanitized exit reason.
|
||
|
||
---
|
||
|
||
### Cold start is slow (> 10 s to reach `running`)
|
||
|
||
**Symptoms:** State stays `"starting"` for a long time before going to `"running"` or `"error"`.
|
||
|
||
**Explanation:** 9Router's cold start includes importing large dependency trees (DNS,
|
||
tunnel, MITM modules). Default health interval is 2 s with 3 attempts before the
|
||
supervisor declares a timeout (but continues polling).
|
||
|
||
**Fix:** The `healthIntervalMs` and the `waitForHealthy` timeout
|
||
(`healthIntervalMs * 3`) are configurable in `bootstrap.ts`. For services with longer
|
||
startup times, increase `healthIntervalMs` to 5000 and `stopTimeoutMs` to 30 000.
|
||
|
||
---
|
||
|
||
### Port collision (`EADDRINUSE`)
|
||
|
||
**Symptoms:** Logs show `address already in use :::20130`.
|
||
|
||
**Causes:**
|
||
|
||
- Another process is already using port 20130.
|
||
- A previous 9Router process was not fully stopped (zombie PID).
|
||
|
||
**Fix:**
|
||
|
||
1. Change the default port via `NINEROUTER_PORT` environment variable in `.env`.
|
||
2. Find and kill the conflicting process: `lsof -ti :20130 | xargs kill -9`.
|
||
3. The port is configurable per service in `bootstrap.ts` via the `port` field.
|
||
|
||
**Note:** 9Router defaults to port 20130 specifically to avoid colliding with
|
||
OmniRoute's default port 20128.
|
||
|
||
---
|
||
|
||
### Permission denied (EACCES) on install
|
||
|
||
**Symptoms:** Install returns 500, logs show `EACCES` or `permission denied`.
|
||
|
||
**Causes:**
|
||
|
||
- `DATA_DIR` or its parent is not writable by the OmniRoute process.
|
||
- Running inside Docker rootless without write access to the mapped volume.
|
||
|
||
**Fix:**
|
||
|
||
1. Check `DATA_DIR` (default: `~/.omniroute/`): `ls -la ~/.omniroute/`
|
||
2. Ensure the OmniRoute process user owns the directory: `chown -R $USER ~/.omniroute/`
|
||
3. In Docker, ensure the volume mount has the correct permissions for the container user.
|
||
|
||
---
|
||
|
||
### Update fails (`npm install` timeout or network error)
|
||
|
||
**Symptoms:** Update returns 500 with `InstallError`, logs show network timeout.
|
||
|
||
**Checklist:**
|
||
|
||
1. Confirm npm registry is reachable: `npm ping`.
|
||
2. Check for corporate proxy: `npm config get proxy`, `npm config get https-proxy`.
|
||
3. Try the install manually: `npm install {package}@latest --prefix ~/.omniroute/services/{name}/`.
|
||
4. If behind an air-gap, pre-download the tarball and use `npm install /path/to/tarball.tgz`.
|
||
|
||
---
|
||
|
||
### Service shows `"error"` state immediately after start (fast crash)
|
||
|
||
**Symptoms:** State transitions from `"starting"` to `"error"` in under 5 seconds.
|
||
`lastError` shows `"Fast crash (exited with code 1)"`.
|
||
|
||
**Checklist:**
|
||
|
||
1. Read the full log tail: `GET /api/services/{name}/logs?tail=500`.
|
||
2. Common cause: missing environment variables expected by the service.
|
||
3. For 9Router: verify `NINEROUTER_DISABLE_MITM=true` and
|
||
`NINEROUTER_DISABLE_TUNNEL=true` are in the env passed at spawn (see
|
||
`installers/ninerouter.ts` `resolveSpawnArgs`).
|
||
|
||
---
|
||
|
||
## 8. FAQ
|
||
|
||
**Q: Can I expose the embedded services endpoints to non-loopback clients?**
|
||
|
||
No. The LOCAL_ONLY tier is intentional (hard rule #17). Routes that can invoke
|
||
`npm install` or spawn `node` processes must not be reachable from non-loopback
|
||
traffic, because a leaked JWT via a tunnel (Cloudflare, Ngrok, Tailscale) would
|
||
otherwise allow arbitrary process spawning. There is no opt-out carve-out for
|
||
`/api/services/` — unlike `/api/mcp/`, it is excluded from the manage-scope bypass
|
||
list. See `docs/security/ROUTE_GUARD_TIERS.md`.
|
||
|
||
---
|
||
|
||
**Q: Will 9Router and CLIProxyAPI be available in production/cloud deployments?**
|
||
|
||
Yes. Both services follow the same local-first model as OmniRoute itself. They run
|
||
on the same machine and communicate over loopback. "Production" here means the VPS
|
||
or local server where OmniRoute is deployed, not a remote cloud provider.
|
||
|
||
---
|
||
|
||
**Q: How do I debug the supervisor?**
|
||
|
||
1. Tail the SSE log stream: `curl -N http://localhost:20128/api/services/9router/logs`.
|
||
2. Check structured logs in OmniRoute's pino output filtered by
|
||
`service:supervisor` namespace.
|
||
3. Inspect the DB row: `sqlite3 ~/.omniroute/omniroute.db "SELECT * FROM version_manager WHERE tool='9router'"`.
|
||
4. Use `GET /api/services/9router/status` to see the current live state, PID, health,
|
||
and `lastError` in one call.
|
||
|
||
---
|
||
|
||
**Q: The supervisor shows `health: "degraded"` or `health: "unknown"` but state is `"running"`. Is that a problem?**
|
||
|
||
`"degraded"` means the health probe returned a non-200 response. `"unknown"` means no
|
||
probe has completed yet (race with first poll). Both are transient during startup.
|
||
If health stays `"degraded"` for more than `healthIntervalMs * 3` ms after
|
||
`"running"`, the embedded service is running but its HTTP API is not responding. Check
|
||
whether the port is correct in the status response and whether the service is actually
|
||
listening on that port.
|
||
|
||
---
|
||
|
||
**Q: Can I change the 9Router API key without a full restart?**
|
||
|
||
No. The API key is passed to 9Router via an environment variable at spawn time.
|
||
Environment variables cannot be changed in a running process. `POST .../rotate-key`
|
||
automatically stops and restarts the service to apply the new key. The key rotation
|
||
takes effect within the service's `stopTimeoutMs` (default 15 s) plus its startup
|
||
time.
|
||
|
||
---
|
||
|
||
**Q: What is the ring buffer limit and what happens when it fills?**
|
||
|
||
Each service has a dedicated 5 MB ring buffer. When the buffer is full, the oldest
|
||
log lines are evicted to make room for new ones. The SSE `snapshot` event returns
|
||
the most recent lines within the `tail` limit. Logs are not persisted to disk unless
|
||
`logsBufferPath` is set in the DB row.
|
||
|
||
---
|
||
|
||
## See also
|
||
|
||
- `docs/security/ROUTE_GUARD_TIERS.md` — LOCAL_ONLY tier details
|
||
- `docs/architecture/CODEBASE_DOCUMENTATION.md` — §3.2 Embedded Services module mapping
|
||
- `docs/architecture/ARCHITECTURE.md` — system-level context
|
||
- `docs/openapi.yaml` — machine-readable endpoint definitions
|
||
- `CLAUDE.md` §"Adding a New Embedded Service" — quick-reference checklist
|