Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 902c7244d1 | |||
| 4f11762c68 | |||
| 8a7f7c1ba0 | |||
| af46f87eed | |||
| fd749d1e0b | |||
| 5046f90dfa | |||
| cf13e95610 | |||
| 5763609008 | |||
| 6d672ab09a | |||
| ac68022233 | |||
| c2b31f6b20 | |||
| 54b1d8c8de | |||
| cd1ab696b2 | |||
| d9d0640f6e | |||
| e19046116a | |||
| 82a621ec08 | |||
| ce560ebe9d | |||
| f900a81ec9 | |||
| 2a620b178d | |||
| 5aaaad529b |
@@ -6,61 +6,83 @@ description: Create a new release, bump version up to 1.x.10 threshold, update c
|
||||
|
||||
Bump version, finalize CHANGELOG, commit, tag, push, publish to npm, and create GitHub release.
|
||||
|
||||
> **VERSION RULE: Always use PATCH bumps (2.x.y → 2.x.y+1)**
|
||||
> NEVER use `npm version minor` or `npm version major`.
|
||||
> Always use: `npm version patch --no-git-tag-version`
|
||||
> The threshold rule: when `y` reaches 10, bump to `2.(x+1).0` — e.g. `2.1.10` → `2.2.0`.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Determine new version
|
||||
|
||||
Check current version in `package.json` and increment the patch number:
|
||||
Check current version in `package.json` and increment the **patch** number only:
|
||||
|
||||
```bash
|
||||
grep '"version"' package.json
|
||||
```
|
||||
|
||||
Version format: `1.x.y` — increment `y` for patch, `x` for minor (threshold: y=10 triggers x+1).
|
||||
Version format: `2.x.y` — examples:
|
||||
|
||||
### 2. Finalize CHANGELOG.md
|
||||
|
||||
Replace `[Unreleased]` header with the new version and date:
|
||||
|
||||
```markdown
|
||||
## [1.x.y] — YYYY-MM-DD
|
||||
```
|
||||
|
||||
### 3. Bump version in package.json
|
||||
- `2.1.2` → `2.1.3` (patch)
|
||||
- `2.1.9` → `2.1.10` (patch)
|
||||
- `2.1.10` → `2.2.0` (minor threshold — do manually with `sed`)
|
||||
|
||||
```bash
|
||||
sed -i 's/"version": "OLD"/"version": "NEW"/' package.json
|
||||
# ALWAYS use patch:
|
||||
npm version patch --no-git-tag-version
|
||||
```
|
||||
|
||||
### 4. Stage, commit, and tag
|
||||
### 2. Regenerate lock file (REQUIRED after version bump)
|
||||
|
||||
**Mandatory** — skipping causes `@swc/helpers` lock mismatch and CI failures:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Finalize CHANGELOG.md
|
||||
|
||||
Replace `[Unreleased]` header with the new version and date.
|
||||
Keep an empty `## [Unreleased]` section above it.
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [2.x.y] — YYYY-MM-DD
|
||||
```
|
||||
|
||||
### 4. Update openapi.yaml version ⚠️ MANDATORY
|
||||
|
||||
> **CI will fail** if `docs/openapi.yaml` version ≠ `package.json` version (`check:docs-sync` enforces this).
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
VERSION=$(node -p "require('./package.json').version") && sed -i "s/ version: .*/ version: $VERSION/" docs/openapi.yaml && echo "✓ openapi.yaml → $VERSION"
|
||||
```
|
||||
|
||||
### 5. Stage, commit, and tag
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(release): vX.Y.Z — summary of changes"
|
||||
git tag -a vX.Y.Z -m "Release vX.Y.Z — summary"
|
||||
git add package.json package-lock.json CHANGELOG.md docs/openapi.yaml
|
||||
git commit -m "chore(release): v2.x.y — summary of changes"
|
||||
git tag -a v2.x.y -m "Release v2.x.y"
|
||||
```
|
||||
|
||||
### 5. Push to GitHub
|
||||
### 6. Push to GitHub
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
git push origin vX.Y.Z
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
### 6. Publish to npm
|
||||
|
||||
```bash
|
||||
npm publish
|
||||
```
|
||||
|
||||
Wait for completion (prepublishOnly runs `npm run build:cli` automatically).
|
||||
|
||||
### 7. Create GitHub release
|
||||
|
||||
```bash
|
||||
gh release create vX.Y.Z --title "Release vX.Y.Z" --notes-file /tmp/release_notes.md
|
||||
gh release create v2.x.y --title "v2.x.y — summary" --notes "..."
|
||||
```
|
||||
|
||||
### 8. Deploy to VPS (if requested)
|
||||
@@ -68,7 +90,7 @@ gh release create vX.Y.Z --title "Release vX.Y.Z" --notes-file /tmp/release_note
|
||||
See `/deploy-vps` workflow for Akamai VPS or use npm for local VPS:
|
||||
|
||||
```bash
|
||||
ssh root@<VPS_IP> "npm install -g omniroute@X.Y.Z && pm2 restart omniroute"
|
||||
ssh root@<VPS_IP> "npm install -g omniroute@2.x.y && pm2 restart omniroute"
|
||||
```
|
||||
|
||||
## Notes
|
||||
@@ -76,3 +98,13 @@ ssh root@<VPS_IP> "npm install -g omniroute@X.Y.Z && pm2 restart omniroute"
|
||||
- Always run `/update-docs` BEFORE this workflow (ensures CHANGELOG and README are current)
|
||||
- The `prepublishOnly` script runs `npm run build:cli` automatically during `npm publish`
|
||||
- After npm publish, verify with `npm info omniroute version`
|
||||
- Lock file sync errors are caused by skipping `npm install` after version bump
|
||||
|
||||
## Known CI Pitfalls
|
||||
|
||||
| CI failure | Cause | Fix |
|
||||
| ------------------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `[docs-sync] FAIL - OpenAPI version differs from package.json` | Skipped step 4 — `docs/openapi.yaml` version not updated | Run step 4 (`sed -i ...`) and commit |
|
||||
| `[docs-sync] FAIL - CHANGELOG.md first section must be "## [Unreleased]"` | `## [Unreleased]` missing or not at top of CHANGELOG | Add `## [Unreleased]\n\n---\n` before the first versioned `## [x.y.z]` |
|
||||
| Electron Linux `.deb` build fails (`FpmTarget` error) | `fpm` Ruby gem not installed on `ubuntu-latest` runner | Already fixed in `electron-release.yml` (`gem install fpm` step) |
|
||||
| Docker Hub `502 error writing layer blob` | Transient Docker Hub network error during ARM64 push | Re-run the Docker publish workflow; no code change needed |
|
||||
|
||||
@@ -10,6 +10,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
|
||||
@@ -49,6 +49,9 @@ jobs:
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
no-cache: false
|
||||
env:
|
||||
DOCKER_BUILDKIT_INLINE_CACHE: 1
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
|
||||
@@ -107,6 +107,10 @@ jobs:
|
||||
"
|
||||
echo "✓ electron/package.json version set to $VERSION_NO_V"
|
||||
|
||||
- name: Install fpm (Linux .deb packaging tool)
|
||||
if: matrix.platform == 'linux'
|
||||
run: sudo gem install fpm --no-document
|
||||
|
||||
- name: Install Electron dependencies
|
||||
working-directory: electron
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Install dependencies (skip scripts to avoid heavy build)
|
||||
run: npm ci --ignore-scripts
|
||||
run: npm install --ignore-scripts --no-audit --no-fund
|
||||
|
||||
- name: Sync version from release tag
|
||||
run: |
|
||||
@@ -39,6 +39,13 @@ jobs:
|
||||
run: node scripts/prepublish.mjs
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --access public
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
# Check if this version is already published — skip instead of failing with E403
|
||||
if npm view "omniroute@${VERSION}" version --silent 2>/dev/null | grep -q "^${VERSION}$"; then
|
||||
echo "️⚠️ Version ${VERSION} is already published on npm — skipping."
|
||||
exit 0
|
||||
fi
|
||||
npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -102,7 +102,6 @@ cloud/
|
||||
security-analysis/
|
||||
|
||||
# Deploy workflow (contains sensitive VPS credentials)
|
||||
.agent/workflows/deploy.md
|
||||
clipr/
|
||||
app.log
|
||||
*.tgz
|
||||
|
||||
+104
@@ -11,6 +11,110 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [2.2.6] — 2026-03-10
|
||||
|
||||
> ### 🐛 Fix Claude Thinking Tokens Invisible in Passthrough Mode
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Claude thinking tokens not visible (#289)** — When routing through Antigravity OAuth or any Claude provider, thinking blocks were being emitted as regular `delta.content` with `<think>/<\/think>` XML wrappers. Fixed: now correctly maps `thinking_delta` events to `delta.reasoning_content` so clients like Claude Code, Cursor, and Windsurf display the thinking panel properly.
|
||||
|
||||
---
|
||||
|
||||
## [2.2.5] — 2026-03-10
|
||||
|
||||
> ### 🔧 Zero-Config Bootstrap · 🐛 Electron Black Screen Fix
|
||||
|
||||
### Features
|
||||
|
||||
- **Zero-config bootstrap (#252, #249)** — OmniRoute now auto-generates required secrets on first run across all deployment modes (npm, Docker, Electron Desktop App):
|
||||
- `JWT_SECRET` (64-byte hex) — required for auth/sessions
|
||||
- `STORAGE_ENCRYPTION_KEY` (32-byte hex) — required for SQLite encryption
|
||||
- `API_KEY_SECRET` (32-byte hex) — required for API key signing
|
||||
- Secrets are persisted to `{DATA_DIR}/server.env` and survive restarts, Docker volume remounts, and upgrades
|
||||
- Friendly startup warnings if OAuth secrets (Antigravity, iFlow, Gemini) are not configured
|
||||
- New **`scripts/bootstrap-env.mjs`** module — single source of truth for zero-config initialization
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Electron black screen on macOS/Windows/Linux** — The Next.js server was crashing silently because `JWT_SECRET` and `STORAGE_ENCRYPTION_KEY` are never present in desktop OS environments. Fixed by calling `bootstrapEnv()` before spawning `server.js`, with secrets persisted to Electron's `userData` directory.
|
||||
- **Dashboard bootstrap banner** — Added dismissable amber warning banner on the dashboard home when running in zero-config mode, showing where `server.env` is stored and how to customize secrets.
|
||||
|
||||
### Note for Docker users
|
||||
|
||||
Previously, `--env-file .env` was required to pass secrets to the container. Now OmniRoute will generate and persist them automatically in the mounted volume. Existing `DATA_DIR` secrets are always respected.
|
||||
|
||||
---
|
||||
|
||||
## [2.2.4] — 2026-03-10
|
||||
|
||||
> ### 🔧 CI Fixes
|
||||
|
||||
### CI
|
||||
|
||||
- **docs-sync fix** — Updated `docs/openapi.yaml` version from `2.2.0` to `2.2.3` (was out of sync with `package.json`, causing CI lint failure)
|
||||
- **CHANGELOG format** — Added required `## [Unreleased]` section at top of `CHANGELOG.md` (required by `check:docs-sync` script)
|
||||
- **Electron Linux** — Added `gem install fpm` step to `electron-release.yml` Linux build job; `fpm` is required by `electron-builder` to package `.deb` installers but was not pre-installed on `ubuntu-latest` runners
|
||||
- **Docker publish** — Added `DOCKER_BUILDKIT_INLINE_CACHE` env; previous `502 error writing layer blob` was a transient Docker Hub network error
|
||||
|
||||
---
|
||||
|
||||
## [2.2.3] — 2026-03-10
|
||||
|
||||
> ### 🐛 Bug Fixes · 🔧 Reliability
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Antigravity/Gemini CLI: remove fake projectId fallback (#285)** — OmniRoute was generating random fallback project IDs (e.g. `useful-fuze-a04c5`) when OAuth credentials lacked a real GCP `projectId`. This caused confusing `Permission denied on resource project` and `Verify your account` errors from Google. Now throws a clear actionable error: _reconnect OAuth so OmniRoute can load your real Cloud Code project_. Affects `antigravity.ts`, `openai-to-gemini.ts`, `geminiHelper.ts`.
|
||||
- **Claude Code: filter empty-named tool_use blocks across all message roles (#288)** — Pass 1.4 only filtered empty tool names from `assistant` messages. Extended to all roles (user, system). Also filters `tool_result` blocks missing `tool_use_id`, and top-level `body.tools` declarations with empty names. Prevents `Invalid input[x].name: empty string` 400 errors from Claude API.
|
||||
- **Docker: explicit @swc/helpers copy (#288)** — Added `COPY --from=builder /app/node_modules/@swc/helpers` to Dockerfile `runner-base` stage. The standalone tracer doesn't always include this package, causing runtime `MODULE_NOT_FOUND` crashloops.
|
||||
|
||||
---
|
||||
|
||||
## [2.2.2] — 2026-03-10
|
||||
|
||||
> ### ✨ New Features · 🔀 Model Aliases
|
||||
|
||||
### New Features
|
||||
|
||||
- **system-info.mjs (#280)** — New `npm run system-info` command that collects Node.js version, OmniRoute version, OS info, CLI tool versions (iflow, gemini, claude, codex, antigravity, droid, openclaw, kilo, cursor, aider), Docker/PM2 status, and system packages. Outputs `system-info.txt` for easy attachment to bug reports.
|
||||
|
||||
### Model Aliases
|
||||
|
||||
- **Kimi K2/K2.5 Fireworks aliases (#265)** — Built-in aliases added: `fireworks/accounts/fireworks/models/kimi-k2p5` and `kimi-k2p5` → `moonshotai/Kimi-K2.5`; same for `kimi-k2` → `moonshotai/Kimi-K2`. Fireworks long path model names now auto-resolve.
|
||||
- **Mistral short aliases (#278)** — `mistral-large` → `mistral-large-latest`, `mistral-small` → `mistral-small-latest`, `codestral` → `codestral-latest`.
|
||||
- **Llama short aliases** — `llama-3.3` → `llama-3.3-70b-versatile`, `llama-3-70b` → `llama-3.3-70b-versatile`, `llama-3-8b` → `llama3-8b-8192`.
|
||||
- **Custom aliases** — Users can define their own aliases in **Settings → Model Aliases** tab. Example: `gpt-5.4` → `cx/gpt-5.4`.
|
||||
|
||||
---
|
||||
|
||||
## [2.2.1] — 2026-03-10
|
||||
|
||||
> ### 🐛 Bug Fixes · 🔐 Security · 🔧 CI
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Gemini image routing (#273)** — `gemini-3.1-flash-image-preview` was missing from the `antigravity` image provider registry in `imageRegistry.ts`, causing image generation to fall through to the chat handler. Added alongside `gemini-2.5-flash-preview-image-generation`.
|
||||
- **Ollama Cloud model listing (#276)** — `ollama-cloud` was absent from `PROVIDER_MODELS_CONFIG` in the models route, causing 400 errors when listing models from `api.ollama.com`. Entry added.
|
||||
- **Missing apiKey error clarity (#277)** — When login is disabled and a provider has no API key configured, the model import route now returns `400` with a clear message instead of a generic `401 Unauthorized`.
|
||||
|
||||
### Security
|
||||
|
||||
- **TLS validation re-enabled (GHSA-50)** — `mitm/server.ts`: `rejectUnauthorized` now defaults to `true`. Opt-out only via `MITM_DISABLE_TLS_VERIFY=1`.
|
||||
- **Path traversal hardening (GHSA-41–49)** — Added `safePath()`, `safeProfilePath()`, `safeLogPath()` helpers across `backupService.ts`, `db/backup.ts`, `codex-profiles/route.ts`, and `mitm/server.ts`. All user-supplied IDs/filenames are now anchored within their allowed directories using `path.resolve()` + bounds check.
|
||||
- **Prototype pollution fix (GHSA-18–20)** — `usageHistory.ts`: `pendingRequests` maps now use `Object.create(null)` + `hasOwnProperty` guards, preventing `__proto__` / `constructor` injection via crafted provider IDs.
|
||||
- **Dependency: dompurify updated to ^3.3.2** — Resolves CVE-2026-0540 (XSS in rendered HTML).
|
||||
- **GitHub Actions: added `permissions: contents: read`** — Prevents token over-permission in CI jobs.
|
||||
|
||||
### CI
|
||||
|
||||
- **Lock file sync** — Added `@swc/helpers: "^0.5.19"` override in `package.json`; regenerated `package-lock.json`. Fixes `npm ci` failures across `ci.yml` and `docker-publish.yml`.
|
||||
- **npm-publish: skip if version exists** — Workflow now checks registry before publishing; exits cleanly with a warning instead of failing with `E403` if the version is already on npm.
|
||||
- **npm-publish: use `npm install` instead of `npm ci`** — Prevents publish failures when a tag commit's lock file is slightly out of sync.
|
||||
- **Lint: `cursor.ts` any-budget** — Replaced `any` with `unknown` + type narrowing in `isToolBoundaryAbort()`.
|
||||
|
||||
---
|
||||
|
||||
## [2.2.0] — 2026-03-10
|
||||
|
||||
> ### 🔧 Bug Fixes · Provider Support · CI Recovery
|
||||
|
||||
@@ -29,6 +29,8 @@ RUN mkdir -p /app/data
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
# Explicitly copy @swc/helpers — not always traced by standalone output but needed at runtime
|
||||
COPY --from=builder /app/node_modules/@swc/helpers ./node_modules/@swc/helpers
|
||||
COPY --from=builder /app/scripts/run-standalone.mjs ./run-standalone.mjs
|
||||
COPY --from=builder /app/scripts/runtime-env.mjs ./runtime-env.mjs
|
||||
COPY --from=builder /app/scripts/healthcheck.mjs ./healthcheck.mjs
|
||||
|
||||
@@ -167,6 +167,16 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f
|
||||
- **Contributing**: See [CONTRIBUTING.md](CONTRIBUTING.md), open a PR, or pick a `good first issue`
|
||||
- **Original Project**: [9router by decolua](https://github.com/decolua/9router)
|
||||
|
||||
### 🐛 Reporting a Bug?
|
||||
|
||||
When opening an issue, please run the system-info command and attach the generated file:
|
||||
|
||||
```bash
|
||||
npm run system-info
|
||||
```
|
||||
|
||||
This generates a `system-info.txt` with your Node.js version, OmniRoute version, OS details, installed CLI tools (iflow, gemini, claude, codex, antigravity, droid, etc.), Docker/PM2 status, and system packages — everything we need to reproduce your issue quickly. Attach the file directly to your GitHub issue.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 How It Works
|
||||
@@ -358,6 +368,7 @@ When a call fails, the dev doesn't know if it was a rate limit, expired token, w
|
||||
- **Translator Playground** — 4 debugging modes: Playground (format translation), Chat Tester (round-trip), Test Bench (batch), Live Monitor (real-time)
|
||||
- **Request Telemetry** — p50/p95/p99 latency + X-Request-Id tracing
|
||||
- **File-Based Logging with Rotation** — Console interceptor captures everything to JSON log with size-based rotation
|
||||
- **System Info Report** — `npm run system-info` generates `system-info.txt` with your full environment (Node version, OmniRoute version, OS, CLI tools, Docker/PM2 status). Attach it when reporting issues for instant triage.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.2.0
|
||||
version: 2.2.6
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
||||
+64
-1
@@ -383,6 +383,69 @@ function startNextServer() {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Zero-config bootstrap: auto-generate required secrets ─────────────────
|
||||
// Electron uses CJS — cannot dynamically import ESM bootstrap-env.mjs.
|
||||
// This mirrors bootstrap-env.mjs logic synchronously:
|
||||
// 1. Read persisted secrets from userData/server.env
|
||||
// 2. Generate missing secrets with crypto.randomBytes()
|
||||
// 3. Persist back to userData/server.env for future restarts
|
||||
const crypto = require("crypto");
|
||||
const userDataDir = app.getPath("userData");
|
||||
const serverEnvPath = path.join(userDataDir, "server.env");
|
||||
|
||||
// Parse a simple KEY=VALUE file
|
||||
function parseEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
const env = {};
|
||||
for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith("#")) continue;
|
||||
const eq = t.indexOf("=");
|
||||
if (eq < 1) continue;
|
||||
env[t.slice(0, eq).trim()] = t.slice(eq + 1).trim();
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const persisted = parseEnvFile(serverEnvPath);
|
||||
const serverEnv = { ...process.env, ...persisted };
|
||||
let changed = false;
|
||||
|
||||
if (!serverEnv.JWT_SECRET) {
|
||||
serverEnv.JWT_SECRET = persisted.JWT_SECRET = crypto.randomBytes(64).toString("hex");
|
||||
changed = true;
|
||||
console.log("[Electron] ✨ JWT_SECRET auto-generated");
|
||||
}
|
||||
if (!serverEnv.STORAGE_ENCRYPTION_KEY) {
|
||||
serverEnv.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY = crypto
|
||||
.randomBytes(32)
|
||||
.toString("hex");
|
||||
serverEnv.STORAGE_ENCRYPTION_KEY_VERSION = persisted.STORAGE_ENCRYPTION_KEY_VERSION = "v1";
|
||||
changed = true;
|
||||
console.log("[Electron] ✨ STORAGE_ENCRYPTION_KEY auto-generated");
|
||||
}
|
||||
if (!serverEnv.API_KEY_SECRET) {
|
||||
serverEnv.API_KEY_SECRET = persisted.API_KEY_SECRET = crypto.randomBytes(32).toString("hex");
|
||||
changed = true;
|
||||
console.log("[Electron] ✨ API_KEY_SECRET auto-generated");
|
||||
}
|
||||
if (changed) {
|
||||
serverEnv.OMNIROUTE_BOOTSTRAPPED = "true";
|
||||
try {
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
const lines = [
|
||||
"# Auto-generated by OmniRoute bootstrap",
|
||||
"",
|
||||
...Object.entries(persisted).map(([k, v]) => `${k}=${v}`),
|
||||
"",
|
||||
];
|
||||
fs.writeFileSync(serverEnvPath, lines.join("\n"), "utf8");
|
||||
console.log("[Electron] 📁 Secrets persisted to:", serverEnvPath);
|
||||
} catch (e) {
|
||||
console.warn("[Electron] Could not persist secrets:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[Electron] Starting Next.js server on port", serverPort);
|
||||
sendToRenderer("server-status", { status: "starting", port: serverPort });
|
||||
|
||||
@@ -390,7 +453,7 @@ function startNextServer() {
|
||||
nextServer = spawn("node", [serverScript], {
|
||||
cwd: NEXT_SERVER_PATH,
|
||||
env: {
|
||||
...process.env,
|
||||
...serverEnv,
|
||||
PORT: String(serverPort),
|
||||
NODE_ENV: "production",
|
||||
},
|
||||
|
||||
@@ -63,7 +63,10 @@ export const IMAGE_PROVIDERS = {
|
||||
authType: "oauth",
|
||||
authHeader: "bearer",
|
||||
format: "gemini-image", // Special format: uses Gemini generateContent API
|
||||
models: [{ id: "gemini-2.5-flash-preview-image-generation", name: "Nano Banana" }],
|
||||
models: [
|
||||
{ id: "gemini-2.5-flash-preview-image-generation", name: "Gemini 2.5 Flash Image" },
|
||||
{ id: "gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image Preview" },
|
||||
],
|
||||
supportedSizes: ["1024x1024"],
|
||||
},
|
||||
|
||||
|
||||
@@ -38,14 +38,11 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
const bodyProjectId = body?.project;
|
||||
const credentialsProjectId = credentials?.projectId;
|
||||
const hasExplicitProject = !!(bodyProjectId || credentialsProjectId);
|
||||
const projectId = bodyProjectId || credentialsProjectId || this.generateProjectId();
|
||||
const projectId = bodyProjectId || credentialsProjectId;
|
||||
|
||||
if (!hasExplicitProject) {
|
||||
console.warn(
|
||||
`[Antigravity] ⚠️ No projectId provided via body or credentials — using generated fallback "${projectId}". ` +
|
||||
`This may cause 404 errors if the account has no active GCP project. ` +
|
||||
`Ensure the OAuth token includes a valid project or the request includes a project field.`
|
||||
if (!projectId) {
|
||||
throw new Error(
|
||||
"Missing Google projectId for Antigravity account. Please reconnect OAuth so OmniRoute can fetch your real Cloud Code project (loadCodeAssist)."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,12 +125,6 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
generateProjectId() {
|
||||
const adj = ["useful", "bright", "swift", "calm", "bold"][Math.floor(Math.random() * 5)];
|
||||
const noun = ["fuze", "wave", "spark", "flow", "core"][Math.floor(Math.random() * 5)];
|
||||
return `${adj}-${noun}-${crypto.randomUUID().slice(0, 5)}`;
|
||||
}
|
||||
|
||||
generateSessionId() {
|
||||
return `-${Math.floor(Math.random() * 9_000_000_000_000_000_000)}`;
|
||||
}
|
||||
|
||||
@@ -148,12 +148,17 @@ function parseCursorJsonErrorFrame(text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function isToolBoundaryAbort(jsonError: any, toolCallCount: number) {
|
||||
function isToolBoundaryAbort(jsonError: unknown, toolCallCount: number) {
|
||||
if (!jsonError || toolCallCount <= 0) return false;
|
||||
const code = jsonError?.error?.code || "";
|
||||
const debugError = jsonError?.error?.details?.[0]?.debug?.error || "";
|
||||
const title = jsonError?.error?.details?.[0]?.debug?.details?.title || "";
|
||||
const detail = jsonError?.error?.details?.[0]?.debug?.details?.detail || "";
|
||||
const e = jsonError as Record<string, unknown>;
|
||||
const err = e?.error as Record<string, unknown> | undefined;
|
||||
const details = (err?.details as Record<string, unknown>[] | undefined)?.[0];
|
||||
const debug = details?.debug as Record<string, unknown> | undefined;
|
||||
const debugDetails = debug?.details as Record<string, unknown> | undefined;
|
||||
const code = (err?.code as string) || "";
|
||||
const debugError = (debug?.error as string) || "";
|
||||
const title = (debugDetails?.title as string) || "";
|
||||
const detail = (debugDetails?.detail as string) || "";
|
||||
const message = `${title} ${detail}`.toLowerCase();
|
||||
const isAbortedCode = code === "aborted" || debugError === "ERROR_USER_ABORTED_REQUEST";
|
||||
return isAbortedCode && message.includes("tool call ended before result was received");
|
||||
|
||||
@@ -31,6 +31,24 @@ const BUILT_IN_ALIASES: Record<string, string> = {
|
||||
"gpt-4-0125-preview": "gpt-4-turbo",
|
||||
"gpt-4-1106-preview": "gpt-4-turbo",
|
||||
"gpt-3.5-turbo-0125": "gpt-3.5-turbo",
|
||||
|
||||
// Kimi/Moonshot — Fireworks long-path aliases (#265)
|
||||
"accounts/fireworks/models/kimi-k2p5": "moonshotai/Kimi-K2.5",
|
||||
"fireworks/accounts/fireworks/models/kimi-k2p5": "moonshotai/Kimi-K2.5",
|
||||
"kimi-k2p5": "moonshotai/Kimi-K2.5",
|
||||
"accounts/fireworks/models/kimi-k2": "moonshotai/Kimi-K2",
|
||||
"fireworks/accounts/fireworks/models/kimi-k2": "moonshotai/Kimi-K2",
|
||||
"kimi-k2": "moonshotai/Kimi-K2",
|
||||
|
||||
// Mistral short aliases
|
||||
"mistral-large": "mistral-large-latest",
|
||||
"mistral-small": "mistral-small-latest",
|
||||
codestral: "codestral-latest",
|
||||
|
||||
// Llama short aliases
|
||||
"llama-3.3": "llama-3.3-70b-versatile",
|
||||
"llama-3-70b": "llama-3.3-70b-versatile",
|
||||
"llama-3-8b": "llama3-8b-8192",
|
||||
};
|
||||
|
||||
// ── Custom Aliases (persisted via Settings API) ─────────────────────────────
|
||||
|
||||
@@ -127,14 +127,24 @@ export function prepareClaudeRequest(body, provider = null) {
|
||||
}
|
||||
|
||||
// Pass 1.4: Filter out tool_use blocks with empty names (causes Claude 400 error)
|
||||
// Apply to ALL roles (assistant tool_use + any user messages that may carry tool_use)
|
||||
// Also filter tool_result blocks with missing tool_use_id
|
||||
for (const msg of filtered) {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
if (Array.isArray(msg.content)) {
|
||||
msg.content = msg.content.filter(
|
||||
(block) => block.type !== "tool_use" || (block.name && block.name.trim())
|
||||
(block) => block.type !== "tool_use" || (block.name && block.name?.trim())
|
||||
);
|
||||
msg.content = msg.content.filter(
|
||||
(block) => block.type !== "tool_result" || block.tool_use_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Also filter top-level tool declarations with empty names
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
body.tools = body.tools.filter((tool) => tool.name && tool.name?.trim());
|
||||
}
|
||||
|
||||
// Pass 1.5: Fix tool_use/tool_result ordering
|
||||
// Each tool_use must have tool_result in the NEXT message (not same message with other content)
|
||||
filtered = fixToolUseOrdering(filtered);
|
||||
|
||||
@@ -126,15 +126,6 @@ export function generateSessionId() {
|
||||
return `-${Math.floor(Math.random() * 9000000000000000000)}`;
|
||||
}
|
||||
|
||||
// Generate project ID
|
||||
export function generateProjectId() {
|
||||
const adjectives = ["useful", "bright", "swift", "calm", "bold"];
|
||||
const nouns = ["fuze", "wave", "spark", "flow", "core"];
|
||||
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||
return `${adj}-${noun}-${crypto.randomUUID().slice(0, 5)}`;
|
||||
}
|
||||
|
||||
// Helper: Remove unsupported keywords recursively from object/array
|
||||
function removeUnsupportedKeywords(obj, keywords) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
@@ -175,6 +175,9 @@ export function openaiToClaudeRequest(model, body, stream) {
|
||||
};
|
||||
});
|
||||
|
||||
// Filter out tools with empty names (would cause Claude 400 error)
|
||||
result.tools = result.tools.filter((tool) => tool.name && tool.name?.trim());
|
||||
|
||||
// Add cache_control to last tool that doesn't have defer_loading
|
||||
// Tools with defer_loading=true cannot have cache_control (API rejects it)
|
||||
for (let i = result.tools.length - 1; i >= 0; i--) {
|
||||
@@ -227,6 +230,8 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr
|
||||
if (part.type === "text" && part.text) {
|
||||
blocks.push({ type: "text", text: part.text });
|
||||
} else if (part.type === "tool_result") {
|
||||
// Skip tool_result with no tool_use_id (would be useless and may cause errors)
|
||||
if (!part.tool_use_id) continue;
|
||||
blocks.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: part.tool_use_id,
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
tryParseJSON,
|
||||
generateRequestId,
|
||||
generateSessionId,
|
||||
generateProjectId,
|
||||
cleanJSONSchemaForAntigravity,
|
||||
} from "../helpers/geminiHelper.ts";
|
||||
|
||||
@@ -321,13 +320,11 @@ export function openaiToGeminiCLIRequest(model, body, stream) {
|
||||
|
||||
// Wrap Gemini CLI format in Cloud Code wrapper
|
||||
function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigravity = false) {
|
||||
const hasRealProject = !!credentials?.projectId;
|
||||
const projectId = credentials?.projectId || generateProjectId();
|
||||
const projectId = credentials?.projectId;
|
||||
|
||||
if (!hasRealProject) {
|
||||
console.warn(
|
||||
`[${isAntigravity ? "Antigravity" : "GeminiCLI"}] ⚠️ No projectId in credentials — using generated fallback "${projectId}". ` +
|
||||
`This may cause 404 errors. Ensure the OAuth token includes a valid GCP project.`
|
||||
if (!projectId) {
|
||||
throw new Error(
|
||||
`${isAntigravity ? "Antigravity" : "GeminiCLI"} account is missing projectId. Reconnect OAuth to load your real Cloud Code project before sending requests.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -374,13 +371,11 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
|
||||
}
|
||||
|
||||
function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = null) {
|
||||
const hasRealProject = !!credentials?.projectId;
|
||||
const projectId = credentials?.projectId || generateProjectId();
|
||||
const projectId = credentials?.projectId;
|
||||
|
||||
if (!hasRealProject) {
|
||||
console.warn(
|
||||
`[Antigravity/Claude] ⚠️ No projectId in credentials — using generated fallback "${projectId}". ` +
|
||||
`This may cause 404 errors. Ensure the OAuth token includes a valid GCP project.`
|
||||
if (!projectId) {
|
||||
throw new Error(
|
||||
"Antigravity/Claude account is missing projectId. Reconnect OAuth to load your real Cloud Code project before sending requests."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,9 @@ export function claudeToOpenAIResponse(chunk, state) {
|
||||
} else if (block?.type === "thinking") {
|
||||
state.inThinkingBlock = true;
|
||||
state.currentBlockIndex = chunk.index;
|
||||
results.push(createChunk(state, { content: "<think>" }));
|
||||
// Emit empty reasoning_content to signal thinking block start
|
||||
// (clients like Claude Code look for reasoning_content, not <think> tags)
|
||||
results.push(createChunk(state, { reasoning_content: "" }));
|
||||
} else if (block?.type === "tool_use") {
|
||||
const toolCallIndex = state.toolCallIndex++;
|
||||
// Restore original tool name from mapping (Claude OAuth)
|
||||
@@ -76,7 +78,9 @@ export function claudeToOpenAIResponse(chunk, state) {
|
||||
if (delta?.type === "text_delta" && delta.text) {
|
||||
results.push(createChunk(state, { content: delta.text }));
|
||||
} else if (delta?.type === "thinking_delta" && delta.thinking) {
|
||||
results.push(createChunk(state, { content: delta.thinking }));
|
||||
// Map Claude thinking_delta → OpenAI reasoning_content
|
||||
// Clients (Claude Code, Cursor, etc.) display reasoning_content as the thinking panel
|
||||
results.push(createChunk(state, { reasoning_content: delta.thinking }));
|
||||
} else if (delta?.type === "input_json_delta" && delta.partial_json) {
|
||||
const toolCall = state.toolCalls.get(chunk.index);
|
||||
if (toolCall) {
|
||||
@@ -99,7 +103,8 @@ export function claudeToOpenAIResponse(chunk, state) {
|
||||
|
||||
case "content_block_stop": {
|
||||
if (state.inThinkingBlock && chunk.index === state.currentBlockIndex) {
|
||||
results.push(createChunk(state, { content: "</think>" }));
|
||||
// Thinking block closed — no additional content needed;
|
||||
// reasoning_content chunks have already been streamed
|
||||
state.inThinkingBlock = false;
|
||||
}
|
||||
state.textBlockStarted = false;
|
||||
|
||||
Generated
+33
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.6",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -18,6 +18,7 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"bottleneck": "^2.19.5",
|
||||
"dompurify": "^3.3.2",
|
||||
"express": "^5.2.1",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
@@ -2985,9 +2986,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
|
||||
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
@@ -5674,10 +5675,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@@ -6864,6 +6868,7 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -8773,6 +8778,15 @@
|
||||
"marked": "14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor/node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -8964,6 +8978,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl/node_modules/@swc/helpers": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
|
||||
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
|
||||
+7
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.6",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -76,7 +76,8 @@
|
||||
"check": "npm run lint && npm run test",
|
||||
"prepublishOnly": "npm run build:cli",
|
||||
"postinstall": "node scripts/postinstall.mjs",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky",
|
||||
"system-info": "node scripts/system-info.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
@@ -84,6 +85,7 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"bottleneck": "^2.19.5",
|
||||
"dompurify": "^3.3.2",
|
||||
"express": "^5.2.1",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
@@ -138,5 +140,8 @@
|
||||
"*.{json,md,yml,yaml,css}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"@swc/helpers": "^0.5.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OmniRoute — Zero-Config Bootstrap
|
||||
*
|
||||
* Auto-generates required secrets (JWT_SECRET, STORAGE_ENCRYPTION_KEY) if
|
||||
* missing or empty, persists them to {DATA_DIR}/server.env so they survive
|
||||
* restarts, Docker volume remounts, and upgrades.
|
||||
*
|
||||
* Works across all deployment modes:
|
||||
* - npm / CLI: called from run-standalone.mjs and run-next.mjs
|
||||
* - Docker: same, secrets persisted in mounted volume
|
||||
* - Electron: called from main.js startup, persisted in userData
|
||||
*
|
||||
* Priority (lowest → highest):
|
||||
* 1. Auto-generated defaults
|
||||
* 2. {DATA_DIR}/server.env (persisted on first boot)
|
||||
* 3. .env in CWD (user overrides)
|
||||
* 4. process.env (shell / Docker -e flags, highest priority)
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
// ── OAuth secrets that are optional but warn if missing ─────────────────────
|
||||
const OPTIONAL_OAUTH_SECRETS = [
|
||||
{ key: "ANTIGRAVITY_OAUTH_CLIENT_SECRET", label: "Antigravity OAuth" },
|
||||
{ key: "IFLOW_OAUTH_CLIENT_SECRET", label: "iFlow OAuth" },
|
||||
{ key: "GEMINI_OAUTH_CLIENT_SECRET", label: "Gemini OAuth" },
|
||||
];
|
||||
|
||||
// ── Resolve DATA_DIR (mirrors dataPaths.ts logic) ───────────────────────────
|
||||
function resolveDataDir(overridePath) {
|
||||
if (overridePath) return resolve(overridePath);
|
||||
|
||||
const configured = process.env.DATA_DIR?.trim();
|
||||
if (configured) return resolve(configured);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
||||
return join(appData, "omniroute");
|
||||
}
|
||||
|
||||
const xdg = process.env.XDG_CONFIG_HOME?.trim();
|
||||
if (xdg) return join(resolve(xdg), "omniroute");
|
||||
|
||||
return join(homedir(), ".omniroute");
|
||||
}
|
||||
|
||||
// ── Parse a simple KEY=VALUE env file ───────────────────────────────────────
|
||||
function parseEnvFile(filePath) {
|
||||
if (!existsSync(filePath)) return {};
|
||||
const env = {};
|
||||
const lines = readFileSync(filePath, "utf8").split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx < 1) continue;
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const val = trimmed.slice(eqIdx + 1).trim();
|
||||
env[key] = val;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
// ── Write a simple KEY=VALUE env file ───────────────────────────────────────
|
||||
function writeEnvFile(filePath, env) {
|
||||
const lines = [
|
||||
"# Auto-generated by OmniRoute bootstrap — do not delete",
|
||||
`# Created: ${new Date().toISOString()}`,
|
||||
"",
|
||||
...Object.entries(env).map(([k, v]) => `${k}=${v}`),
|
||||
"",
|
||||
];
|
||||
writeFileSync(filePath, lines.join("\n"), "utf8");
|
||||
}
|
||||
|
||||
// ── Main bootstrap function ──────────────────────────────────────────────────
|
||||
/**
|
||||
* @param {{ dataDirOverride?: string; quiet?: boolean }} options
|
||||
* @returns {Record<string, string>} merged env to pass to child process
|
||||
*/
|
||||
export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
|
||||
const log = quiet ? () => {} : (msg) => process.stderr.write(`[bootstrap] ${msg}\n`);
|
||||
|
||||
const dataDir = resolveDataDir(dataDirOverride);
|
||||
const serverEnvPath = join(dataDir, "server.env");
|
||||
const dotEnvPath = join(process.cwd(), ".env");
|
||||
|
||||
// ── Layer 1: Load persisted server.env ────────────────────────────────────
|
||||
let persisted = parseEnvFile(serverEnvPath);
|
||||
|
||||
// ── Layer 2: Load .env from CWD (user overrides, higher priority) ─────────
|
||||
const dotEnv = parseEnvFile(dotEnvPath);
|
||||
|
||||
// ── Merge: persisted < .env < process.env ─────────────────────────────────
|
||||
const merged = { ...persisted, ...dotEnv, ...process.env };
|
||||
|
||||
// ── Auto-generate required secrets ────────────────────────────────────────
|
||||
let needsPersist = false;
|
||||
|
||||
if (!merged.JWT_SECRET?.trim()) {
|
||||
persisted.JWT_SECRET = randomBytes(64).toString("hex");
|
||||
merged.JWT_SECRET = persisted.JWT_SECRET;
|
||||
needsPersist = true;
|
||||
log("✨ JWT_SECRET auto-generated (first run)");
|
||||
}
|
||||
|
||||
if (!merged.STORAGE_ENCRYPTION_KEY?.trim()) {
|
||||
persisted.STORAGE_ENCRYPTION_KEY = randomBytes(32).toString("hex");
|
||||
merged.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY;
|
||||
needsPersist = true;
|
||||
log("✨ STORAGE_ENCRYPTION_KEY auto-generated (first run)");
|
||||
}
|
||||
|
||||
if (!merged.STORAGE_ENCRYPTION_KEY_VERSION?.trim()) {
|
||||
persisted.STORAGE_ENCRYPTION_KEY_VERSION = "v1";
|
||||
merged.STORAGE_ENCRYPTION_KEY_VERSION = persisted.STORAGE_ENCRYPTION_KEY_VERSION;
|
||||
needsPersist = true;
|
||||
}
|
||||
|
||||
if (!merged.API_KEY_SECRET?.trim()) {
|
||||
persisted.API_KEY_SECRET = randomBytes(32).toString("hex");
|
||||
merged.API_KEY_SECRET = persisted.API_KEY_SECRET;
|
||||
needsPersist = true;
|
||||
log("✨ API_KEY_SECRET auto-generated (first run)");
|
||||
}
|
||||
|
||||
// ── Persist new secrets ────────────────────────────────────────────────────
|
||||
if (needsPersist) {
|
||||
try {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
// Only persist keys that we auto-generated (not .env or process.env vals)
|
||||
writeEnvFile(serverEnvPath, persisted);
|
||||
log(`📁 Secrets persisted to: ${serverEnvPath}`);
|
||||
} catch (e) {
|
||||
log(`⚠️ Could not persist secrets to ${serverEnvPath}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mark as bootstrapped ───────────────────────────────────────────────────
|
||||
if (needsPersist) {
|
||||
merged.OMNIROUTE_BOOTSTRAPPED = "true";
|
||||
}
|
||||
|
||||
// ── Warn about missing optional OAuth secrets ──────────────────────────────
|
||||
const missingOauth = OPTIONAL_OAUTH_SECRETS.filter(({ key }) => !merged[key]?.trim());
|
||||
if (missingOauth.length > 0) {
|
||||
log("ℹ️ The following OAuth integrations are not configured:");
|
||||
for (const { key, label } of missingOauth) {
|
||||
log(` • ${label} (${key}) — set in .env or ${serverEnvPath}`);
|
||||
}
|
||||
log(" These providers will not work until configured.");
|
||||
}
|
||||
|
||||
// ── Warn about default password ────────────────────────────────────────────
|
||||
if (merged.INITIAL_PASSWORD === "CHANGEME" || !merged.INITIAL_PASSWORD?.trim()) {
|
||||
log("⚠️ INITIAL_PASSWORD is not set — using default 'CHANGEME'. Change it in Settings!");
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ── CLI usage: node scripts/bootstrap-env.mjs ──────────────────────────────
|
||||
if (process.argv[1] && process.argv[1].endsWith("bootstrap-env.mjs")) {
|
||||
const env = bootstrapEnv();
|
||||
process.stderr.write(`[bootstrap] Done. DATA_DIR resolved to: ${resolveDataDir()}\n`);
|
||||
process.stderr.write(`[bootstrap] JWT_SECRET length: ${env.JWT_SECRET?.length ?? 0}\n`);
|
||||
process.stderr.write(
|
||||
`[bootstrap] STORAGE_ENCRYPTION_KEY length: ${env.STORAGE_ENCRYPTION_KEY?.length ?? 0}\n`
|
||||
);
|
||||
}
|
||||
@@ -5,12 +5,16 @@ import {
|
||||
withRuntimePortEnv,
|
||||
spawnWithForwardedSignals,
|
||||
} from "./runtime-env.mjs";
|
||||
import { bootstrapEnv } from "./bootstrap-env.mjs";
|
||||
|
||||
const mode = process.argv[2] === "start" ? "start" : "dev";
|
||||
|
||||
const runtimePorts = resolveRuntimePorts();
|
||||
const { dashboardPort } = runtimePorts;
|
||||
|
||||
// Auto-generate secrets on first run, merge .env + process.env
|
||||
const env = bootstrapEnv();
|
||||
|
||||
const args = ["./node_modules/next/dist/bin/next", mode, "--port", String(dashboardPort)];
|
||||
if (mode === "dev") {
|
||||
args.splice(2, 0, "--webpack");
|
||||
@@ -18,5 +22,5 @@ if (mode === "dev") {
|
||||
|
||||
spawnWithForwardedSignals(process.execPath, args, {
|
||||
stdio: "inherit",
|
||||
env: withRuntimePortEnv(process.env, runtimePorts),
|
||||
env: withRuntimePortEnv(env, runtimePorts),
|
||||
});
|
||||
|
||||
@@ -5,10 +5,14 @@ import {
|
||||
withRuntimePortEnv,
|
||||
spawnWithForwardedSignals,
|
||||
} from "./runtime-env.mjs";
|
||||
import { bootstrapEnv } from "./bootstrap-env.mjs";
|
||||
|
||||
const runtimePorts = resolveRuntimePorts();
|
||||
|
||||
// Auto-generate secrets on first run, merge .env + process.env
|
||||
const env = bootstrapEnv();
|
||||
|
||||
spawnWithForwardedSignals("node", ["server.js"], {
|
||||
stdio: "inherit",
|
||||
env: withRuntimePortEnv(process.env, runtimePorts),
|
||||
env: withRuntimePortEnv(env, runtimePorts),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* system-info.mjs — OmniRoute System Information Reporter (#280)
|
||||
*
|
||||
* Collects system/environment info for bug reports.
|
||||
* Usage: node scripts/system-info.mjs [--output system-info.txt]
|
||||
*
|
||||
* Output includes:
|
||||
* - Node.js version
|
||||
* - OmniRoute version
|
||||
* - OS info
|
||||
* - Relevant system packages (if apt available)
|
||||
* - Agent CLI tools (iflow, gemini, claude, codex, antigravity, droid, etc.)
|
||||
* - Docker / PM2 status
|
||||
*/
|
||||
|
||||
import { execSync } from "child_process";
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import os from "os";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, "..");
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function run(cmd, fallback = "N/A") {
|
||||
try {
|
||||
return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function toolVersion(cmd, args = "--version") {
|
||||
const version = run(`${cmd} ${args}`, null);
|
||||
if (version === null) return "not installed";
|
||||
// Trim to first line, remove prefixes like "v", "Version: "
|
||||
return version
|
||||
.split("\n")[0]
|
||||
.replace(/^(version\s*:?\s*|v)/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function section(title) {
|
||||
const line = "─".repeat(60);
|
||||
return `\n${line}\n ${title}\n${line}\n`;
|
||||
}
|
||||
|
||||
// ── Collect Info ──────────────────────────────────────────────────────────
|
||||
|
||||
const lines = [];
|
||||
|
||||
lines.push("OmniRoute System Information Report");
|
||||
lines.push(`Generated: ${new Date().toISOString()}`);
|
||||
|
||||
// ── Node.js & Runtime ────────────────────────────────────────────────────
|
||||
|
||||
lines.push(section("Node.js & Runtime"));
|
||||
lines.push(`Node.js: ${process.version}`);
|
||||
lines.push(`npm: v${run("npm --version")}`);
|
||||
lines.push(`Platform: ${process.platform} (${process.arch})`);
|
||||
lines.push(`OS: ${os.type()} ${os.release()} (${os.arch()})`);
|
||||
lines.push(`Hostname: ${os.hostname()}`);
|
||||
lines.push(`CPUs: ${os.cpus().length}x ${os.cpus()[0]?.model || "unknown"}`);
|
||||
lines.push(`Total RAM: ${Math.round(os.totalmem() / 1024 / 1024)} MB`);
|
||||
lines.push(`Free RAM: ${Math.round(os.freemem() / 1024 / 1024)} MB`);
|
||||
|
||||
// ── OmniRoute Version ────────────────────────────────────────────────────
|
||||
|
||||
lines.push(section("OmniRoute"));
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8"));
|
||||
lines.push(`Version: ${pkg.version}`);
|
||||
lines.push(`Name: ${pkg.name}`);
|
||||
} catch {
|
||||
lines.push("Version: unable to read package.json");
|
||||
}
|
||||
|
||||
const installedGlobal = run("npm list -g omniroute --depth=0 2>/dev/null | grep omniroute");
|
||||
lines.push(`Global npm: ${installedGlobal || "not installed globally"}`);
|
||||
|
||||
const pm2Status = run("pm2 list 2>/dev/null | grep omniroute | awk '{print $4, $10, $12}'");
|
||||
lines.push(`PM2 status: ${pm2Status || "not running via PM2"}`);
|
||||
|
||||
// ── Agent CLI Tools ──────────────────────────────────────────────────────
|
||||
|
||||
lines.push(section("Agent CLI Tools"));
|
||||
|
||||
const cliTools = [
|
||||
{ name: "iflow-cli", cmd: "iflow", args: "--version" },
|
||||
{ name: "gemini-cli", cmd: "gemini", args: "--version" },
|
||||
{ name: "claude-code", cmd: "claude", args: "--version" },
|
||||
{ name: "openai-codex", cmd: "codex", args: "--version" },
|
||||
{ name: "antigravity", cmd: "antigravity", args: "--version" },
|
||||
{ name: "droid", cmd: "droid", args: "--version" },
|
||||
{ name: "openclaw", cmd: "openclaw", args: "--version" },
|
||||
{ name: "kilo", cmd: "kilo", args: "--version" },
|
||||
{ name: "cursor", cmd: "cursor", args: "--version" },
|
||||
{ name: "aider", cmd: "aider", args: "--version" },
|
||||
];
|
||||
|
||||
for (const { name, cmd, args } of cliTools) {
|
||||
const v = toolVersion(cmd, args);
|
||||
lines.push(`${name.padEnd(20)} ${v}`);
|
||||
}
|
||||
|
||||
// ── Docker ───────────────────────────────────────────────────────────────
|
||||
|
||||
lines.push(section("Docker"));
|
||||
lines.push(`Docker: ${run("docker --version", "not installed")}`);
|
||||
|
||||
const dockerContainers = run(
|
||||
"docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null",
|
||||
"N/A"
|
||||
);
|
||||
lines.push(`Containers:\n${dockerContainers}`);
|
||||
|
||||
// ── System Packages ──────────────────────────────────────────────────────
|
||||
|
||||
lines.push(section("System Packages (relevant)"));
|
||||
|
||||
const relevantPkgs = ["build-essential", "libssl-dev", "openssl", "libsqlite3-dev", "python3"];
|
||||
for (const pkg of relevantPkgs) {
|
||||
const ver = run(`dpkg -l ${pkg} 2>/dev/null | grep '^ii' | awk '{print $3}'`, "not found");
|
||||
lines.push(`${pkg.padEnd(24)} ${ver}`);
|
||||
}
|
||||
|
||||
// ── Environment Variables (safe subset) ─────────────────────────────────
|
||||
|
||||
lines.push(section("Environment Variables (non-sensitive)"));
|
||||
|
||||
const safeEnvKeys = [
|
||||
"NODE_ENV",
|
||||
"PORT",
|
||||
"DATA_DIR",
|
||||
"DB_BACKUPS_DIR",
|
||||
"LOG_LEVEL",
|
||||
"NEXT_PUBLIC_APP_URL",
|
||||
"ROUTER_API_KEY_HINT",
|
||||
];
|
||||
|
||||
for (const key of safeEnvKeys) {
|
||||
const val = process.env[key];
|
||||
if (val !== undefined) {
|
||||
// Mask if looks like a secret
|
||||
const masked = val.length > 8 ? val.substring(0, 4) + "****" : "****";
|
||||
lines.push(`${key.padEnd(28)} ${masked}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────────
|
||||
|
||||
const report = lines.join("\n") + "\n";
|
||||
|
||||
// Write to file
|
||||
const outArg = process.argv.find((a) => a.startsWith("--output="));
|
||||
const outFile = outArg
|
||||
? outArg.replace("--output=", "")
|
||||
: process.argv[process.argv.indexOf("--output") + 1] || "system-info.txt";
|
||||
|
||||
const outPath = join(ROOT, outFile);
|
||||
|
||||
writeFileSync(outPath, report);
|
||||
console.log(report);
|
||||
console.log(`\n✅ Report saved to: ${outPath}`);
|
||||
console.log(
|
||||
`📎 Attach this file when reporting issues at: https://github.com/diegosouzapw/OmniRoute/issues`
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Shown when OmniRoute was started with auto-generated secrets (zero-config mode).
|
||||
* The banner is dismissable and persists only for the current session.
|
||||
*/
|
||||
export default function BootstrapBanner() {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
// Determine default data dir hint based on platform hint from user-agent
|
||||
const dataDir =
|
||||
typeof navigator !== "undefined" && navigator.platform?.startsWith("Win")
|
||||
? "%APPDATA%\\omniroute\\server.env"
|
||||
: "~/.omniroute/server.env";
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200 mb-4"
|
||||
>
|
||||
<span className="text-amber-400 text-base shrink-0 mt-0.5">⚠️</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-amber-300">Running in zero-config mode</p>
|
||||
<p className="mt-0.5 text-amber-200/80">
|
||||
OmniRoute auto-generated secure encryption keys on first launch. They are persisted to{" "}
|
||||
<code className="font-mono bg-amber-500/20 px-1 rounded text-xs">{dataDir}</code>. No
|
||||
action is required — your data is encrypted and safe. To use custom keys, add{" "}
|
||||
<code className="font-mono bg-amber-500/20 px-1 rounded text-xs">JWT_SECRET</code> and{" "}
|
||||
<code className="font-mono bg-amber-500/20 px-1 rounded text-xs">
|
||||
STORAGE_ENCRYPTION_KEY
|
||||
</code>{" "}
|
||||
to that file.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="shrink-0 text-amber-400/60 hover:text-amber-300 transition-colors ml-1"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { getMachineId } from "@/shared/utils/machine";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import HomePageClient from "./HomePageClient";
|
||||
import BootstrapBanner from "./BootstrapBanner";
|
||||
|
||||
// Must be dynamic — depends on DB state (setupComplete) that changes at runtime
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -12,5 +13,11 @@ export default async function DashboardPage() {
|
||||
redirect("/dashboard/onboarding");
|
||||
}
|
||||
const machineId = await getMachineId();
|
||||
return <HomePageClient machineId={machineId} />;
|
||||
const isBootstrapped = process.env.OMNIROUTE_BOOTSTRAPPED === "true";
|
||||
return (
|
||||
<>
|
||||
{isBootstrapped && <BootstrapBanner />}
|
||||
<HomePageClient machineId={machineId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,19 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
const PROFILES_DIR = path.join(resolveDataDir(), "codex-profiles");
|
||||
|
||||
/**
|
||||
* Resolve a path inside PROFILES_DIR and verify it stays within bounds.
|
||||
* Throws on path traversal attempts.
|
||||
*/
|
||||
function safeProfilePath(...segments: string[]): string {
|
||||
const resolved = path.resolve(PROFILES_DIR, ...segments);
|
||||
const base = path.resolve(PROFILES_DIR);
|
||||
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
|
||||
throw new Error("Invalid path: directory traversal detected");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure profiles directory exists
|
||||
*/
|
||||
|
||||
@@ -247,6 +247,14 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || data.models || [],
|
||||
},
|
||||
"ollama-cloud": {
|
||||
url: "https://api.ollama.com/v1/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.models || data.data || [],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -389,7 +397,13 @@ export async function GET(request, { params }) {
|
||||
// Get auth token
|
||||
const token = accessToken || apiKey;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "No valid token found" }, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"No API key configured for this provider. Please add an API key in the provider settings.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build request URL
|
||||
|
||||
+17
-2
@@ -159,11 +159,26 @@ export async function listDbBackups() {
|
||||
|
||||
export async function restoreDbBackup(backupId: string) {
|
||||
const backupDir = DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups");
|
||||
const backupPath = path.join(backupDir, backupId);
|
||||
|
||||
if (!backupId.startsWith("db_") || !backupId.endsWith(".sqlite")) {
|
||||
// Validate format: must be db_<timestamp>_<reason>.sqlite, no path separators
|
||||
if (
|
||||
!backupId.startsWith("db_") ||
|
||||
!backupId.endsWith(".sqlite") ||
|
||||
backupId.includes(path.sep) ||
|
||||
backupId.includes("/")
|
||||
) {
|
||||
throw new Error("Invalid backup ID");
|
||||
}
|
||||
|
||||
const backupPath = path.resolve(backupDir, backupId);
|
||||
// Prevent path traversal: resolved path must stay within backupDir
|
||||
if (
|
||||
!backupPath.startsWith(path.resolve(backupDir) + path.sep) &&
|
||||
backupPath !== path.resolve(backupDir)
|
||||
) {
|
||||
throw new Error("Invalid backup ID: path traversal detected");
|
||||
}
|
||||
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
throw new Error(`Backup not found: ${backupId}`);
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ const pendingRequests: {
|
||||
byModel: Record<string, number>;
|
||||
byAccount: Record<string, Record<string, number>>;
|
||||
} = {
|
||||
byModel: {},
|
||||
byAccount: {},
|
||||
byModel: Object.create(null) as Record<string, number>,
|
||||
byAccount: Object.create(null) as Record<string, Record<string, number>>,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -50,16 +50,22 @@ export function trackPendingRequest(
|
||||
) {
|
||||
const modelKey = provider ? `${model} (${provider})` : model;
|
||||
|
||||
if (!pendingRequests.byModel[modelKey]) pendingRequests.byModel[modelKey] = 0;
|
||||
// Use hasOwnProperty guard to prevent prototype pollution via crafted keys
|
||||
if (!Object.prototype.hasOwnProperty.call(pendingRequests.byModel, modelKey)) {
|
||||
pendingRequests.byModel[modelKey] = 0;
|
||||
}
|
||||
pendingRequests.byModel[modelKey] = Math.max(
|
||||
0,
|
||||
pendingRequests.byModel[modelKey] + (started ? 1 : -1)
|
||||
);
|
||||
|
||||
if (connectionId) {
|
||||
if (!pendingRequests.byAccount[connectionId]) pendingRequests.byAccount[connectionId] = {};
|
||||
if (!pendingRequests.byAccount[connectionId][modelKey])
|
||||
if (!Object.prototype.hasOwnProperty.call(pendingRequests.byAccount, connectionId)) {
|
||||
pendingRequests.byAccount[connectionId] = Object.create(null) as Record<string, number>;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(pendingRequests.byAccount[connectionId], modelKey)) {
|
||||
pendingRequests.byAccount[connectionId][modelKey] = 0;
|
||||
}
|
||||
pendingRequests.byAccount[connectionId][modelKey] = Math.max(
|
||||
0,
|
||||
pendingRequests.byAccount[connectionId][modelKey] + (started ? 1 : -1)
|
||||
|
||||
+17
-3
@@ -45,12 +45,22 @@ const CHAT_URL_PATTERNS = [":generateContent", ":streamGenerateContent"];
|
||||
const LOG_DIR = path.join(__dirname, "../../logs/mitm");
|
||||
if (ENABLE_FILE_LOG && !fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
|
||||
// Safe log filename: only alphanumeric + hyphens, anchored inside LOG_DIR
|
||||
function safeLogPath(name) {
|
||||
const safe = name.replace(/[^a-zA-Z0-9_\-]/g, "_").substring(0, 80);
|
||||
const resolved = path.resolve(LOG_DIR, safe);
|
||||
if (!resolved.startsWith(path.resolve(LOG_DIR) + path.sep)) {
|
||||
throw new Error("Path traversal attempt detected in log filename");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function saveRequestLog(url, bodyBuffer) {
|
||||
if (!ENABLE_FILE_LOG) return;
|
||||
try {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const urlSlug = url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 60);
|
||||
const filePath = path.join(LOG_DIR, `${ts}_${urlSlug}.json`);
|
||||
const filePath = safeLogPath(`${ts}_${urlSlug}.json`);
|
||||
const body = JSON.parse(bodyBuffer.toString());
|
||||
fs.writeFileSync(filePath, JSON.stringify(body, null, 2));
|
||||
console.log(`💾 Saved request: ${filePath}`);
|
||||
@@ -64,7 +74,7 @@ function saveResponseLog(url, data) {
|
||||
try {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const urlSlug = url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 60);
|
||||
const filePath = path.join(LOG_DIR, `${ts}_${urlSlug}_response.txt`);
|
||||
const filePath = safeLogPath(`${ts}_${urlSlug}_response.txt`);
|
||||
fs.writeFileSync(filePath, data);
|
||||
console.log(`💾 Saved response: ${filePath}`);
|
||||
} catch {
|
||||
@@ -156,6 +166,10 @@ function getMappedModel(model) {
|
||||
async function passthrough(req, res, bodyBuffer) {
|
||||
const targetIP = await resolveTargetIP();
|
||||
|
||||
// TLS validation is enabled by default. Set MITM_DISABLE_TLS_VERIFY=1 only
|
||||
// in controlled local environments where the target uses a self-signed cert.
|
||||
const rejectUnauthorized = process.env.MITM_DISABLE_TLS_VERIFY !== "1";
|
||||
|
||||
const forwardReq = https.request(
|
||||
{
|
||||
hostname: targetIP,
|
||||
@@ -164,7 +178,7 @@ async function passthrough(req, res, bodyBuffer) {
|
||||
method: req.method,
|
||||
headers: { ...req.headers, host: TARGET_HOST },
|
||||
servername: TARGET_HOST,
|
||||
rejectUnauthorized: false,
|
||||
rejectUnauthorized,
|
||||
},
|
||||
(forwardRes) => {
|
||||
res.writeHead(forwardRes.statusCode, forwardRes.headers);
|
||||
|
||||
@@ -5,11 +5,24 @@ import { resolveDataDir } from "@/lib/dataPaths";
|
||||
const BACKUP_DIR = path.join(resolveDataDir(), "backups");
|
||||
const MAX_BACKUPS_PER_TOOL = 5;
|
||||
|
||||
/**
|
||||
* Resolve a path within BACKUP_DIR and verify it stays within bounds.
|
||||
* Throws if the resolved path escapes BACKUP_DIR (path traversal guard).
|
||||
*/
|
||||
function safePath(...segments: string[]): string {
|
||||
const resolved = path.resolve(BACKUP_DIR, ...segments);
|
||||
const base = path.resolve(BACKUP_DIR);
|
||||
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
|
||||
throw new Error("Invalid path: directory traversal detected");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup directory for a specific tool
|
||||
*/
|
||||
function getToolBackupDir(toolId: string) {
|
||||
return path.join(BACKUP_DIR, toolId);
|
||||
return safePath(toolId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,7 +149,8 @@ export async function listBackups(toolId: string) {
|
||||
*/
|
||||
export async function restoreBackup(toolId: string, backupId: string) {
|
||||
const dir = getToolBackupDir(toolId);
|
||||
const backupPath = path.join(dir, backupId);
|
||||
// Anchor backupId within the tool dir — prevent path traversal via backupId
|
||||
const backupPath = safePath(toolId, backupId);
|
||||
const metaPath = backupPath + ".meta.json";
|
||||
|
||||
// Read metadata to find original path
|
||||
@@ -174,8 +188,8 @@ export async function restoreBackup(toolId: string, backupId: string) {
|
||||
* Delete a specific backup by its id.
|
||||
*/
|
||||
export async function deleteBackup(toolId: string, backupId: string) {
|
||||
const dir = getToolBackupDir(toolId);
|
||||
const backupPath = path.join(dir, backupId);
|
||||
// Anchor backupId within the tool dir — prevent path traversal via backupId
|
||||
const backupPath = safePath(toolId, backupId);
|
||||
const metaPath = backupPath + ".meta.json";
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user