fix(cli): restore packaged machine-token authentication (#10468)

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.
This commit is contained in:
小妍儿 ✨
2026-08-21 02:07:19 +08:00
committed by GitHub
parent ff9a4c2fbd
commit 621f30a188
34 changed files with 1078 additions and 247 deletions

View File

@@ -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"));

View File

@@ -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 <host> --key <oma_live_...>`",
{
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

View File

@@ -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 = "";
}

View File

@@ -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:<port>/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

View File

@@ -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();

View File

@@ -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,

View File

@@ -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 '{}'
```

View File

@@ -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":"<management-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

View File

@@ -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 '{}'
```

View File

@@ -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 '{}'
```

View File

@@ -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 '{}'
```

View File

@@ -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 '{}'
```

View File

@@ -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 '{}'
```

View File

@@ -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 OCRcompatible 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 Whispercompatible 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 '{}'
```

View File

@@ -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 '{}'
```

View File

@@ -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 '{}'
```

View File

@@ -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 '{}'
```

View File

@@ -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 '{}'
```

View File

@@ -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 '{}'
```

View File

@@ -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 <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 '{}'
```

View File

@@ -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<string> {
} 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 {

View File

@@ -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<typeof areaMap.get>[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":"<management-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("");

View File

@@ -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;
}

View File

@@ -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 "";
}

View File

@@ -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 });

View File

@@ -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);

View File

@@ -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("<!-- generated by src/lib/agentSkills/generator.ts"),
"Missing generated comment",
"Missing generated comment"
);
} finally {
rmTmpDir(tmpDir);
@@ -200,6 +197,66 @@ test("apply mode writes SKILL.md for an API skill with correct sections", async
assert.ok(content.includes("## Authentication"), "Missing Authentication section");
assert.ok(content.includes("## Endpoints"), "Missing endpoints section");
assert.ok(content.includes("## Payloads"), "Missing payloads section");
assert.ok(content.includes('-d \'{"password":"<management-password>"}\''));
assert.ok(content.includes("-c cookie.jar"), "login must save the dashboard session cookie");
assert.ok(content.includes("-b cookie.jar"), "auth examples must send the session cookie");
assert.ok(content.includes("x-omniroute-csrf"), "mutations must include a CSRF token");
const loginExample = content.slice(
content.indexOf("### POST /api/auth/login"),
content.indexOf("### POST /api/auth/logout")
);
assert.ok(!loginExample.includes("Authorization: Bearer"));
const logoutExample = content.slice(
content.indexOf("### POST /api/auth/logout"),
content.indexOf("### GET /api/auth/oidc/login")
);
assert.ok(logoutExample.includes("-b cookie.jar"));
assert.ok(logoutExample.includes("x-omniroute-csrf"));
assert.ok(!logoutExample.includes("Authorization: Bearer"));
} finally {
rmTmpDir(tmpDir);
}
});
test("generic API skill GET and mutation examples use standalone Bearer auth", async () => {
const tmpDir = mkTmpDir();
try {
refreshCatalog();
const report = await generateAgentSkills({
dryRun: false,
prune: false,
outputDir: tmpDir,
onlyIds: ["omni-providers", "omni-settings"],
});
assert.equal(report.errors.length, 0, `Errors: ${JSON.stringify(report.errors)}`);
for (const id of ["omni-providers", "omni-settings"]) {
const content = fs.readFileSync(path.join(tmpDir, id, "SKILL.md"), "utf-8");
assert.ok(content.includes(' -H "Authorization: Bearer $OMNIROUTE_TOKEN"'));
assert.ok(!content.includes("cookie.jar"), `${id} must not assume a session cookie`);
assert.ok(!content.includes("CSRF_TOKEN"), `${id} must not assume a CSRF token`);
}
const providers = fs.readFileSync(path.join(tmpDir, "omni-providers", "SKILL.md"), "utf-8");
const providersGet = providers.slice(
providers.indexOf("### GET /api/providers"),
providers.indexOf("### POST /api/providers")
);
const providersPost = providers.slice(
providers.indexOf("### POST /api/providers"),
providers.indexOf("### GET /api/providers/{id}")
);
assert.ok(providersGet.includes(' -H "Authorization: Bearer $OMNIROUTE_TOKEN"'));
assert.ok(providersPost.includes(' -H "Authorization: Bearer $OMNIROUTE_TOKEN" \\\n'));
assert.ok(providersPost.includes(' -H "Content-Type: application/json" \\\n'));
const settings = fs.readFileSync(path.join(tmpDir, "omni-settings", "SKILL.md"), "utf-8");
const settingsPatch = settings.slice(
settings.indexOf("### PATCH /api/settings"),
settings.indexOf("### POST /api/settings/purge-request-history")
);
assert.ok(settingsPatch.includes(' -H "Authorization: Bearer $OMNIROUTE_TOKEN" \\\n'));
assert.ok(settingsPatch.includes(' -H "Content-Type: application/json" \\\n'));
} finally {
rmTmpDir(tmpDir);
}
@@ -283,10 +340,7 @@ test("prune apply mode deletes orphan dirs", async () => {
assert.ok(report.pruned.includes("old-orphan-to-delete"), "Orphan not pruned");
// Orphan dir should be gone
assert.ok(
!fs.existsSync(orphanDir),
"Orphan dir was NOT deleted in apply+prune mode",
);
assert.ok(!fs.existsSync(orphanDir), "Orphan dir was NOT deleted in apply+prune mode");
} finally {
rmTmpDir(tmpDir);
}
@@ -316,7 +370,7 @@ test("prune does not delete catalog skill dirs", async () => {
// omni-providers should not be in orphansDetected
assert.ok(
!report.orphansDetected.includes("omni-providers"),
"Valid skill mistakenly flagged as orphan",
"Valid skill mistakenly flagged as orphan"
);
assert.ok(report.orphansDetected.includes("orphan-xyz"), "Orphan not detected");
} finally {
@@ -343,7 +397,8 @@ test("marker preservation: custom block survives regeneration", async () => {
const originalContent = fs.readFileSync(skillFile, "utf-8");
// Inject a custom block
const customBlock = "<!-- skill:custom-start -->\nMy custom content here.\n<!-- skill:custom-end -->";
const customBlock =
"<!-- skill:custom-start -->\nMy custom content here.\n<!-- skill:custom-end -->";
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("<!-- skill:custom-start -->"),
"Custom start marker missing",
);
assert.ok(
newContent.includes("<!-- skill:custom-end -->"),
"Custom end marker missing",
"Custom content was lost during regeneration"
);
assert.ok(newContent.includes("<!-- skill:custom-start -->"), "Custom start marker missing");
assert.ok(newContent.includes("<!-- skill:custom-end -->"), "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, &amp;, &lt;, &gt;
assert.ok(
!result.body.includes("\\\\n"),
`Skill ${id}: body contains \\\\n (double-escaped newline)`,
);
assert.ok(
!result.body.includes("&amp;"),
`Skill ${id}: body contains HTML entity &amp;`,
);
assert.ok(
!result.body.includes("&lt;"),
`Skill ${id}: body contains HTML entity &lt;`,
);
assert.ok(
!result.body.includes("&gt;"),
`Skill ${id}: body contains HTML entity &gt;`,
`Skill ${id}: body contains \\\\n (double-escaped newline)`
);
assert.ok(!result.body.includes("&amp;"), `Skill ${id}: body contains HTML entity &amp;`);
assert.ok(!result.body.includes("&lt;"), `Skill ${id}: body contains HTML entity &lt;`);
assert.ok(!result.body.includes("&gt;"), `Skill ${id}: body contains HTML entity &gt;`);
}
});
@@ -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("<!-- generated by src/lib/agentSkills/generator.ts; manual edits will be overwritten -->"),
"Missing mandatory generated comment",
content.includes(
"<!-- generated by src/lib/agentSkills/generator.ts; manual edits will be overwritten -->"
),
"Missing mandatory generated comment"
);
} finally {
rmTmpDir(tmpDir);

View File

@@ -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 () => {

View File

@@ -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");
});

View File

@@ -15,6 +15,8 @@ const ORIGINAL_STORAGE_ENCRYPTION_KEY = process.env.STORAGE_ENCRYPTION_KEY;
interface DoctorCheck {
name: string;
status: string;
message?: string;
details?: Record<string, unknown>;
}
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;
}
});

View File

@@ -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<void>((resolve) => destination.listen(0, "127.0.0.1", resolve));
await new Promise<void>((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<void>((resolve) => redirector.close(() => resolve())),
new Promise<void>((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 () => {

View File

@@ -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;
}
});

View File

@@ -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;
}
});

View File

@@ -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);
}
}