From 621f30a1883e986e09e7ff44bc6f07442a24e294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A6=8D=E5=84=BF=20=E2=9C=A8?= Date: Fri, 21 Aug 2026 02:07:19 +0800 Subject: [PATCH] fix(cli): restore packaged machine-token authentication (#10468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Obrigado por restaurar e endurecer a autenticação por machine-token no CLI empacotado. Validação (worktree combinado a partir de origin/release/v3.8.50, merge limpo, 0 conflitos — 34 arquivos, +1078/-247): - `npm run typecheck:core` — limpo - `node scripts/check/check-complexity.mjs` — OK (2558 violações vs baseline 2774) - `node scripts/check/check-cognitive-complexity.mjs` — OK (1152 violações vs baseline 1223) - `node scripts/check/check-file-size.mjs` — OK - `node scripts/check/check-changelog-integrity.mjs` — OK - Testes focados (8 arquivos: cli-doctor-command, cli-machine-token, lib/machineToken, lib/managementCliToken, agentSkills-generator, api/settings-audit, check-pack-boot, next-config) — 95/95 passando Os dois achados de segurança do maintainer-feedback original (checagem de loopback tipo SSRF, escopo de cookie/CSRF) já estavam corrigidos e cobertos por teste no commit `2b785f0068a862fbd867221294325ad921787782` desta branch. --- bin/cli/api.mjs | 36 ++++- bin/cli/commands/doctor.mjs | 99 ++++++++++++- bin/cli/utils/cliToken.mjs | 34 +++-- docs/security/CLI_TOKEN.md | 23 ++-- scripts/build/postinstall.mjs | 25 ++++ scripts/check/check-pack-boot.mjs | 134 ++++++++++++++++-- skills/omni-api-keys/SKILL.md | 4 +- skills/omni-auth/SKILL.md | 14 +- skills/omni-budget/SKILL.md | 2 +- skills/omni-cli-tools/SKILL.md | 26 ++-- skills/omni-combos-routing/SKILL.md | 8 +- skills/omni-compression/SKILL.md | 2 +- skills/omni-context-rtk/SKILL.md | 6 +- skills/omni-inference/SKILL.md | 38 ++--- skills/omni-models/SKILL.md | 2 +- skills/omni-providers/SKILL.md | 24 ++-- skills/omni-settings/SKILL.md | 34 ++--- skills/omni-sync-cloud/SKILL.md | 12 +- skills/omni-usage-logs/SKILL.md | 2 +- skills/omni-version-manager/SKILL.md | 78 +++++------ src/app/api/settings/route.ts | 3 + src/lib/agentSkills/generator.ts | 55 ++++++-- src/lib/api/requireManagementAuth.ts | 13 +- src/lib/machineToken.ts | 27 ++-- src/server/authz/pipeline.ts | 6 + src/server/authz/policies/management.ts | 1 + tests/unit/agentSkills-generator.test.ts | 135 +++++++++++------- tests/unit/api/settings-audit.test.ts | 22 +++ tests/unit/check-pack-boot.test.ts | 72 ++++++++++ tests/unit/cli-doctor-command.test.ts | 161 ++++++++++++++++++++++ tests/unit/cli-machine-token.test.ts | 150 ++++++++++++++++++++ tests/unit/lib/machineToken.test.ts | 28 +++- tests/unit/lib/managementCliToken.test.ts | 37 ++++- tests/unit/next-config.test.ts | 12 +- 34 files changed, 1078 insertions(+), 247 deletions(-) diff --git a/bin/cli/api.mjs b/bin/cli/api.mjs index fff6cf0829..6534f91095 100644 --- a/bin/cli/api.mjs +++ b/bin/cli/api.mjs @@ -52,6 +52,19 @@ function resolveUrl(path, opts) { return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`; } +/** The machine-derived token is valid only for the local loopback server. */ +export function isLoopbackUrl(value) { + try { + const hostname = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase(); + if (hostname === "localhost" || hostname === "::1") return true; + if (/^127(?:\.[0-9]{1,3}){3}$/.test(hostname)) return true; + if (/^::ffff:(?:127\.|7f[0-9a-f]{2}:)/i.test(hostname)) return true; + return false; + } catch { + return false; + } +} + export async function buildHeaders(opts) { const headers = new Headers(opts.headers || {}); if (!headers.has("accept")) headers.set("accept", "application/json"); @@ -87,10 +100,17 @@ export async function buildHeaders(opts) { if (auth && !headers.has("authorization")) { headers.set("authorization", `Bearer ${auth}`); } - // Inject machine-id derived CLI token; env var override for testing. - const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken()); - if (cliToken && !headers.has(CLI_TOKEN_HEADER)) { - headers.set(CLI_TOKEN_HEADER, cliToken); + // Inject the machine-derived credential only for an explicit local loopback + // destination. Remote contexts and absolute remote URLs use scoped access + // tokens and must never receive this machine-bound local credential. + const destinationUrl = opts.destinationUrl ?? getBaseUrl(opts); + if (!isLoopbackUrl(destinationUrl)) { + headers.delete(CLI_TOKEN_HEADER); + } else { + const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken()); + if (cliToken && !headers.has(CLI_TOKEN_HEADER)) { + headers.set(CLI_TOKEN_HEADER, cliToken); + } } if (opts.idempotencyKey && !headers.has("idempotency-key")) { headers.set("idempotency-key", opts.idempotencyKey); @@ -195,8 +215,12 @@ function fetchOnce(url, init, timeoutMs) { export async function apiFetch(path, opts = {}) { const method = String(opts.method || "GET").toUpperCase(); const url = resolveUrl(path, opts); - const headers = await buildHeaders(opts); + const headers = await buildHeaders({ ...opts, destinationUrl: url }); const body = serializeBody(opts.body, headers); + // Undici preserves custom headers across cross-origin redirects. A local server + // redirect must never turn the loopback machine credential into an outbound + // secret, so fail redirects whenever this header is present. + const redirect = headers.has(CLI_TOKEN_HEADER) ? "error" : opts.redirect; const timeout = opts.timeout ?? (Number.parseInt(process.env.OMNIROUTE_HTTP_TIMEOUT_MS || "", 10) || 30000); const maxAttempts = opts.retry === false ? 1 : (opts.retryMax ?? RETRY_DEFAULTS.maxAttempts); @@ -205,7 +229,7 @@ export async function apiFetch(path, opts = {}) { let lastErr; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - const res = await fetchOnce(url, { method, headers, body }, timeout); + const res = await fetchOnce(url, { method, headers, body, redirect }, timeout); if (res.ok) return enrichResponse(res, opts); if (attempt < maxAttempts && shouldRetryStatus(res.status, method, opts)) { const delay = computeBackoff(attempt, res.headers.get("retry-after")); diff --git a/bin/cli/commands/doctor.mjs b/bin/cli/commands/doctor.mjs index 9ac34bf636..817013dd19 100644 --- a/bin/cli/commands/doctor.mjs +++ b/bin/cli/commands/doctor.mjs @@ -4,7 +4,9 @@ import os from "node:os"; import path from "node:path"; import { createDecipheriv, scryptSync } from "node:crypto"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { isLoopbackUrl } from "../api.mjs"; import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs"; +import { getCliToken, CLI_TOKEN_HEADER } from "../utils/cliToken.mjs"; import { printHeading } from "../io.mjs"; import { t } from "../i18n.mjs"; import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs"; @@ -378,11 +380,11 @@ function checkMemory() { }); } -async function fetchWithTimeout(url) { +async function fetchWithTimeout(url, options = {}) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS); try { - return await fetch(url, { signal: controller.signal }); + return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timeout); } @@ -471,6 +473,98 @@ async function checkServerLiveness(options = {}) { ); } +export async function checkMachineTokenAuth(options = {}) { + if (process.env.OMNIROUTE_DISABLE_CLI_TOKEN === "true") { + return warn("CLI machine token", "CLI machine-token authentication is disabled", { + derived: false, + accepted: false, + disabled: true, + tokenExposed: false, + }); + } + + let url; + try { + const parsed = new URL(resolveLivenessUrl(options)); + if ( + !["http:", "https:"].includes(parsed.protocol) || + parsed.username || + parsed.password || + !isLoopbackUrl(parsed.toString()) + ) { + return warn( + "CLI machine token", + "Machine-token probes are limited to HTTP(S) loopback endpoints", + { derived: false, accepted: false, tokenExposed: false } + ); + } + parsed.pathname = "/api/cli/whoami"; + parsed.search = ""; + parsed.hash = ""; + url = parsed.toString(); + } catch { + return warn("CLI machine token", "Could not resolve the management endpoint", { + derived: false, + accepted: false, + tokenExposed: false, + }); + } + + const token = await getCliToken(); + if (!token) { + return fail( + "CLI machine token", + "Could not derive a machine token; verify the node-machine-id runtime is installed", + { derived: false, accepted: false, tokenExposed: false } + ); + } + + try { + const response = await fetchWithTimeout(url, { + headers: { [CLI_TOKEN_HEADER]: token }, + redirect: "error", + }); + if (response.ok) { + return ok("CLI machine token", "Server accepted the local machine token", { + url, + status: response.status, + derived: true, + accepted: true, + tokenExposed: false, + }); + } + if (response.status === 401 || response.status === 403) { + return warn( + "CLI machine token", + "Server rejected the local machine token; if the CLI and server are on different hosts or container boundaries, run `omniroute connect --key `", + { + url, + status: response.status, + derived: true, + accepted: false, + containerBoundaryLikely: true, + tokenExposed: false, + } + ); + } + return warn("CLI machine token", `Machine-token probe returned HTTP ${response.status}`, { + url, + status: response.status, + derived: true, + accepted: false, + tokenExposed: false, + }); + } catch { + return warn("CLI machine token", "Machine-token endpoint could not be reached", { + url, + status: 0, + derived: true, + accepted: false, + tokenExposed: false, + }); + } +} + export async function collectDoctorChecks(context = {}, options = {}) { const rootDir = context.rootDir || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); @@ -488,6 +582,7 @@ export async function collectDoctorChecks(context = {}, options = {}) { if (!options.skipLiveness) { checks.push(await checkServerLiveness(options)); + checks.push(await checkMachineTokenAuth(options)); } // CLI tool health checks diff --git a/bin/cli/utils/cliToken.mjs b/bin/cli/utils/cliToken.mjs index 94691ba952..38895c13bf 100644 --- a/bin/cli/utils/cliToken.mjs +++ b/bin/cli/utils/cliToken.mjs @@ -12,25 +12,39 @@ function getActiveSalt() { return process.env.OMNIROUTE_CLI_SALT || BUILTIN_DEFAULT_SALT; } -export async function getCliToken() { - const salt = getActiveSalt(); - if (_cached !== null && _cachedSalt === salt) return _cached; +export function deriveCliToken(machineIdModule, salt) { try { // node-machine-id is CommonJS: under `await import()` its exports land on // `.default`, so destructuring `machineIdSync` off the namespace yields // undefined and calling it throws — which the catch below turned into an // empty token, silently disabling CLI auth for every management request. // Same resolution order as src/lib/machineToken.ts. - const mod = await import("node-machine-id"); - const machineIdSync = mod.machineIdSync ?? mod.default?.machineIdSync; - if (typeof machineIdSync !== "function") throw new Error("machine-id API unavailable"); + const machineIdSync = + machineIdModule?.machineIdSync || machineIdModule?.default?.machineIdSync; + if (typeof machineIdSync !== "function") return ""; // machineIdSync(true) returns the original unhashed hardware ID — mirrors // getMachineTokenSync() in src/lib/machineToken.ts (#10148 cliToken hardening). - const mid = machineIdSync(true); - _cached = crypto.createHmac("sha256", mid).update(salt).digest("hex"); + const rawId = machineIdSync(true); + if (!rawId) return ""; + return crypto.createHmac("sha256", rawId).update(salt).digest("hex"); + } catch { + return ""; + } +} + +export async function getCliToken() { + const salt = getActiveSalt(); + if (_cached !== null && _cachedSalt === salt) return _cached; + try { + const imported = await import("node-machine-id"); + const token = deriveCliToken(imported, salt); + if (!token) { + // Swallowing here changes control flow (every management call goes out + // unauthenticated and 401s), so leave a breadcrumb rather than failing mute. + console.debug("[CLI_TOKEN] machine-id resolution failed, CLI auth disabled"); + } + _cached = token; } catch (e) { - // Swallowing here changes control flow (every management call goes out - // unauthenticated and 401s), so leave a breadcrumb rather than failing mute. console.debug("[CLI_TOKEN] machine-id resolution failed, CLI auth disabled:", e); _cached = ""; } diff --git a/docs/security/CLI_TOKEN.md b/docs/security/CLI_TOKEN.md index 1e00e22334..4d3383229d 100644 --- a/docs/security/CLI_TOKEN.md +++ b/docs/security/CLI_TOKEN.md @@ -20,21 +20,26 @@ password on every invocation. (falls back to an empty string on failure, disabling CLI auth). 2. It computes `HMAC-SHA256(machine_id, salt)` and returns the full 64-char hex digest — a deterministic, non-reversible token tied to this machine. -3. The CLI sends the token as `x-omniroute-cli-token` on every request to - `http://localhost:/api/...`. +3. The CLI sends the token as `x-omniroute-cli-token` only when the resolved + destination is an explicit loopback URL (`localhost`, `127.0.0.0/8`, or + loopback IPv6). Requests carrying the token use `redirect: error`, so a local + redirect cannot forward it to another origin. Remote contexts use scoped + access tokens instead. If derivation is unavailable, the CLI omits the header + and `omniroute doctor` reports the failure instead of treating an empty token + as valid. 4. The server (`src/server/authz/policies/management.ts`) recomputes the expected token with the same salt and compares via `timingSafeEqual` to prevent timing-based extraction. ## Security properties -| Property | Detail | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| **Loopback-only** | Accepted only when `Host` is `localhost`, `127.0.0.1`, or `::1`. | -| **Constant-time compare** | `crypto.timingSafeEqual` prevents timing attacks. | -| **Non-reversible** | HMAC output cannot recover the machine-id. | -| **No `always`-protected bypass** | `isAlwaysProtectedPath()` is evaluated before the CLI token check. `/api/shutdown` and `/api/settings/database` always require JWT. | -| **Non-exportable** | Token is never written to disk or logged. | +| Property | Detail | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Loopback-only** | Accepted only when the server's trusted peer-locality stamp (derived from the real TCP peer address) says loopback. The client-controlled `Host` header is never trusted for locality. | +| **Constant-time compare** | `crypto.timingSafeEqual` prevents timing attacks. | +| **Non-reversible** | HMAC output cannot recover the machine-id. | +| **No `always`-protected bypass** | `isAlwaysProtectedPath()` is evaluated before the CLI token check. `/api/shutdown` and `/api/settings/database` always require JWT. | +| **Non-exportable** | Token is never written to disk or logged. | ## Salt rotation diff --git a/scripts/build/postinstall.mjs b/scripts/build/postinstall.mjs index 9570691b46..1628aca7cf 100644 --- a/scripts/build/postinstall.mjs +++ b/scripts/build/postinstall.mjs @@ -16,6 +16,8 @@ * - better-sqlite3 (SQLite bindings) * - wreq-js (TLS client for OAuth providers) * - tls-client-node (TLS client for chatgpt-web/claude-web/grok-web/lmarena/perplexity-web) + * - sql.js (WASM SQLite fallback runtime) + * - node-machine-id (local CLI machine-token server runtime) * * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/129 * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/321 @@ -33,6 +35,7 @@ import { readdirSync, writeFileSync, } from "node:fs"; +import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -45,6 +48,7 @@ import { fixPlaywrightAndroid } from "./fixPlaywrightAndroid.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const ROOT = join(__dirname, "..", ".."); +const requireFromPackage = createRequire(join(ROOT, "package.json")); /** * Patch node-gyp's common.gypi to include the android_ndk_path variable. @@ -437,12 +441,33 @@ async function verifyDevNativeModules() { } } +async function ensureStandaloneRuntimePackages() { + for (const packageName of ["sql.js", "node-machine-id"]) { + let source; + try { + source = dirname(dirname(requireFromPackage.resolve(packageName))); + } catch { + console.warn(` ⚠️ ${packageName} could not be resolved from the npm install.`); + continue; + } + const destination = join(ROOT, "dist", "node_modules", packageName); + try { + mkdirSync(dirname(destination), { recursive: true }); + cpSync(source, destination, { recursive: true, force: true }); + console.log(` ✅ ${packageName} copied to standalone dist/node_modules.`); + } catch (err) { + console.warn(` ⚠️ Could not copy ${packageName}: ${err.message}`); + } + } +} + await verifyDevNativeModules(); await fixBetterSqliteBinary(); await fixWreqJsBinary(); await fixTlsClientNodeBinary({ rootDir: ROOT }); await fixPlaywrightAndroid({ rootDir: ROOT }); await ensureSwcHelpers(); +await ensureStandaloneRuntimePackages(); await ensureLlmlinguaOptionals(); await syncProjectEnv(); diff --git a/scripts/check/check-pack-boot.mjs b/scripts/check/check-pack-boot.mjs index 9eabab477a..673decdd21 100644 --- a/scripts/check/check-pack-boot.mjs +++ b/scripts/check/check-pack-boot.mjs @@ -14,13 +14,17 @@ * 0 = boots and reports the right version · 1 = boot failed · 2 = missing build. */ import { execFileSync, spawn } from "node:child_process"; +import { createHmac } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; const POLL_INTERVAL_MS = 2_000; const BOOT_DEADLINE_MS = 240_000; +const MAX_SERVER_OUTPUT_CHARS = 1_000_000; const SQLJS_STARTUP_MARKER = "Pre-initializing sql.js WASM"; +const DEFAULT_CLI_SALT = "omniroute-cli-auth-v1"; export const REQUIRED_SQLJS_RUNTIME_FILES = Object.freeze([ "dist/node_modules/sql.js/package.json", @@ -28,6 +32,11 @@ export const REQUIRED_SQLJS_RUNTIME_FILES = Object.freeze([ "dist/node_modules/sql.js/dist/sql-wasm.wasm", ]); +export const REQUIRED_MACHINE_TOKEN_RUNTIME_FILES = Object.freeze([ + "node_modules/node-machine-id/package.json", + "node_modules/node-machine-id/index.js", +]); + /** Parse `npm pack --json` output into the generated tarball filename. */ export function pickTarball(packJsonOutput) { const parsed = JSON.parse(packJsonOutput); @@ -62,6 +71,39 @@ export function findMissingSqlJsRuntimeFiles(packageRoot, exists = fs.existsSync ); } +export function findMissingMachineTokenRuntimeFiles(packageRoot, exists = fs.existsSync) { + return REQUIRED_MACHINE_TOKEN_RUNTIME_FILES.filter( + (relativePath) => !exists(path.join(packageRoot, relativePath)) + ); +} + +export function evaluateMachineTokenAuth({ + cliToken, + unauthenticatedStatus, + invalidStatus, + authenticatedStatus, + salt = process.env.OMNIROUTE_CLI_SALT || DEFAULT_CLI_SALT, +}) { + const failures = []; + if (!/^[0-9a-f]{64}$/.test(cliToken || "")) { + failures.push("packaged CLI derived an empty or malformed machine token"); + } + const emptyMachineIdToken = createHmac("sha256", "").update(salt).digest("hex"); + if (cliToken === emptyMachineIdToken) { + failures.push("packaged CLI derived the public empty-machine-id token"); + } + if (unauthenticatedStatus !== 401) { + failures.push(`no-credential request returned ${unauthenticatedStatus} (expected 401)`); + } + if (invalidStatus !== 401) { + failures.push(`invalid-token request returned ${invalidStatus} (expected 401)`); + } + if (authenticatedStatus !== 200) { + failures.push(`packaged CLI token request returned ${authenticatedStatus} (expected 200)`); + } + return { ok: failures.length === 0, failures }; +} + export function evaluateSqlJsRoundTrip({ startupOutput, beforeValue, @@ -106,8 +148,9 @@ async function readJsonResponse(url, options) { return { response, body }; } -async function verifySettingsRoundTrip(baseUrl, startupOutput) { - const initial = await readJsonResponse(`${baseUrl}/api/settings`); +async function verifySettingsRoundTrip(baseUrl, startupOutput, cliToken) { + const authHeaders = { "x-omniroute-cli-token": cliToken }; + const initial = await readJsonResponse(`${baseUrl}/api/settings`, { headers: authHeaders }); if (initial.response.status !== 200 || !initial.body || typeof initial.body !== "object") { return { ok: false, @@ -119,7 +162,7 @@ async function verifySettingsRoundTrip(baseUrl, startupOutput) { const expectedValue = !beforeValue; const patched = await readJsonResponse(`${baseUrl}/api/settings`, { method: "PATCH", - headers: { "Content-Type": "application/json" }, + headers: { ...authHeaders, "Content-Type": "application/json" }, body: JSON.stringify({ debugMode: expectedValue }), }); if (patched.response.status !== 200 || !patched.body || typeof patched.body !== "object") { @@ -129,7 +172,7 @@ async function verifySettingsRoundTrip(baseUrl, startupOutput) { }; } - const readBack = await readJsonResponse(`${baseUrl}/api/settings`); + const readBack = await readJsonResponse(`${baseUrl}/api/settings`, { headers: authHeaders }); if (readBack.response.status !== 200 || !readBack.body || typeof readBack.body !== "object") { return { ok: false, @@ -242,22 +285,61 @@ function spawnServer(binPath, port, dataDir) { OMNIROUTE_SKIP_SYSTEM_TRUST: "1", OMNIROUTE_PACK_BOOT_SMOKE: "1", OMNIROUTE_PACK_BOOT_FORCE_SQLJS: "1", + INITIAL_PASSWORD: "pack-boot-machine-token-auth-required", }, stdio: ["ignore", "pipe", "pipe"], detached: true, }); const tail = []; + let retainedChars = 0; const keepTail = (chunk) => { - tail.push(String(chunk)); - while (tail.length > 80) tail.shift(); + const text = String(chunk); + tail.push(text); + retainedChars += text.length; + while (retainedChars > MAX_SERVER_OUTPUT_CHARS && tail.length > 1) { + retainedChars -= tail.shift().length; + } }; child.stdout.on("data", keepTail); child.stderr.on("data", keepTail); return { child, tail }; } +function derivePackagedCliToken(packageRoot) { + const cliModuleUrl = pathToFileURL( + path.join(packageRoot, "bin", "cli", "utils", "cliToken.mjs") + ).href; + return execFileSync( + process.execPath, + [ + "--input-type=module", + "--eval", + "import(process.argv[1]).then(async m => process.stdout.write(await m.getCliToken()))", + cliModuleUrl, + ], + { encoding: "utf8", env: { ...process.env } } + ).trim(); +} + +async function verifyMachineTokenAuth(baseUrl, cliToken) { + const endpoint = `${baseUrl}/api/cli/whoami`; + const unauthenticatedStatus = (await fetch(endpoint)).status; + const invalidStatus = ( + await fetch(endpoint, { headers: { "x-omniroute-cli-token": "0".repeat(64) } }) + ).status; + const authenticatedStatus = ( + await fetch(endpoint, { headers: { "x-omniroute-cli-token": cliToken } }) + ).status; + return evaluateMachineTokenAuth({ + cliToken, + unauthenticatedStatus, + invalidStatus, + authenticatedStatus, + }); +} + /** Poll /api/monitoring/health until the packed version answers or the boot deadline passes. */ -async function waitForHealthy(port, child, expectedVersion) { +async function waitForHealthy(port, child, expectedVersion, cliToken) { // Seed from authoritative state (Node sets these synchronously at death), then attach a // named once-listener, then re-check: a child that died before this call, or in the gap // before the listener attached, would otherwise never fire "exit" and waste the deadline. @@ -279,7 +361,9 @@ async function waitForHealthy(port, child, expectedVersion) { return { ok: false, failures: [`process exited (${childExit}) before serving`] }; } try { - const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`); + const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`, { + headers: { "x-omniroute-cli-token": cliToken }, + }); const body = await res.json().catch(() => null); verdict = evaluateBoot(res.status, body, expectedVersion); if (verdict.ok) return verdict; @@ -299,8 +383,10 @@ async function waitForHealthy(port, child, expectedVersion) { * field throws: coercing with `=== true` would read `false` for a malformed response and * could falsely "pass" persistence whenever the expected value happens to be false. */ -async function readSettingsDebugMode(baseUrl) { - const { response, body } = await readJsonResponse(`${baseUrl}/api/settings`); +async function readSettingsDebugMode(baseUrl, cliToken) { + const { response, body } = await readJsonResponse(`${baseUrl}/api/settings`, { + headers: { "x-omniroute-cli-token": cliToken }, + }); if (response.status !== 200 || !body || typeof body !== "object") { throw new Error(`settings GET HTTP ${response.status} or non-JSON body`); } @@ -350,21 +436,38 @@ async function main() { ); } log("installed package contains the complete sql.js WASM runtime"); + const missingMachineTokenFiles = findMissingMachineTokenRuntimeFiles(packageRoot); + if (missingMachineTokenFiles.length > 0) { + throw new Error( + `installed package is missing the node-machine-id runtime contract: ${missingMachineTokenFiles.join(", ")}` + ); + } + log("installed package contains the node-machine-id runtime"); const port = pickPort(); const dataDir = path.join(tmp, "data"); fs.mkdirSync(dataDir, { recursive: true }); const binPath = path.join(prefix, "bin", "omniroute"); + const packagedCliToken = derivePackagedCliToken(packageRoot); // BOOT #1 — boot, prove the forced sql.js tier, PATCH a setting, then shut down cleanly // so the sql.js adapter's graceful persist actually lands on disk. The in-flow stopChild // THROWS on failure; that lands in catch as primaryError and boot #2 never starts. log(`boot #1: installed CLI on :${port} (DATA_DIR isolated)…`); ({ child, tail } = spawnServer(binPath, port, dataDir)); - let verdict = await waitForHealthy(port, child, expectedVersion); + let verdict = await waitForHealthy(port, child, expectedVersion, packagedCliToken); if (verdict.ok) { log(`healthy: HTTP 200, version ${expectedVersion}`); - const roundTrip = await verifySettingsRoundTrip(`http://127.0.0.1:${port}`, tail.join("")); + const baseUrl = `http://127.0.0.1:${port}`; + const machineAuth = await verifyMachineTokenAuth(baseUrl, packagedCliToken); + if (!machineAuth.ok) { + verdict = machineAuth; + } else { + log("machine-token auth passed with no/invalid/valid contrast controls"); + } + const roundTrip = verdict.ok + ? await verifySettingsRoundTrip(baseUrl, tail.join(""), packagedCliToken) + : { ok: false, failures: verdict.failures }; if (roundTrip.ok) { log("settings write/read succeeded through the forced sql.js driver"); await stopChild(child); // throws here → primaryError; boot #2 is skipped @@ -373,10 +476,13 @@ async function main() { // BOOT #2 — same DATA_DIR, fresh process: the value must be read back FROM DISK. log("boot #2: rebooting on the same DATA_DIR to prove disk persistence…"); ({ child, tail } = spawnServer(binPath, port, dataDir)); - verdict = await waitForHealthy(port, child, expectedVersion); + verdict = await waitForHealthy(port, child, expectedVersion, packagedCliToken); if (verdict.ok) { log(`healthy: HTTP 200, version ${expectedVersion}`); - const restartValue = await readSettingsDebugMode(`http://127.0.0.1:${port}`); + const restartValue = await readSettingsDebugMode( + `http://127.0.0.1:${port}`, + packagedCliToken + ); const persistence = evaluateRestartPersistence({ expectedValue: roundTrip.expectedValue, restartValue, diff --git a/skills/omni-api-keys/SKILL.md b/skills/omni-api-keys/SKILL.md index 7e6169879d..7501714b01 100644 --- a/skills/omni-api-keys/SKILL.md +++ b/skills/omni-api-keys/SKILL.md @@ -29,7 +29,7 @@ Create API key ```bash curl -X POST https://localhost:20128/api/keys \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -49,7 +49,7 @@ Update API key ```bash curl -X PATCH https://localhost:20128/api/keys/{id} \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-auth/SKILL.md b/skills/omni-auth/SKILL.md index 9fdbebb75a..cd909eb51d 100644 --- a/skills/omni-auth/SKILL.md +++ b/skills/omni-auth/SKILL.md @@ -10,7 +10,7 @@ Manage API key authentication and session tokens. Start here to authenticate req ## Authentication -All requests require a valid Bearer token or session cookie. Obtain a token via `POST /api/auth/login` or configure `REQUIRE_API_KEY=false` for local development. +Remote API requests use a Bearer credential. Dashboard login is different: `POST /api/auth/login` accepts a management password and returns an `auth_token` session cookie. ## Endpoints @@ -20,9 +20,9 @@ Authenticate user ```bash curl -X POST https://localhost:20128/api/auth/login \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" -H "Content-Type: application/json" \ - -d '{}' + -c cookie.jar \ + -d '{"password":""}' ``` ### POST /api/auth/logout @@ -30,8 +30,10 @@ curl -X POST https://localhost:20128/api/auth/login \ Log out ```bash +CSRF_TOKEN=$(curl -s https://localhost:20128/api/auth/csrf -b cookie.jar | jq -r .token) curl -X POST https://localhost:20128/api/auth/logout \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -b cookie.jar \ + -H "x-omniroute-csrf: $CSRF_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -48,7 +50,7 @@ remains available as a fallback while OIDC is enabled. ```bash curl https://localhost:20128/api/auth/oidc/login \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -b cookie.jar ``` ### GET /api/auth/oidc/callback @@ -64,7 +66,7 @@ JWT used by password login and redirects to `/dashboard`. ```bash curl https://localhost:20128/api/auth/oidc/callback \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -b cookie.jar ``` ## Payloads diff --git a/skills/omni-budget/SKILL.md b/skills/omni-budget/SKILL.md index 09744616d2..a7b8fd67d1 100644 --- a/skills/omni-budget/SKILL.md +++ b/skills/omni-budget/SKILL.md @@ -29,7 +29,7 @@ Update rate limit configuration ```bash curl -X POST https://localhost:20128/api/rate-limit \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-cli-tools/SKILL.md b/skills/omni-cli-tools/SKILL.md index 76c3e54b23..b5376cf960 100644 --- a/skills/omni-cli-tools/SKILL.md +++ b/skills/omni-cli-tools/SKILL.md @@ -29,7 +29,7 @@ Create CLI tool backup ```bash curl -X POST https://localhost:20128/api/cli-tools/backups \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -67,7 +67,7 @@ Update Antigravity MITM proxy settings ```bash curl -X POST https://localhost:20128/api/cli-tools/antigravity-mitm \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -96,7 +96,7 @@ Update Antigravity MITM alias configuration ```bash curl -X PUT https://localhost:20128/api/cli-tools/antigravity-mitm/alias \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -116,7 +116,7 @@ Apply Claude CLI settings ```bash curl -X POST https://localhost:20128/api/cli-tools/claude-settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -145,7 +145,7 @@ Apply Cline CLI settings ```bash curl -X POST https://localhost:20128/api/cli-tools/cline-settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -174,7 +174,7 @@ Create Codex profile ```bash curl -X POST https://localhost:20128/api/cli-tools/codex-profiles \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -185,7 +185,7 @@ Update Codex profile ```bash curl -X PUT https://localhost:20128/api/cli-tools/codex-profiles \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -214,7 +214,7 @@ Apply Codex CLI settings ```bash curl -X POST https://localhost:20128/api/cli-tools/codex-settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -243,7 +243,7 @@ Apply Droid CLI settings ```bash curl -X POST https://localhost:20128/api/cli-tools/droid-settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -272,7 +272,7 @@ Apply Kilo CLI settings ```bash curl -X POST https://localhost:20128/api/cli-tools/kilo-settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -301,7 +301,7 @@ Apply OpenClaw CLI settings ```bash curl -X POST https://localhost:20128/api/cli-tools/openclaw-settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -334,7 +334,7 @@ Local-only. Registers OmniRoute as an `openai-compat` provider in Crush's config ```bash curl -X POST https://localhost:20128/api/cli-tools/crush-settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -369,7 +369,7 @@ Local-only. Writes the OmniRoute config block in CodeWhale TOML format. ```bash curl -X POST https://localhost:20128/api/cli-tools/codewhale-settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-combos-routing/SKILL.md b/skills/omni-combos-routing/SKILL.md index 6558a3b39c..9745bf9085 100644 --- a/skills/omni-combos-routing/SKILL.md +++ b/skills/omni-combos-routing/SKILL.md @@ -29,7 +29,7 @@ Create routing combo ```bash curl -X POST https://localhost:20128/api/combos \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -40,7 +40,7 @@ Update combo ```bash curl -X PATCH https://localhost:20128/api/combos/{id} \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -69,7 +69,7 @@ Test a combo configuration ```bash curl -X POST https://localhost:20128/api/combos/test \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -93,7 +93,7 @@ Registers a fallback routing chain for a model. ```bash curl -X POST https://localhost:20128/api/fallback/chains \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-compression/SKILL.md b/skills/omni-compression/SKILL.md index eb89f08ccf..f17f39c52f 100644 --- a/skills/omni-compression/SKILL.md +++ b/skills/omni-compression/SKILL.md @@ -20,7 +20,7 @@ Preview compression for a message payload ```bash curl -X POST https://localhost:20128/api/compression/preview \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-context-rtk/SKILL.md b/skills/omni-context-rtk/SKILL.md index 8bad8466e5..8f75be1881 100644 --- a/skills/omni-context-rtk/SKILL.md +++ b/skills/omni-context-rtk/SKILL.md @@ -29,7 +29,7 @@ Update RTK compression settings ```bash curl -X PUT https://localhost:20128/api/context/rtk/config \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -49,7 +49,7 @@ Validate or install an RTK TOML schema v1 filter file ```bash curl -X POST https://localhost:20128/api/context/rtk/import \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -60,7 +60,7 @@ Run RTK compression preview for text ```bash curl -X POST https://localhost:20128/api/context/rtk/test \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-inference/SKILL.md b/skills/omni-inference/SKILL.md index e6a787c259..2951ce794b 100644 --- a/skills/omni-inference/SKILL.md +++ b/skills/omni-inference/SKILL.md @@ -40,7 +40,7 @@ OpenAI-compatible chat completions endpoint. Routes to configured providers. ```bash curl -X POST https://localhost:20128/api/v1/chat/completions \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -64,7 +64,7 @@ Routes to a specific provider by name. ```bash curl -X POST https://localhost:20128/api/v1/providers/{provider}/chat/completions \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -77,7 +77,7 @@ Provides compatibility with Ollama's /api/chat format. ```bash curl -X POST https://localhost:20128/api/v1/api/chat \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -90,7 +90,7 @@ Anthropic Messages API endpoint. Routes to Claude providers. ```bash curl -X POST https://localhost:20128/api/v1/messages \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -101,7 +101,7 @@ Count tokens for a message ```bash curl -X POST https://localhost:20128/api/v1/messages/count_tokens \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -114,7 +114,7 @@ OpenAI Responses API endpoint. ```bash curl -X POST https://localhost:20128/api/v1/responses \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -125,7 +125,7 @@ Create embeddings ```bash curl -X POST https://localhost:20128/api/v1/embeddings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -158,7 +158,7 @@ Create embeddings (provider-specific) ```bash curl -X POST https://localhost:20128/api/v1/providers/{provider}/embeddings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -169,7 +169,7 @@ Generate images ```bash curl -X POST https://localhost:20128/api/v1/images/generations \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -180,7 +180,7 @@ Generate images (provider-specific) ```bash curl -X POST https://localhost:20128/api/v1/providers/{provider}/images/generations \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -193,7 +193,7 @@ Text-to-speech endpoint. Routes to configured TTS providers. ```bash curl -X POST https://localhost:20128/api/v1/audio/speech \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -206,7 +206,7 @@ Audio-to-text transcription endpoint. ```bash curl -X POST https://localhost:20128/api/v1/audio/transcriptions \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -219,7 +219,7 @@ Content moderation endpoint. Routes to configured moderation providers. ```bash curl -X POST https://localhost:20128/api/v1/moderations \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -232,7 +232,7 @@ Document reranking endpoint. ```bash curl -X POST https://localhost:20128/api/v1/rerank \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -278,7 +278,7 @@ Creates a subscription record. If `mode` is `rule`, at least one entry in `ruleP ```bash curl -X POST https://localhost:20128/api/v1/management/proxy-subscriptions \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -300,7 +300,7 @@ Partial update — only fields present in the body are changed (name/url/mode/ru ```bash curl -X PATCH https://localhost:20128/api/v1/management/proxy-subscriptions/{id} \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -335,7 +335,7 @@ Re-fetches and re-parses the subscription URL, syncs its nodes into `proxy_regis ```bash curl -X POST https://localhost:20128/api/v1/management/proxy-subscriptions/{id}/refresh \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -348,7 +348,7 @@ Multi-provider document OCR endpoint (Mistral OCR–compatible request and respo ```bash curl -X POST https://localhost:20128/api/v1/ocr \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -361,7 +361,7 @@ OpenAI Whisper–compatible audio translation (multipart/form-data). Unlike `/ap ```bash curl -X POST https://localhost:20128/api/v1/audio/translations \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-models/SKILL.md b/skills/omni-models/SKILL.md index a86ac9fd79..4d3b9aed0b 100644 --- a/skills/omni-models/SKILL.md +++ b/skills/omni-models/SKILL.md @@ -40,7 +40,7 @@ Create or update a model alias ```bash curl -X POST https://localhost:20128/api/models/alias \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-providers/SKILL.md b/skills/omni-providers/SKILL.md index 8a973dc354..532a134e41 100644 --- a/skills/omni-providers/SKILL.md +++ b/skills/omni-providers/SKILL.md @@ -29,7 +29,7 @@ Create provider connection ```bash curl -X POST https://localhost:20128/api/providers \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -49,7 +49,7 @@ Update provider connection ```bash curl -X PATCH https://localhost:20128/api/providers/{id} \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -69,7 +69,7 @@ Test provider connection ```bash curl -X POST https://localhost:20128/api/providers/{id}/test \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -100,7 +100,7 @@ Test multiple providers at once ```bash curl -X POST https://localhost:20128/api/providers/test-batch \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -111,7 +111,7 @@ Validate provider credentials ```bash curl -X POST https://localhost:20128/api/providers/validate \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -131,7 +131,7 @@ Import an Antigravity CLI (agy) token file as an `agy` connection ```bash curl -X POST https://localhost:20128/api/providers/agy-auth/import \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -142,7 +142,7 @@ Bulk-import multiple Antigravity CLI (agy) token files (up to 50) ```bash curl -X POST https://localhost:20128/api/providers/agy-auth/import-bulk \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -153,7 +153,7 @@ Extract `.json` token files from an uploaded ZIP for agy bulk import ```bash curl -X POST https://localhost:20128/api/providers/agy-auth/zip-extract \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -164,7 +164,7 @@ Auto-detect and import the local Antigravity CLI (agy) login from disk ```bash curl -X POST https://localhost:20128/api/providers/agy-auth/apply-local \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -184,7 +184,7 @@ Create provider node ```bash curl -X POST https://localhost:20128/api/provider-nodes \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -195,7 +195,7 @@ Update provider node ```bash curl -X PATCH https://localhost:20128/api/provider-nodes/{id} \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -215,7 +215,7 @@ Validate a provider node ```bash curl -X POST https://localhost:20128/api/provider-nodes/validate \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-settings/SKILL.md b/skills/omni-settings/SKILL.md index 9ab8f95dfb..f3d3a2ae5a 100644 --- a/skills/omni-settings/SKILL.md +++ b/skills/omni-settings/SKILL.md @@ -33,7 +33,7 @@ Update any subset of the extended memory settings. All fields are optional; only ```bash curl -X PUT https://localhost:20128/api/settings/memory \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -57,7 +57,7 @@ Update Qdrant configuration. Pass `apiKey: ""` to remove the stored key. Schema: ```bash curl -X PUT https://localhost:20128/api/settings/qdrant \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -81,7 +81,7 @@ Performs a test semantic search against the Qdrant collection. Useful for valida ```bash curl -X POST https://localhost:20128/api/settings/qdrant/search \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -94,7 +94,7 @@ Removes Qdrant points for memories that have expired or exceeded the configured ```bash curl -X POST https://localhost:20128/api/settings/qdrant/cleanup \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -125,7 +125,7 @@ Update settings ```bash curl -X PATCH https://localhost:20128/api/settings \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -138,7 +138,7 @@ Deletes `call_logs`, legacy `request_detail_logs`, and local request artifact fi ```bash curl -X POST https://localhost:20128/api/settings/purge-request-history \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -158,7 +158,7 @@ Update global compression settings ```bash curl -X PUT https://localhost:20128/api/settings/compression \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -180,7 +180,7 @@ Partial-merge update. Numeric floors (e.g. a maxTextChars below the truncation-t ```bash curl -X PUT https://localhost:20128/api/settings/compression/mcp-accessibility \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -212,7 +212,7 @@ Requires a dashboard management session cookie when management auth is enabled. ```bash curl -X PUT https://localhost:20128/api/settings/payload-rules \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -241,7 +241,7 @@ Update proxy settings ```bash curl -X PATCH https://localhost:20128/api/settings/proxy \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -252,7 +252,7 @@ Test proxy connection ```bash curl -X POST https://localhost:20128/api/settings/proxy/test \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -263,7 +263,7 @@ Toggle login requirement ```bash curl -X POST https://localhost:20128/api/settings/require-login \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -288,7 +288,7 @@ Configure IP filtering with blacklist/whitelist modes, add/remove individual IPs ```bash curl -X PUT https://localhost:20128/api/settings/ip-filter \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -310,7 +310,7 @@ Update system prompt configuration ```bash curl -X PUT https://localhost:20128/api/settings/system-prompt \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -332,7 +332,7 @@ Update thinking budget configuration ```bash curl -X PUT https://localhost:20128/api/settings/thinking-budget \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -365,7 +365,7 @@ Update quota store driver settings ```bash curl -X PUT https://localhost:20128/api/settings/quota-store \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -378,7 +378,7 @@ Dashboard-only. Purges stored usage-history records. ```bash curl -X POST https://localhost:20128/api/settings/purge-usage-history \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-sync-cloud/SKILL.md b/skills/omni-sync-cloud/SKILL.md index d6d7c5d41a..68c2d4a4a1 100644 --- a/skills/omni-sync-cloud/SKILL.md +++ b/skills/omni-sync-cloud/SKILL.md @@ -22,7 +22,7 @@ Authenticates with the OmniRoute cloud worker for remote access. ```bash curl -X POST https://localhost:20128/api/cloud/auth \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -33,7 +33,7 @@ Update cloud worker credentials ```bash curl -X PUT https://localhost:20128/api/cloud/credentials/update \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -46,7 +46,7 @@ Resolves a model request through the cloud worker. ```bash curl -X POST https://localhost:20128/api/cloud/model/resolve \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -66,7 +66,7 @@ Update cloud model alias ```bash curl -X PUT https://localhost:20128/api/cloud/models/alias \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -77,7 +77,7 @@ Sync with cloud ```bash curl -X POST https://localhost:20128/api/sync/cloud \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -88,7 +88,7 @@ Initialize cloud sync ```bash curl -X POST https://localhost:20128/api/sync/initialize \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-usage-logs/SKILL.md b/skills/omni-usage-logs/SKILL.md index dd090853a7..af685e0e35 100644 --- a/skills/omni-usage-logs/SKILL.md +++ b/skills/omni-usage-logs/SKILL.md @@ -105,7 +105,7 @@ Set or update budget limits for usage tracking. ```bash curl -X POST https://localhost:20128/api/usage/budget \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/skills/omni-version-manager/SKILL.md b/skills/omni-version-manager/SKILL.md index 711a8f3e8e..eceda51dbc 100644 --- a/skills/omni-version-manager/SKILL.md +++ b/skills/omni-version-manager/SKILL.md @@ -22,7 +22,7 @@ Installs the `9router` npm package under DATA_DIR/services/9router/. Uses execFi ```bash curl -X POST https://localhost:20128/api/services/9router/install \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -35,7 +35,7 @@ Spawns the 9Router process. Idempotent if already running. **LOCAL_ONLY** — lo ```bash curl -X POST https://localhost:20128/api/services/9router/start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -48,7 +48,7 @@ Gracefully stops 9Router (SIGTERM → 15 s → SIGKILL). Idempotent. **LOCAL_ONL ```bash curl -X POST https://localhost:20128/api/services/9router/stop \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -61,7 +61,7 @@ Equivalent to stop() then start() under the operation lock. **LOCAL_ONLY** — l ```bash curl -X POST https://localhost:20128/api/services/9router/restart \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -74,7 +74,7 @@ Stops the service (if running), installs the newer npm version, then restarts. * ```bash curl -X POST https://localhost:20128/api/services/9router/update \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -87,7 +87,7 @@ Generates a new API key, encrypts it at-rest, and restarts the service to apply ```bash curl -X POST https://localhost:20128/api/services/9router/rotate-key \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -111,7 +111,7 @@ When enabled, 9Router starts automatically on the next OmniRoute boot. **LOCAL_O ```bash curl -X POST https://localhost:20128/api/services/9router/auto-start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -124,7 +124,7 @@ When enabled, an externally-adopted (not OmniRoute-spawned) 9Router process is r ```bash curl -X POST https://localhost:20128/api/services/9router/auto-restart-adopted \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -137,7 +137,7 @@ Installs the CLIProxyAPI package under DATA_DIR/services/cliproxy/. **LOCAL_ONLY ```bash curl -X POST https://localhost:20128/api/services/cliproxy/install \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -150,7 +150,7 @@ Spawns the CLIProxyAPI process. Idempotent if already running. **LOCAL_ONLY** ```bash curl -X POST https://localhost:20128/api/services/cliproxy/start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -163,7 +163,7 @@ Gracefully stops CLIProxyAPI. Idempotent. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/cliproxy/stop \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -176,7 +176,7 @@ stop() then start() under the operation lock. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/cliproxy/restart \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -189,7 +189,7 @@ Stops, installs newer version, restarts. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/cliproxy/update \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -213,7 +213,7 @@ When enabled, CLIProxyAPI starts automatically on the next OmniRoute boot. **LOC ```bash curl -X POST https://localhost:20128/api/services/cliproxy/auto-start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -226,7 +226,7 @@ When enabled, an externally-adopted (not OmniRoute-spawned) CLIProxyAPI process ```bash curl -X POST https://localhost:20128/api/services/cliproxy/auto-restart-adopted \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -239,7 +239,7 @@ Installs the `mux` npm package (coder/mux — local agent-orchestration daemon) ```bash curl -X POST https://localhost:20128/api/services/mux/install \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -252,7 +252,7 @@ Spawns `mux server --host 127.0.0.1 --port `. Idempotent if already runnin ```bash curl -X POST https://localhost:20128/api/services/mux/start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -265,7 +265,7 @@ Gracefully stops Mux. Idempotent. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/mux/stop \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -278,7 +278,7 @@ stop() then start() under the operation lock. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/mux/restart \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -291,7 +291,7 @@ Stops, installs newer version, restarts. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/mux/update \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -315,7 +315,7 @@ When enabled, Mux starts automatically on the next OmniRoute boot. **LOCAL_ONLY* ```bash curl -X POST https://localhost:20128/api/services/mux/auto-start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -328,7 +328,7 @@ When enabled, an externally-adopted (not OmniRoute-spawned) Mux process is resta ```bash curl -X POST https://localhost:20128/api/services/mux/auto-restart-adopted \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -341,7 +341,7 @@ Installs the `@maximhq/bifrost` npm package under DATA_DIR/services/bifrost/. Th ```bash curl -X POST https://localhost:20128/api/services/bifrost/install \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -354,7 +354,7 @@ Starts the supervised Bifrost process. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/bifrost/start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -367,7 +367,7 @@ Stops the supervised Bifrost process. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/bifrost/stop \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -380,7 +380,7 @@ Restarts the supervised Bifrost process. **LOCAL_ONLY** — loopback only. ```bash curl -X POST https://localhost:20128/api/services/bifrost/restart \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -393,7 +393,7 @@ Updates Bifrost to the latest npm version. Stops the running process, installs t ```bash curl -X POST https://localhost:20128/api/services/bifrost/update \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -417,7 +417,7 @@ When enabled, Bifrost starts automatically on the next OmniRoute boot. **LOCAL_O ```bash curl -X POST https://localhost:20128/api/services/bifrost/auto-start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -430,7 +430,7 @@ When enabled, an externally-adopted (not OmniRoute-spawned) Bifrost process is r ```bash curl -X POST https://localhost:20128/api/services/bifrost/auto-restart-adopted \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -443,7 +443,7 @@ Installs the `@askalf/dario` npm package (Claude-account-pool proxy) under DATA_ ```bash curl -X POST https://localhost:20128/api/services/dario/install \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -456,7 +456,7 @@ Spawns the Dario process. Idempotent if already running. **LOCAL_ONLY** — loop ```bash curl -X POST https://localhost:20128/api/services/dario/start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -469,7 +469,7 @@ Gracefully stops Dario. Idempotent — returns a stopped status even if no super ```bash curl -X POST https://localhost:20128/api/services/dario/stop \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -482,7 +482,7 @@ Equivalent to stop() then start() under the operation lock. **LOCAL_ONLY** — l ```bash curl -X POST https://localhost:20128/api/services/dario/restart \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -495,7 +495,7 @@ Stops the service (if running), installs the newer npm version, then restarts it ```bash curl -X POST https://localhost:20128/api/services/dario/update \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -519,7 +519,7 @@ When enabled, Dario starts automatically on the next OmniRoute boot. **LOCAL_ONL ```bash curl -X POST https://localhost:20128/api/services/dario/auto-start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -532,7 +532,7 @@ When enabled, an externally-adopted (not OmniRoute-spawned) Dario process is res ```bash curl -X POST https://localhost:20128/api/services/dario/auto-restart-adopted \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -545,7 +545,7 @@ Forwards to the running Dario instance's `POST /admin/login/start` using the sto ```bash curl -X POST https://localhost:20128/api/services/dario/admin/login-start \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -558,7 +558,7 @@ Forwards to the running Dario instance's `POST /admin/login/complete`. On succes ```bash curl -X POST https://localhost:20128/api/services/dario/admin/login-complete \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` @@ -604,7 +604,7 @@ Writes the source connection's access/refresh token pair directly into Dario's o ```bash curl -X POST https://localhost:20128/api/services/dario/admin/import-from-omniroute \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 2fa930dd7d..3c662c0f78 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -35,6 +35,7 @@ import { AUTHZ_HEADER_AUTH_KIND, AUTHZ_HEADER_PEER_LOCALITY, } from "@/server/authz/headers"; +import { readSubjectFromHeaders } from "@/server/authz/assertAuth"; /** * Force this route to run dynamically per-request and never be cached/prerendered. @@ -133,6 +134,8 @@ async function deriveAuditActor(request: Request): Promise { } catch { /* fall through */ } + const subject = readSubjectFromHeaders(request.headers); + if (subject.kind === "management_key" && subject.label === "local-cli-token") return "cli"; try { if (await isCliTokenAuthValid(request)) return "cli"; } catch { diff --git a/src/lib/agentSkills/generator.ts b/src/lib/agentSkills/generator.ts index dd106b42ba..8f5599622f 100644 --- a/src/lib/agentSkills/generator.ts +++ b/src/lib/agentSkills/generator.ts @@ -76,6 +76,7 @@ function extractCustomBlock(content: string): string | null { function buildApiBody(skill: AgentSkill, sources: BuildSources): string { const areaMap = sources.openapi.areas; const ops = areaMap.get(skill.area as Parameters[0]) ?? []; + const usesDashboardSession = skill.id === "omni-auth"; const lines: string[] = []; @@ -84,10 +85,17 @@ function buildApiBody(skill: AgentSkill, sources: BuildSources): string { lines.push(""); lines.push("## Authentication\n"); - lines.push( - "All requests require a valid Bearer token or session cookie. " + - "Obtain a token via `POST /api/auth/login` or configure `REQUIRE_API_KEY=false` for local development." - ); + if (usesDashboardSession) { + lines.push( + "Remote API requests use a Bearer credential. Dashboard login is different: " + + "`POST /api/auth/login` accepts a management password and returns an `auth_token` session cookie." + ); + } else { + lines.push( + "All requests require a valid Bearer token or session cookie. " + + "Obtain a token via `POST /api/auth/login` or configure `REQUIRE_API_KEY=false` for local development." + ); + } lines.push(""); lines.push("## Endpoints\n"); @@ -105,14 +113,41 @@ function buildApiBody(skill: AgentSkill, sources: BuildSources): string { lines.push(op.description); lines.push(""); } - // Minimal curl example - const curlMethod = op.method === "GET" ? "" : `-X ${op.method} `; + // Minimal curl example. Only omni-auth establishes and consumes a dashboard + // session; generic API skills use independently usable Bearer examples. lines.push("```bash"); - lines.push(`curl ${curlMethod}https://localhost:20128${op.path} \\`); - lines.push(' -H "Authorization: Bearer $OMNIROUTE_TOKEN"'); - if (["POST", "PUT", "PATCH"].includes(op.method)) { + if (usesDashboardSession && op.path === "/api/auth/login" && op.method === "POST") { + lines.push(`curl -X POST https://localhost:20128${op.path} \\`); lines.push(' -H "Content-Type: application/json" \\'); - lines.push(" -d '{}'"); + lines.push(" -c cookie.jar \\"); + lines.push(' -d \'{"password":""}\''); + } else if (usesDashboardSession) { + const curlMethod = op.method === "GET" ? "" : `-X ${op.method} `; + if (op.method === "GET") { + lines.push(`curl ${curlMethod}https://localhost:20128${op.path} \\`); + lines.push(" -b cookie.jar"); + } else { + lines.push( + "CSRF_TOKEN=$(curl -s https://localhost:20128/api/auth/csrf -b cookie.jar | jq -r .token)" + ); + lines.push(`curl ${curlMethod}https://localhost:20128${op.path} \\`); + lines.push(" -b cookie.jar \\"); + const hasJsonBody = ["POST", "PUT", "PATCH"].includes(op.method); + lines.push(` -H "x-omniroute-csrf: $CSRF_TOKEN"${hasJsonBody ? " \\" : ""}`); + if (hasJsonBody) { + lines.push(' -H "Content-Type: application/json" \\'); + lines.push(" -d '{}'"); + } + } + } else { + const curlMethod = op.method === "GET" ? "" : `-X ${op.method} `; + const hasJsonBody = ["POST", "PUT", "PATCH"].includes(op.method); + lines.push(`curl ${curlMethod}https://localhost:20128${op.path} \\`); + lines.push(` -H "Authorization: Bearer $OMNIROUTE_TOKEN"${hasJsonBody ? " \\" : ""}`); + if (hasJsonBody) { + lines.push(' -H "Content-Type: application/json" \\'); + lines.push(" -d '{}'"); + } } lines.push("```"); lines.push(""); diff --git a/src/lib/api/requireManagementAuth.ts b/src/lib/api/requireManagementAuth.ts index eb0dc99239..34ebc25cbf 100644 --- a/src/lib/api/requireManagementAuth.ts +++ b/src/lib/api/requireManagementAuth.ts @@ -5,6 +5,7 @@ import { getApiKeyMetadata } from "@/lib/db/apiKeys"; import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth"; import { evaluateAccessTokenAuth } from "@/server/authz/accessTokenAuth"; import { isTrustedLoopbackInternalServiceRequest } from "@/lib/api/internalServiceAuth"; +import { AUTHZ_HEADER_AUTH_KIND, AUTHZ_HEADER_AUTH_LABEL } from "@/server/authz/headers"; import { MANAGE_SCOPE, hasManageScope as hasManageScopeShared, @@ -52,7 +53,17 @@ export async function requireManagementAuth( return null; } - // CLI machine-id token allows localhost CLI access without an explicit API key. + // The authz pipeline strips the raw machine-token header after it validates it + // and forwards this trusted subject stamp to route handlers. + if ( + request.headers.get(AUTHZ_HEADER_AUTH_KIND) === "management_key" && + request.headers.get(AUTHZ_HEADER_AUTH_LABEL) === "local-cli-token" + ) { + return null; + } + + // Direct/raw-Node callers without the central pipeline can still validate the + // CLI token here, including the trusted peer-locality stamp path. if (await isCliTokenAuthValid(request)) { return null; } diff --git a/src/lib/machineToken.ts b/src/lib/machineToken.ts index e83da29495..bb910ae9ed 100644 --- a/src/lib/machineToken.ts +++ b/src/lib/machineToken.ts @@ -1,9 +1,13 @@ import { createHash, createHmac } from "node:crypto"; +import { createRequire } from "node:module"; let machineIdSync: (original?: boolean) => string; try { - // Use require() to bypass webpack static analysis that breaks the default export - const mod = require("node-machine-id"); + // Anchor runtime resolution to the process entrypoint. Turbopack rewrites + // createRequire(import.meta.url) into an in-bundle resolver, which cannot load + // external CommonJS packages from the installed standalone node_modules tree. + const runtimeRequire = createRequire(process.argv[1] || process.cwd()); + const mod = runtimeRequire("node-machine-id"); machineIdSync = mod.machineIdSync || mod.default?.machineIdSync; } catch { machineIdSync = () => ""; @@ -15,10 +19,19 @@ function getActiveSalt(): string { return process.env.OMNIROUTE_CLI_SALT || BUILTIN_DEFAULT_SALT; } -function deriveToken(rawId: string, salt: string): string { +export function deriveMachineToken(rawId: string, salt: string): string { + if (!rawId) return ""; return createHmac("sha256", rawId).update(salt).digest("hex"); } +export function deriveLegacyCliToken(machineId: string, salt: string): string { + if (!machineId) return ""; + return createHash("sha256") + .update(machineId + salt) + .digest("hex") + .substring(0, 32); +} + let cached: string | null = null; let cachedSalt: string | null = null; @@ -27,8 +40,9 @@ export function getMachineTokenSync(salt?: string): string { try { // machineIdSync(true) returns the original unhashed hardware ID. const rawId = machineIdSync(true); + if (!rawId) return ""; if (activeSalt === cachedSalt && cached !== null) return cached; - const token = deriveToken(rawId, activeSalt); + const token = deriveMachineToken(rawId, activeSalt); if (!salt) { cached = token; cachedSalt = activeSalt; @@ -43,10 +57,7 @@ export function getLegacyCliTokenSync(salt?: string): string { const activeSalt = salt ?? getActiveSalt(); try { const machineId = machineIdSync(); - return createHash("sha256") - .update(machineId + activeSalt) - .digest("hex") - .substring(0, 32); + return deriveLegacyCliToken(machineId, activeSalt); } catch { return ""; } diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index f2619a189b..9f4e46bed1 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -26,6 +26,7 @@ import { AUTHZ_HEADER_REQUEST_ID, AUTHZ_HEADER_ROUTE_CLASS, AUTHZ_TRUSTED_HEADERS, + CLI_TOKEN_HEADER, PEER_IP_HEADER, VIA_PROXY_HEADER, } from "./headers"; @@ -330,6 +331,11 @@ export async function runAuthzPipeline( process.env.OMNIROUTE_PEER_STAMP_TOKEN ); requestHeaders.set(AUTHZ_HEADER_PEER_LOCALITY, peerLocality); + // Local CLI-token auth is decided centrally above. Preserve that trusted + // decision for route-level requireManagementAuth without forwarding the + // machine token itself: custom client auth headers are stripped before the + // route runs, so the route consumes only the stamped auth subject. + requestHeaders.delete(CLI_TOKEN_HEADER); if (method === "OPTIONS") { const preflight = new NextResponse(null, { status: 204 }); diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index e3523035f6..772c801247 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -77,6 +77,7 @@ function isPrivateLanRequest(ctx: PolicyContext): boolean { } function hasValidCliToken(ctx: PolicyContext): boolean { + if (process.env.OMNIROUTE_DISABLE_CLI_TOKEN === "true") return false; if (!isLoopbackRequest(ctx)) return false; const headers = ctx.request.headers; const provided = headers.get(CLI_TOKEN_HEADER); diff --git a/tests/unit/agentSkills-generator.test.ts b/tests/unit/agentSkills-generator.test.ts index df0f5d8d47..9391af4a7f 100644 --- a/tests/unit/agentSkills-generator.test.ts +++ b/tests/unit/agentSkills-generator.test.ts @@ -15,13 +15,10 @@ import os from "node:os"; // ── Dynamic imports (tsx/esm resolves TS imports) ──────────────────────────── -const { generateAgentSkills, buildSkillMarkdown, __testing } = await import( - "../../src/lib/agentSkills/generator.ts" -); +const { generateAgentSkills, buildSkillMarkdown, __testing } = + await import("../../src/lib/agentSkills/generator.ts"); -const { getCatalog, refreshCatalog } = await import( - "../../src/lib/agentSkills/catalog.ts" -); +const { getCatalog, refreshCatalog } = await import("../../src/lib/agentSkills/catalog.ts"); // ── Helpers ────────────────────────────────────────────────────────────────── @@ -66,7 +63,7 @@ test("dry-run (default) returns report without writing any files", async () => { assert.equal( report.generated.length + report.unchanged.length, 46, - `Expected 46 total (generated+unchanged), got generated=${report.generated.length} unchanged=${report.unchanged.length}`, + `Expected 46 total (generated+unchanged), got generated=${report.generated.length} unchanged=${report.unchanged.length}` ); assert.equal(report.errors.length, 0, `Unexpected errors: ${JSON.stringify(report.errors)}`); @@ -75,7 +72,7 @@ test("dry-run (default) returns report without writing any files", async () => { assert.equal( entries.length, 0, - `Dry-run wrote ${entries.length} entries to ${tmpDir}: ${entries.join(", ")}`, + `Dry-run wrote ${entries.length} entries to ${tmpDir}: ${entries.join(", ")}` ); } finally { rmTmpDir(tmpDir); @@ -128,7 +125,7 @@ test("apply mode writes SKILL.md with valid frontmatter for omni-providers", asy // Generated comment present assert.ok( content.includes("\nMy custom content here.\n"; + const customBlock = + "\nMy custom content here.\n"; const contentWithCustom = originalContent + "\n" + customBlock + "\n"; fs.writeFileSync(skillFile, contentWithCustom, "utf-8"); @@ -355,27 +410,17 @@ test("marker preservation: custom block survives regeneration", async () => { onlyIds: ["omni-providers"], }); - assert.equal( - report2.errors.length, - 0, - `Errors: ${JSON.stringify(report2.errors)}`, - ); + assert.equal(report2.errors.length, 0, `Errors: ${JSON.stringify(report2.errors)}`); const newContent = fs.readFileSync(skillFile, "utf-8"); // Custom block should still be present assert.ok( newContent.includes("My custom content here."), - "Custom content was lost during regeneration", - ); - assert.ok( - newContent.includes(""), - "Custom start marker missing", - ); - assert.ok( - newContent.includes(""), - "Custom end marker missing", + "Custom content was lost during regeneration" ); + assert.ok(newContent.includes(""), "Custom start marker missing"); + assert.ok(newContent.includes(""), "Custom end marker missing"); } finally { rmTmpDir(tmpDir); } @@ -391,7 +436,10 @@ test("buildSkillMarkdown returns valid frontmatter + body for omni-providers", ( assert.ok(typeof result.frontmatter === "object", "frontmatter must be an object"); assert.equal(result.frontmatter.name, "omni-providers"); assert.ok(result.frontmatter.description.length > 0, "description must be non-empty"); - assert.ok(typeof result.body === "string" && result.body.length > 0, "body must be a non-empty string"); + assert.ok( + typeof result.body === "string" && result.body.length > 0, + "body must be a non-empty string" + ); }); test("buildSkillMarkdown body has no erroneously escaped characters", () => { @@ -406,20 +454,11 @@ test("buildSkillMarkdown body has no erroneously escaped characters", () => { // Check for common escape errors: \\n in rendered text, &, <, > assert.ok( !result.body.includes("\\\\n"), - `Skill ${id}: body contains \\\\n (double-escaped newline)`, - ); - assert.ok( - !result.body.includes("&"), - `Skill ${id}: body contains HTML entity &`, - ); - assert.ok( - !result.body.includes("<"), - `Skill ${id}: body contains HTML entity <`, - ); - assert.ok( - !result.body.includes(">"), - `Skill ${id}: body contains HTML entity >`, + `Skill ${id}: body contains \\\\n (double-escaped newline)` ); + assert.ok(!result.body.includes("&"), `Skill ${id}: body contains HTML entity &`); + assert.ok(!result.body.includes("<"), `Skill ${id}: body contains HTML entity <`); + assert.ok(!result.body.includes(">"), `Skill ${id}: body contains HTML entity >`); } }); @@ -430,7 +469,7 @@ test("buildSkillMarkdown throws for unknown skillId", () => { assert.throws( () => buildSkillMarkdown("non-existent-skill", sources), /non-existent-skill/, - "Should throw with skill ID in message", + "Should throw with skill ID in message" ); }); @@ -464,7 +503,7 @@ test("buildSkillMarkdown description is at most 2000 chars", () => { const result = buildSkillMarkdown(skill.id, sources); assert.ok( result.frontmatter.description.length <= 2000, - `Skill ${skill.id}: description too long (${result.frontmatter.description.length} > 2000)`, + `Skill ${skill.id}: description too long (${result.frontmatter.description.length} > 2000)` ); } }); @@ -509,8 +548,10 @@ test("generated SKILL.md contains the mandatory generated comment", async () => const content = fs.readFileSync(path.join(tmpDir, "omni-providers", "SKILL.md"), "utf-8"); assert.ok( - content.includes(""), - "Missing mandatory generated comment", + content.includes( + "" + ), + "Missing mandatory generated comment" ); } finally { rmTmpDir(tmpDir); diff --git a/tests/unit/api/settings-audit.test.ts b/tests/unit/api/settings-audit.test.ts index e23296713b..20844c741a 100644 --- a/tests/unit/api/settings-audit.test.ts +++ b/tests/unit/api/settings-audit.test.ts @@ -108,6 +108,28 @@ test("AC-9: successful PATCH writes settings.update with diff of changed keys", }); }); +test("CLI subject stamp preserves actor attribution after the raw token is stripped", async () => { + await bootstrapWithPassword("initial-pass-cli-actor"); + await settingsDb.updateSettings({ theme: "light" }); + + const response = await settingsRoute.PATCH( + new Request("http://localhost/api/settings", { + method: "PATCH", + headers: { + "content-type": "application/json", + "x-omniroute-auth-kind": "management_key", + "x-omniroute-auth-label": "local-cli-token", + }, + body: JSON.stringify({ theme: "dark" }), + }) + ); + + assert.equal(response.status, 200); + const rows = settingsRows().filter((r) => r.action === "settings.update"); + assert.equal(rows.length, 1); + assert.equal(rows[0].actor, "cli"); +}); + // ─── AC-10 — failure rows for each rejection path ──────────────────────── test("AC-10a: PASSWORD_REQUIRED failure writes settings.update_failed", async () => { diff --git a/tests/unit/check-pack-boot.test.ts b/tests/unit/check-pack-boot.test.ts index ea592db217..56abe03176 100644 --- a/tests/unit/check-pack-boot.test.ts +++ b/tests/unit/check-pack-boot.test.ts @@ -1,14 +1,18 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { REQUIRED_SQLJS_RUNTIME_FILES, + REQUIRED_MACHINE_TOKEN_RUNTIME_FILES, pickTarball, evaluateBoot, pickPort, findMissingSqlJsRuntimeFiles, + findMissingMachineTokenRuntimeFiles, + evaluateMachineTokenAuth, evaluateSqlJsRoundTrip, evaluateRestartPersistence, } from "../../scripts/check/check-pack-boot.mjs"; @@ -74,6 +78,64 @@ test("installed package contract requires sql.js metadata, entrypoint, and WASM" ); }); +test("installed package contract requires a resolvable node-machine-id CommonJS runtime", () => { + const present = new Set( + REQUIRED_MACHINE_TOKEN_RUNTIME_FILES.map((file) => path.join("/pkg", file)) + ); + assert.deepEqual( + findMissingMachineTokenRuntimeFiles("/pkg", (file) => present.has(file)), + [] + ); + + present.delete(path.join("/pkg", "node_modules/node-machine-id/index.js")); + assert.deepEqual( + findMissingMachineTokenRuntimeFiles("/pkg", (file) => present.has(file)), + ["node_modules/node-machine-id/index.js"] + ); +}); + +test("machine-token smoke requires no/invalid credentials to fail and the packaged CLI token to pass", () => { + assert.deepEqual( + evaluateMachineTokenAuth({ + cliToken: "a".repeat(64), + unauthenticatedStatus: 401, + invalidStatus: 401, + authenticatedStatus: 200, + }), + { ok: true, failures: [] } + ); + + for (const candidate of [ + { cliToken: "", unauthenticatedStatus: 401, invalidStatus: 401, authenticatedStatus: 200 }, + { + cliToken: "a".repeat(64), + unauthenticatedStatus: 200, + invalidStatus: 401, + authenticatedStatus: 200, + }, + { + cliToken: "a".repeat(64), + unauthenticatedStatus: 401, + invalidStatus: 200, + authenticatedStatus: 200, + }, + { + cliToken: "a".repeat(64), + unauthenticatedStatus: 401, + invalidStatus: 401, + authenticatedStatus: 401, + }, + { + cliToken: createHmac("sha256", "").update("omniroute-cli-auth-v1").digest("hex"), + unauthenticatedStatus: 401, + invalidStatus: 401, + authenticatedStatus: 200, + }, + ]) { + assert.equal(evaluateMachineTokenAuth(candidate).ok, false); + } +}); + test("sql.js round trip requires the forced-driver marker plus PATCH and GET persistence", () => { const passing = evaluateSqlJsRoundTrip({ startupOutput: "[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)...", @@ -103,10 +165,20 @@ test("source guard: the gate polls the real health endpoint of the INSTALLED bin ); assert.ok(src.includes("/api/monitoring/health"), "must poll the health endpoint"); assert.ok(src.includes("/api/settings"), "must verify a real application write and read"); + assert.ok(src.includes("/api/cli/whoami"), "must exercise the machine-token auth endpoint"); + assert.ok(src.includes("x-omniroute-cli-token"), "must send the official machine-token header"); + const postinstall = readFileSync( + fileURLToPath(new URL("../../scripts/build/postinstall.mjs", import.meta.url)), + "utf8" + ); + assert.ok(postinstall.includes('["sql.js", "node-machine-id"]')); + assert.ok(postinstall.includes('join(ROOT, "dist", "node_modules", packageName)')); assert.ok( src.includes('OMNIROUTE_PACK_BOOT_FORCE_SQLJS: "1"'), "must force the packaged sql.js tier during this smoke" ); + assert.ok(src.includes("MAX_SERVER_OUTPUT_CHARS")); + assert.ok(!src.includes("while (tail.length > 80)"), "must not discard early startup proof"); assert.ok(src.indexOf("npm") < src.indexOf("spawn"), "pack+install must precede the boot spawn"); }); diff --git a/tests/unit/cli-doctor-command.test.ts b/tests/unit/cli-doctor-command.test.ts index c10aa53256..4eedec0fdd 100644 --- a/tests/unit/cli-doctor-command.test.ts +++ b/tests/unit/cli-doctor-command.test.ts @@ -15,6 +15,8 @@ const ORIGINAL_STORAGE_ENCRYPTION_KEY = process.env.STORAGE_ENCRYPTION_KEY; interface DoctorCheck { name: string; status: string; + message?: string; + details?: Record; } interface DoctorResult { @@ -109,3 +111,162 @@ test("doctor fails when encrypted credentials exist without storage key", async assert.equal(getCheck(result, "Storage/encryption")?.status, "fail"); }); }); + +test("doctor probes the real machine-token endpoint without exposing the token", async () => { + await withDoctorEnv(async () => { + const originalFetch = globalThis.fetch; + let observedToken = ""; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + assert.match(url, /\/api\/cli\/whoami$/); + assert.equal(init?.redirect, "error"); + observedToken = new Headers(init?.headers).get("x-omniroute-cli-token") || ""; + return new Response(JSON.stringify({ authenticated: true }), { + status: observedToken ? 200 : 401, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + try { + const { checkMachineTokenAuth } = await import("../../bin/cli/commands/doctor.mjs"); + const check = await checkMachineTokenAuth({ + livenessUrl: "http://127.0.0.1:21999/api/health/degradation", + }); + + assert.equal(check.status, "ok"); + assert.match(observedToken, /^[0-9a-f]{64}$/); + assert.ok( + !JSON.stringify(check).includes(observedToken), + "doctor output must never expose token" + ); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +test("doctor only sends the machine token to supported loopback URL shapes", async () => { + const originalFetch = globalThis.fetch; + const observedUrls: string[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + observedUrls.push(String(input)); + assert.equal(init?.redirect, "error"); + assert.match(new Headers(init?.headers).get("x-omniroute-cli-token") || "", /^[0-9a-f]{64}$/); + return new Response(null, { status: 200 }); + }) as typeof fetch; + + try { + const { checkMachineTokenAuth } = await import("../../bin/cli/commands/doctor.mjs"); + const loopbackUrls = [ + "http://localhost:21999/health", + "http://127.0.0.42:21999/health", + "http://[::1]:21999/health", + "http://[::ffff:127.0.0.1]:21999/health", + ]; + + for (const livenessUrl of loopbackUrls) { + const check = await checkMachineTokenAuth({ livenessUrl }); + assert.equal(check.status, "ok", livenessUrl); + } + assert.equal(observedUrls.length, loopbackUrls.length); + assert.ok(observedUrls.every((url) => url.endsWith("/api/cli/whoami"))); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("doctor refuses remote, deceptive, credential-bearing, and unsupported probe URLs", async () => { + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + return new Response(null, { status: 200 }); + }) as typeof fetch; + + try { + const { checkMachineTokenAuth } = await import("../../bin/cli/commands/doctor.mjs"); + const rejectedUrls = [ + "https://remote.example.test/health", + "http://localhost.example.test/health", + "http://127.0.0.1.example.test/health", + "http://localhost@remote.example.test/health", + "http://token-user:credential-sentinel@127.0.0.1:21999/health", + "ftp://localhost:21999/health", + "http://0.0.0.0:21999/health", + "http://[::2]:21999/health", + ]; + + for (const livenessUrl of rejectedUrls) { + const check = await checkMachineTokenAuth({ livenessUrl }); + assert.equal(check.status, "warn", livenessUrl); + assert.equal(check.details?.accepted, false); + assert.equal(check.details?.tokenExposed, false); + assert.ok(!JSON.stringify(check).includes("credential-sentinel")); + } + assert.equal(fetchCalls, 0, "rejected targets must never receive a fetch call"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("doctor never follows a machine-token redirect to another origin", async () => { + const originalFetch = globalThis.fetch; + let crossOriginRequests = 0; + let crossOriginTokenObserved = false; + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + if (init?.redirect !== "error") { + crossOriginRequests += 1; + crossOriginTokenObserved = new Headers(init?.headers).has("x-omniroute-cli-token"); + return new Response(null, { status: 200 }); + } + throw new TypeError("redirect blocked"); + }) as typeof fetch; + + try { + const { checkMachineTokenAuth } = await import("../../bin/cli/commands/doctor.mjs"); + const check = await checkMachineTokenAuth({ + livenessUrl: "http://127.0.0.1:21999/redirect-to-other-origin", + }); + + assert.equal(check.status, "warn"); + assert.equal(crossOriginRequests, 0); + assert.equal(crossOriginTokenObserved, false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("doctor gives connect guidance when the server rejects a machine token", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(null, { status: 401 })) as typeof fetch; + try { + const { checkMachineTokenAuth } = await import("../../bin/cli/commands/doctor.mjs"); + const check = await checkMachineTokenAuth({ + livenessUrl: "http://127.0.0.1:21999/api/health/degradation", + }); + assert.equal(check.status, "warn"); + assert.match(check.message || "", /omniroute connect/i); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("doctor reports explicitly disabled machine-token auth without probing", async () => { + const previous = process.env.OMNIROUTE_DISABLE_CLI_TOKEN; + const originalFetch = globalThis.fetch; + process.env.OMNIROUTE_DISABLE_CLI_TOKEN = "true"; + globalThis.fetch = (async () => { + throw new Error("fetch should not run"); + }) as typeof fetch; + try { + const { checkMachineTokenAuth } = await import("../../bin/cli/commands/doctor.mjs"); + const check = await checkMachineTokenAuth(); + assert.equal(check.status, "warn"); + assert.equal(check.details?.disabled, true); + assert.match(check.message || "", /disabled/i); + } finally { + globalThis.fetch = originalFetch; + if (previous === undefined) delete process.env.OMNIROUTE_DISABLE_CLI_TOKEN; + else process.env.OMNIROUTE_DISABLE_CLI_TOKEN = previous; + } +}); diff --git a/tests/unit/cli-machine-token.test.ts b/tests/unit/cli-machine-token.test.ts index 833bf67a0c..35170f5a36 100644 --- a/tests/unit/cli-machine-token.test.ts +++ b/tests/unit/cli-machine-token.test.ts @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import crypto from "node:crypto"; +import http from "node:http"; import { execFileSync } from "node:child_process"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -12,6 +13,37 @@ test("cliToken.mjs pode ser importado sem erro", async () => { assert.equal(mod.CLI_TOKEN_HEADER, "x-omniroute-cli-token"); }); +test("packaged CLI derives the same current machine token as the server", async () => { + const salt = `cli-machine-token-${process.pid}`; + const previousSalt = process.env.OMNIROUTE_CLI_SALT; + process.env.OMNIROUTE_CLI_SALT = salt; + try { + const { getCliToken } = await import(`../../bin/cli/utils/cliToken.mjs?current=${Date.now()}`); + const { getMachineTokenSync } = await import("../../src/lib/machineToken.ts"); + const token = await getCliToken(); + + assert.match(token, /^[0-9a-f]{64}$/, "CLI token must be a non-empty HMAC-SHA256 digest"); + assert.equal(token, getMachineTokenSync(salt)); + } finally { + if (previousSalt === undefined) delete process.env.OMNIROUTE_CLI_SALT; + else process.env.OMNIROUTE_CLI_SALT = previousSalt; + } +}); + +test("getCliToken returns an empty string when machine-id derivation is unavailable", async () => { + const { deriveCliToken } = await import("../../bin/cli/utils/cliToken.mjs"); + assert.equal(deriveCliToken({}, "test-salt"), ""); + assert.equal(deriveCliToken({ default: { machineIdSync: () => "" } }, "test-salt"), ""); + const throwingModule = { + default: { + machineIdSync: () => { + throw new Error("unavailable"); + }, + }, + }; + assert.equal(deriveCliToken(throwingModule, "test-salt"), ""); +}); + test("getCliToken retorna string de 64 chars ou string vazia", async () => { const { getCliToken } = await import("../../bin/cli/utils/cliToken.mjs"); const token = await getCliToken(); @@ -97,6 +129,124 @@ test("OMNIROUTE_CLI_TOKEN env sobrescreve token gerado em apiFetch", async () => } }); +test("apiFetch never sends an implicit machine token to remote contexts", async () => { + const originalBaseUrl = process.env.OMNIROUTE_BASE_URL; + const originalOverride = process.env.OMNIROUTE_CLI_TOKEN; + process.env.OMNIROUTE_BASE_URL = "https://remote.example.test"; + delete process.env.OMNIROUTE_CLI_TOKEN; + try { + const { buildHeaders } = await import(`../../bin/cli/api.mjs?remote=${Date.now()}`); + const headers = await buildHeaders({}); + assert.equal(headers.has("x-omniroute-cli-token"), false); + } finally { + if (originalBaseUrl === undefined) delete process.env.OMNIROUTE_BASE_URL; + else process.env.OMNIROUTE_BASE_URL = originalBaseUrl; + if (originalOverride === undefined) delete process.env.OMNIROUTE_CLI_TOKEN; + else process.env.OMNIROUTE_CLI_TOKEN = originalOverride; + } +}); + +test("apiFetch sends the implicit machine token only to loopback destinations", async () => { + const originalBaseUrl = process.env.OMNIROUTE_BASE_URL; + const originalOverride = process.env.OMNIROUTE_CLI_TOKEN; + process.env.OMNIROUTE_BASE_URL = "http://127.0.0.1:20128"; + delete process.env.OMNIROUTE_CLI_TOKEN; + try { + const [{ buildHeaders, isLoopbackUrl }, { getCliToken }] = await Promise.all([ + import(`../../bin/cli/api.mjs?loopback=${Date.now()}`), + import("../../bin/cli/utils/cliToken.mjs"), + ]); + assert.equal(isLoopbackUrl("http://localhost:20128"), true); + assert.equal(isLoopbackUrl("http://127.0.0.42:20128"), true); + assert.equal(isLoopbackUrl("http://[::1]:20128"), true); + assert.equal(isLoopbackUrl("https://remote.example.test"), false); + const headers = await buildHeaders({}); + assert.equal(headers.get("x-omniroute-cli-token"), await getCliToken()); + } finally { + if (originalBaseUrl === undefined) delete process.env.OMNIROUTE_BASE_URL; + else process.env.OMNIROUTE_BASE_URL = originalBaseUrl; + if (originalOverride === undefined) delete process.env.OMNIROUTE_CLI_TOKEN; + else process.env.OMNIROUTE_CLI_TOKEN = originalOverride; + } +}); + +test("CLI-token overrides are also suppressed for remote contexts", async () => { + const originalBaseUrl = process.env.OMNIROUTE_BASE_URL; + const originalOverride = process.env.OMNIROUTE_CLI_TOKEN; + process.env.OMNIROUTE_BASE_URL = "https://remote.example.test"; + process.env.OMNIROUTE_CLI_TOKEN = "must-not-leave-loopback"; + try { + const { buildHeaders } = await import(`../../bin/cli/api.mjs?override=${Date.now()}`); + const headers = await buildHeaders({ cliToken: "also-local-only" }); + assert.equal(headers.has("x-omniroute-cli-token"), false); + } finally { + if (originalBaseUrl === undefined) delete process.env.OMNIROUTE_BASE_URL; + else process.env.OMNIROUTE_BASE_URL = originalBaseUrl; + if (originalOverride === undefined) delete process.env.OMNIROUTE_CLI_TOKEN; + else process.env.OMNIROUTE_CLI_TOKEN = originalOverride; + } +}); + +test("absolute remote URLs cannot inherit a local context machine token", async () => { + const originalBaseUrl = process.env.OMNIROUTE_BASE_URL; + const originalOverride = process.env.OMNIROUTE_CLI_TOKEN; + const originalFetch = globalThis.fetch; + process.env.OMNIROUTE_BASE_URL = "http://127.0.0.1:20128"; + process.env.OMNIROUTE_CLI_TOKEN = "must-stay-local"; + let receivedHeaders: Headers | null = null; + globalThis.fetch = (async (_url, init) => { + receivedHeaders = new Headers(init?.headers); + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + try { + const { apiFetch } = await import(`../../bin/cli/api.mjs?absolute=${Date.now()}`); + await apiFetch("https://remote.example.test/probe", { retry: false }); + assert.equal(receivedHeaders?.has("x-omniroute-cli-token"), false); + } finally { + globalThis.fetch = originalFetch; + if (originalBaseUrl === undefined) delete process.env.OMNIROUTE_BASE_URL; + else process.env.OMNIROUTE_BASE_URL = originalBaseUrl; + if (originalOverride === undefined) delete process.env.OMNIROUTE_CLI_TOKEN; + else process.env.OMNIROUTE_CLI_TOKEN = originalOverride; + } +}); + +test("apiFetch refuses redirects while carrying a local machine token", async () => { + const originalBaseUrl = process.env.OMNIROUTE_BASE_URL; + const originalOverride = process.env.OMNIROUTE_CLI_TOKEN; + let redirectedRequests = 0; + const destination = http.createServer((_request, response) => { + redirectedRequests += 1; + response.end("unexpected"); + }); + const redirector = http.createServer((_request, response) => { + const destinationAddress = destination.address(); + assert.ok(destinationAddress && typeof destinationAddress === "object"); + response.writeHead(302, { location: `http://127.0.0.1:${destinationAddress.port}/target` }); + response.end(); + }); + await new Promise((resolve) => destination.listen(0, "127.0.0.1", resolve)); + await new Promise((resolve) => redirector.listen(0, "127.0.0.1", resolve)); + const redirectorAddress = redirector.address(); + assert.ok(redirectorAddress && typeof redirectorAddress === "object"); + process.env.OMNIROUTE_BASE_URL = `http://127.0.0.1:${redirectorAddress.port}`; + process.env.OMNIROUTE_CLI_TOKEN = "redirect-secret"; + try { + const { apiFetch } = await import(`../../bin/cli/api.mjs?redirect=${Date.now()}`); + await assert.rejects(() => apiFetch("/redirect", { retry: false }), /fetch failed/i); + assert.equal(redirectedRequests, 0); + } finally { + await Promise.all([ + new Promise((resolve) => redirector.close(() => resolve())), + new Promise((resolve) => destination.close(() => resolve())), + ]); + if (originalBaseUrl === undefined) delete process.env.OMNIROUTE_BASE_URL; + else process.env.OMNIROUTE_BASE_URL = originalBaseUrl; + if (originalOverride === undefined) delete process.env.OMNIROUTE_CLI_TOKEN; + else process.env.OMNIROUTE_CLI_TOKEN = originalOverride; + } +}); + // --- testes server-side: isLoopback --- test("isLoopback aceita 127.0.0.1", async () => { diff --git a/tests/unit/lib/machineToken.test.ts b/tests/unit/lib/machineToken.test.ts index addd7dc7ae..003780e15d 100644 --- a/tests/unit/lib/machineToken.test.ts +++ b/tests/unit/lib/machineToken.test.ts @@ -1,6 +1,15 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { getMachineTokenSync } from "../../../src/lib/machineToken.ts"; +import { + deriveLegacyCliToken, + deriveMachineToken, + getMachineTokenSync, +} from "../../../src/lib/machineToken.ts"; + +test("machine-token derivation fails closed for a missing machine ID", () => { + assert.equal(deriveMachineToken("", "omniroute-cli-auth-v1"), ""); + assert.equal(deriveLegacyCliToken("", "omniroute-cli-auth-v1"), ""); +}); test("getMachineTokenSync returns a 64-character hex string (full SHA-256)", () => { const token = getMachineTokenSync(); @@ -22,10 +31,15 @@ test("getMachineTokenSync with empty string salt does not throw", () => { }); test("getMachineTokenSync respects OMNIROUTE_CLI_SALT env var", () => { - const before = getMachineTokenSync(); - process.env.OMNIROUTE_CLI_SALT = "__test_salt__"; - const withEnv = getMachineTokenSync(); - delete process.env.OMNIROUTE_CLI_SALT; - assert.notEqual(before, withEnv, "env salt must produce a different token"); - assert.match(withEnv, /^[0-9a-f]{64}$/, "env-derived token must still be 64-char hex"); + const previous = process.env.OMNIROUTE_CLI_SALT; + try { + const before = getMachineTokenSync(); + process.env.OMNIROUTE_CLI_SALT = "__test_salt__"; + const withEnv = getMachineTokenSync(); + assert.notEqual(before, withEnv, "env salt must produce a different token"); + assert.match(withEnv, /^[0-9a-f]{64}$/, "env-derived token must still be 64-char hex"); + } finally { + if (previous === undefined) delete process.env.OMNIROUTE_CLI_SALT; + else process.env.OMNIROUTE_CLI_SALT = previous; + } }); diff --git a/tests/unit/lib/managementCliToken.test.ts b/tests/unit/lib/managementCliToken.test.ts index 0a817e2859..638c4c3fd7 100644 --- a/tests/unit/lib/managementCliToken.test.ts +++ b/tests/unit/lib/managementCliToken.test.ts @@ -20,9 +20,9 @@ await settingsDb.updateSettings({ password: "test-password-hash", }); -const { getLegacyCliTokenSync, getMachineTokenSync } = await import( - "../../../src/lib/machineToken.ts" -); +const { getLegacyCliTokenSync, getMachineTokenSync } = + await import("../../../src/lib/machineToken.ts"); +const { requireManagementAuth } = await import("../../../src/lib/api/requireManagementAuth.ts"); const { managementPolicy } = await import("../../../src/server/authz/policies/management.ts"); const { CLI_TOKEN_HEADER } = await import("../../../src/server/authz/headers.ts"); @@ -103,3 +103,34 @@ test("management policy rejects wrong CLI token from localhost", async () => { const outcome = await managementPolicy.evaluate(ctx); assert.equal(outcome.allow, false); }); + +test("route-level auth trusts only the central local-CLI subject stamp", async () => { + const request = new Request("http://localhost/api/cli/whoami", { + headers: { + "x-omniroute-auth-kind": "management_key", + "x-omniroute-auth-label": "local-cli-token", + }, + }); + assert.equal(await requireManagementAuth(request, { alwaysRequireAuth: true }), null); + + const spoofedLabelOnly = new Request("http://localhost/api/cli/whoami", { + headers: { "x-omniroute-auth-label": "local-cli-token" }, + }); + assert.notEqual(await requireManagementAuth(spoofedLabelOnly, { alwaysRequireAuth: true }), null); +}); + +test("management policy rejects machine tokens when CLI-token auth is disabled", async () => { + const previous = process.env.OMNIROUTE_DISABLE_CLI_TOKEN; + process.env.OMNIROUTE_DISABLE_CLI_TOKEN = "true"; + try { + const ctx = makeCtx( + { host: "localhost", [CLI_TOKEN_HEADER]: getMachineTokenSync() }, + { socket: { remoteAddress: "127.0.0.1" } } + ); + const outcome = await managementPolicy.evaluate(ctx); + assert.equal(outcome.allow, false); + } finally { + if (previous === undefined) delete process.env.OMNIROUTE_DISABLE_CLI_TOKEN; + else process.env.OMNIROUTE_DISABLE_CLI_TOKEN = previous; + } +}); diff --git a/tests/unit/next-config.test.ts b/tests/unit/next-config.test.ts index 9281175217..6291fb69f2 100644 --- a/tests/unit/next-config.test.ts +++ b/tests/unit/next-config.test.ts @@ -105,6 +105,7 @@ test("next config declares Turbopack aliases, runtime assets and server external // sqlite-vec ships a native vec0.so loaded at runtime; without externalizing it // the Turbopack build fails with "Unknown module type" on the .so (issue #3066). "sqlite-vec", + "node-machine-id", "wreq-js", "fs", "path", @@ -126,10 +127,7 @@ test("Turbopack aliases @/mitm/manager to the stub ONLY when OMNIROUTE_MITM_STUB process.env.OMNIROUTE_MITM_STUB = "1"; const { default: docker } = await loadNextConfig("mitm-docker"); - assert.equal( - docker.turbopack.resolveAlias["@/mitm/manager"], - "./src/mitm/manager.stub.ts" - ); + assert.equal(docker.turbopack.resolveAlias["@/mitm/manager"], "./src/mitm/manager.stub.ts"); } finally { if (original === undefined) delete process.env.OMNIROUTE_MITM_STUB; else process.env.OMNIROUTE_MITM_STUB = original; @@ -198,7 +196,11 @@ test("manager.stub.ts exports every name statically imported from @/mitm/manager } for (const m of stubSrc.matchAll(/export\s*\{([^}]*)\}/g)) { for (const part of m[1].split(",")) { - const exported = part.trim().split(/\s+as\s+/).pop()?.trim(); // `x as y` exports y + const exported = part + .trim() + .split(/\s+as\s+/) + .pop() + ?.trim(); // `x as y` exports y if (exported) stubExports.add(exported); } }