From a83409e8a1813ebd9eb4ea1260ac9b88a76fa7c0 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:57:48 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20remote=20mode=20=E2=80=94=20drive?= =?UTF-8?q?=20a=20remote=20OmniRoute=20with=20scoped=20access=20tokens=20(?= =?UTF-8?q?#4256)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote mode: drive a remote OmniRoute via scoped CLI access tokens (readβŠ‚writeβŠ‚admin). Includes the check:db-rules allowlist fix (535a5b673). Live VPS end-to-end (passwordβ†’tokenβ†’remote command) is a documented follow-up. Integrated into release/v3.8.29. --- README.md | 16 + bin/cli/api.mjs | 35 ++- bin/cli/commands/configure.mjs | 180 ++++++++++++ bin/cli/commands/connect.mjs | 132 +++++++++ bin/cli/commands/contexts.mjs | 55 +++- bin/cli/commands/registry.mjs | 6 + bin/cli/commands/tokens.mjs | 118 ++++++++ bin/cli/contexts.mjs | 21 +- bin/cli/locales/en.json | 9 + docs/guides/REMOTE-MODE.md | 173 +++++++++++ docs/reference/openapi.yaml | 86 ++++++ scripts/check/check-db-rules.mjs | 1 + .../dashboard/settings/access-tokens/page.tsx | 7 + .../settings/components/AccessTokensTab.tsx | 275 ++++++++++++++++++ src/app/api/cli/connect/route.ts | 156 ++++++++++ src/app/api/cli/tokens/[id]/route.ts | 23 ++ src/app/api/cli/tokens/route.ts | 60 ++++ src/app/api/cli/whoami/route.ts | 38 +++ src/i18n/messages/en.json | 2 + src/i18n/messages/pt-BR.json | 2 + src/lib/accessTokens/scopes.ts | 51 ++++ src/lib/api/requireManagementAuth.ts | 32 ++ src/lib/db/accessTokens.ts | 183 ++++++++++++ .../db/migrations/100_cli_access_tokens.sql | 18 ++ src/server/authz/accessScopes.ts | 62 ++++ src/server/authz/accessTokenAuth.ts | 67 +++++ src/server/authz/policies/management.ts | 27 ++ src/shared/constants/publicApiRoutes.ts | 4 + src/shared/constants/sidebarVisibility.ts | 10 + tests/unit/access-scopes-infer.test.ts | 48 +++ tests/unit/access-token-scopes.test.ts | 58 ++++ tests/unit/access-tokens-db.test.ts | 104 +++++++ tests/unit/cli-connect-helpers.test.ts | 35 +++ tests/unit/cli-remote-mode.test.ts | 178 ++++++++++++ ...quire-management-auth-access-token.test.ts | 84 ++++++ 35 files changed, 2332 insertions(+), 24 deletions(-) create mode 100644 bin/cli/commands/configure.mjs create mode 100644 bin/cli/commands/connect.mjs create mode 100644 bin/cli/commands/tokens.mjs create mode 100644 docs/guides/REMOTE-MODE.md create mode 100644 src/app/(dashboard)/dashboard/settings/access-tokens/page.tsx create mode 100644 src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx create mode 100644 src/app/api/cli/connect/route.ts create mode 100644 src/app/api/cli/tokens/[id]/route.ts create mode 100644 src/app/api/cli/tokens/route.ts create mode 100644 src/app/api/cli/whoami/route.ts create mode 100644 src/lib/accessTokens/scopes.ts create mode 100644 src/lib/db/accessTokens.ts create mode 100644 src/lib/db/migrations/100_cli_access_tokens.sql create mode 100644 src/server/authz/accessScopes.ts create mode 100644 src/server/authz/accessTokenAuth.ts create mode 100644 tests/unit/access-scopes-infer.test.ts create mode 100644 tests/unit/access-token-scopes.test.ts create mode 100644 tests/unit/access-tokens-db.test.ts create mode 100644 tests/unit/cli-connect-helpers.test.ts create mode 100644 tests/unit/cli-remote-mode.test.ts create mode 100644 tests/unit/require-management-auth-access-token.test.ts diff --git a/README.md b/README.md index 322ac327b0..423b31168c 100644 --- a/README.md +++ b/README.md @@ -408,6 +408,21 @@ omniroute setup # guided first-run wizard omniroute doctor # diagnose providers, ports, native deps ``` +### πŸ›°οΈ Remote mode β€” run the CLI here, OmniRoute on a VPS + +OmniRoute on a server? Drive it from your laptop with the **same CLI**. Log in once +with a scoped access token; every command then targets the remote. + +```bash +omniroute connect 192.168.0.15 # password β†’ scoped token, saved as a context +omniroute models list # ← runs against the REMOTE server +omniroute configure codex # ← picks a remote model, writes a local Codex profile +omniroute tokens create --name ci --scope read # mint narrower tokens for other machines +``` + +Tokens are scoped `read` / `write` / `admin`; process-spawning routes stay loopback-only. +πŸ“– [Remote Mode](docs/guides/REMOTE-MODE.md) +
`providers` Β· `oauth` Β· `keys` Β· `combo` Β· `nodes` Β· `models` Β· `cache` Β· `compression` Β· `cost` Β· `usage` Β· `quota` Β· `health` Β· `resilience` Β· `telemetry` Β· `logs` Β· `audit` Β· `mcp` Β· `a2a` Β· `cloud` Β· `memory` Β· `skills` Β· `eval` Β· `tunnel` Β· `backup` Β· `sync` Β· `webhooks` Β· `policy` Β· `pricing` Β· `translator` Β· `simulate` … @@ -845,6 +860,7 @@ Compression: aggressive (~50%) β†’ double your free quota Β· Cost: $0/mo | [User Guide](docs/guides/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | | [Setup Guide](docs/guides/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning | | [CLI Tools Guide](docs/reference/CLI-TOOLS.md) | Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot | +| [Remote Mode](docs/guides/REMOTE-MODE.md) | Drive a remote OmniRoute (VPS) from your laptop CLI via scoped access tokens | | [Quick Start](README.md#-quick-start) | 3-step install β†’ connect β†’ configure | ### πŸ”§ Operations & Deployment diff --git a/bin/cli/api.mjs b/bin/cli/api.mjs index 30a7feec60..ca2e09fa37 100644 --- a/bin/cli/api.mjs +++ b/bin/cli/api.mjs @@ -1,8 +1,6 @@ import { setTimeout as sleep } from "node:timers/promises"; -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { resolveDataDir } from "./data-dir.mjs"; import { getCliToken, CLI_TOKEN_HEADER } from "./utils/cliToken.mjs"; +import { resolveActiveContext } from "./contexts.mjs"; export const RETRY_DEFAULTS = Object.freeze({ maxAttempts: 3, @@ -28,14 +26,12 @@ export function getBaseUrl(opts = {}) { const envUrl = process.env.OMNIROUTE_BASE_URL; if (envUrl) return stripTrailingSlash(envUrl); + // Resolve from the active context (canonical store + legacy profile fallback). + // This is what makes "remote mode" work: `omniroute contexts use ` + // routes every command at the remote server's baseUrl. try { - const configPath = join(resolveDataDir(), "config.json"); - if (existsSync(configPath)) { - const cfg = JSON.parse(readFileSync(configPath, "utf8")); - const profile = cfg.activeProfile && cfg.profiles?.[cfg.activeProfile]; - if (profile?.baseUrl) return stripTrailingSlash(profile.baseUrl); - if (cfg.baseUrl) return stripTrailingSlash(cfg.baseUrl); - } + const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); + if (ctx?.baseUrl) return stripTrailingSlash(ctx.baseUrl); } catch { // Config read failures are not fatal β€” fall through to default. } @@ -56,15 +52,26 @@ function resolveUrl(path, opts) { return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`; } -async function buildHeaders(opts) { +export async function buildHeaders(opts) { const headers = new Headers(opts.headers || {}); if (!headers.has("accept")) headers.set("accept", "application/json"); if (opts.body && !headers.has("content-type") && typeof opts.body !== "string") { headers.set("content-type", "application/json"); } - const apiKey = opts.apiKey ?? process.env.OMNIROUTE_API_KEY; - if (apiKey && !headers.has("authorization")) { - headers.set("authorization", `Bearer ${apiKey}`); + // Auth precedence: explicit opts/env β†’ active context. Within a context the + // scoped accessToken wins over the legacy apiKey. This routes the active + // context's credential to the (possibly remote) server automatically. + let auth = opts.apiKey ?? process.env.OMNIROUTE_API_KEY; + if (!auth) { + try { + const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); + auth = ctx?.accessToken || ctx?.apiKey || null; + } catch { + // No context credential available β€” continue unauthenticated. + } + } + 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()); diff --git a/bin/cli/commands/configure.mjs b/bin/cli/commands/configure.mjs new file mode 100644 index 0000000000..2a92cd25a2 --- /dev/null +++ b/bin/cli/commands/configure.mjs @@ -0,0 +1,180 @@ +import os from "node:os"; +import path from "node:path"; +import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs"; +import { apiFetch } from "../api.mjs"; +import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs"; +import { t } from "../i18n.mjs"; + +/** + * `omniroute configure ` β€” interactive provider+model picker that writes a + * local CLI config pointed at the ACTIVE OmniRoute context (local or remote). + * + * The model catalog comes from the active context's GET /v1/models, so when you + * are in remote mode (`omniroute connect ...`) you pick from the remote server's + * live models and the profile is written on THIS machine. + * + * v1 targets the Codex CLI (writes ~/.codex/.config.toml). The credential + * is referenced by env var (OMNIROUTE_API_KEY) β€” never written to disk. + */ + +const SUPPORTED = ["codex"]; + +/** Derive a short, filesystem-safe profile name from a model id. */ +export function profileNameFromModel(modelId) { + const afterProvider = String(modelId).includes("/") + ? String(modelId).split("/").slice(1).join("/") + : String(modelId); + return afterProvider.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase() || "model"; +} + +/** Provider id for a catalog entry: explicit owned_by, else the id prefix. */ +function providerOf(entry) { + if (entry && typeof entry.owned_by === "string" && entry.owned_by) return entry.owned_by; + const id = typeof entry === "string" ? entry : entry?.id || ""; + return id.includes("/") ? id.split("/")[0] : "(none)"; +} + +function contextWindowOf(entry) { + for (const c of [entry?.context_length, entry?.max_context_window_tokens]) { + if (typeof c === "number" && Number.isFinite(c) && c > 0) return c; + } + return null; +} + +async function fetchModels(globalOpts) { + const res = await apiFetch("/v1/models", { ...globalOpts, acceptNotOk: true }); + if (!res.ok) { + let msg = `HTTP ${res.status}`; + try { + const b = await res.json(); + msg = b?.error?.message || b?.error || msg; + } catch { + /* ignore */ + } + throw new Error(`Could not fetch models: ${msg}`); + } + const body = await res.json(); + const list = Array.isArray(body) ? body : body.data || body.models || []; + return list.filter((m) => (typeof m === "string" ? m : m?.id)); +} + +function buildCodexProfile(modelId, ctx) { + const lines = [ + `# codex --profile ${profileNameFromModel(modelId)}`, + `# ${modelId} β€” generated by 'omniroute configure codex'`, + `model = "${modelId}"`, + `model_provider = "omniroute"`, + ]; + if (ctx && ctx > 0) { + const compact = Math.floor(ctx * 0.85); + lines.push(`model_context_window = ${ctx}`); + lines.push(`model_auto_compact_token_limit = ${compact}`); + } + return lines.join("\n") + "\n"; +} + +async function configureCodex(modelId, ctxWindow, opts) { + const codexHome = opts.codexHome || path.join(os.homedir(), ".codex"); + if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true }); + const profile = opts.name || profileNameFromModel(modelId); + const filePath = path.join(codexHome, `${profile}.config.toml`); + if (existsSync(filePath)) { + copyFileSync(filePath, `${filePath}.bak`); + } + writeFileSync(filePath, buildCodexProfile(modelId, ctxWindow), "utf8"); + printSuccess(`Wrote ${filePath}`); + printInfo(`Use it: codex --profile ${profile}`); + printInfo("Prereq: ~/.codex/config.toml must define the [model_providers.omniroute] block"); + printInfo(" (run the Codex setup once β€” see docs/guides/CODEX-CLI-CONFIGURATION.md)."); +} + +export async function runConfigureCommand(cli, opts = {}, cmd) { + const target = String(cli || "").toLowerCase(); + if (!SUPPORTED.includes(target)) { + printError(`Unsupported CLI '${cli}'. Supported: ${SUPPORTED.join(", ")}.`); + return 2; + } + const globalOpts = cmd ? cmd.optsWithGlobals() : {}; + + let models; + try { + models = await fetchModels(globalOpts); + } catch (e) { + printError(e instanceof Error ? e.message : String(e)); + return 1; + } + if (!models.length) { + printError("The server returned no models."); + return 1; + } + + // Resolve model: explicit flags or interactive pick. + let chosenId = opts.model; + if (chosenId && opts.provider && !chosenId.includes("/")) { + chosenId = `${opts.provider}/${chosenId}`; + } + + if (!chosenId) { + const ids = models.map((m) => (typeof m === "string" ? m : m.id)); + const providers = [...new Set(models.map(providerOf))].sort(); + const prompt = createPrompt(); + try { + printHeading("Configure Codex CLI"); + let providerList = providers; + if (opts.provider) { + providerList = providers.filter((p) => p === opts.provider); + } else { + printInfo(`Providers: ${providers.join(", ")}`); + const p = await prompt.ask("Provider"); + if (p) providerList = providers.filter((x) => x === p); + } + const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id)))); + const candidates = inProvider.length ? inProvider : ids; + printInfo(`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`); + chosenId = await prompt.ask("Model id"); + } finally { + prompt.close(); + } + } + + if (!chosenId) { + printError("No model selected."); + return 2; + } + const entry = byId(models, chosenId); + if (!entry) { + printError(`Model '${chosenId}' is not in the catalog.`); + return 2; + } + const ctxWindow = contextWindowOf(entry); + + if (target === "codex") { + await configureCodex(chosenId, ctxWindow, opts); + } + return 0; +} + +function byId(models, id) { + for (const m of models) { + const mid = typeof m === "string" ? m : m.id; + if (mid === id) return m; + } + return null; +} + +export function registerConfigure(program) { + program + .command("configure ") + .description( + t("configure.description") || + "Pick a provider+model from the active server and write a local CLI config (v1: codex)" + ) + .option("--provider ", "Provider id (skips the interactive provider prompt)") + .option("--model ", "Model id (skips the interactive model prompt)") + .option("--name ", "Profile name to write (default: derived from model)") + .option("--codex-home ", "Codex home dir (default: ~/.codex)") + .action(async (cli, opts, cmd) => { + const code = await runConfigureCommand(cli, opts, cmd); + if (code !== 0) process.exit(code); + }); +} diff --git a/bin/cli/commands/connect.mjs b/bin/cli/commands/connect.mjs new file mode 100644 index 0000000000..b7ec71ae97 --- /dev/null +++ b/bin/cli/commands/connect.mjs @@ -0,0 +1,132 @@ +import { apiFetch } from "../api.mjs"; +import { loadContexts, saveContexts } from "../contexts.mjs"; +import { createPrompt, printSuccess, printError, printInfo } from "../io.mjs"; +import { t } from "../i18n.mjs"; + +/** + * `omniroute connect ` β€” remote mode. + * + * Logs into a remote OmniRoute server and saves the result as the active context + * so every subsequent command targets that server. Two flows: + * - password: prompts for the management password β†’ POST /api/cli/connect β†’ + * server mints a scoped access token (default scope: admin). + * - token: `--key ` validates via GET /api/cli/whoami and saves it. + */ + +/** Normalize a host/URL into a server root baseUrl (no trailing path). */ +export function normalizeBaseUrl(host, port) { + let value = String(host || "").trim(); + if (!value) return ""; + const hadScheme = /^https?:\/\//i.test(value); + if (!hadScheme) value = `http://${value}`; + try { + const u = new URL(value); + // Only apply the default port to a bare host; a full URL is taken as-is. + if (!hadScheme && !u.port && port) u.port = String(port); + return u.origin; + } catch { + return value; + } +} + +/** Derive a clean context name from a host (strip scheme/port). */ +export function hostLabel(host) { + let value = String(host || "").trim().replace(/^https?:\/\//i, ""); + value = value.split("/")[0].split(":")[0]; + return value || "remote"; +} + +async function readErrorMessage(res) { + try { + const body = await res.json(); + return body?.error?.message || body?.error || `HTTP ${res.status}`; + } catch { + return `HTTP ${res.status}`; + } +} + +export async function runConnectCommand(host, opts = {}) { + const baseUrl = normalizeBaseUrl(host, opts.port || "20128"); + if (!baseUrl) { + printError("A host is required, e.g. omniroute connect 192.168.0.15"); + return 2; + } + const name = opts.name || hostLabel(host); + + let accessToken; + let scope; + + if (opts.key) { + // Validate the pasted token against the remote. + const res = await apiFetch("/api/cli/whoami", { + baseUrl, + apiKey: opts.key, + acceptNotOk: true, + }); + if (!res.ok) { + printError(`Token rejected by ${baseUrl}: ${await readErrorMessage(res)}`); + return res.exitCode || 1; + } + const body = await res.json(); + accessToken = opts.key; + scope = body.scope || "unknown"; + } else { + const prompt = createPrompt(); + let password; + try { + password = await prompt.askSecret(`Management password for ${baseUrl}`); + } finally { + prompt.close(); + } + if (!password) { + printError("Password is required (or use --key )."); + return 2; + } + const res = await apiFetch("/api/cli/connect", { + baseUrl, + method: "POST", + body: { password, name, scope: opts.scope }, + acceptNotOk: true, + retry: false, + }); + if (!res.ok) { + printError(`Connect failed (${res.status}): ${await readErrorMessage(res)}`); + return res.exitCode || 1; + } + const body = await res.json(); + accessToken = body.token; + scope = body.scope; + } + + const cfg = loadContexts(); + cfg.contexts = cfg.contexts || {}; + cfg.contexts[name] = { + baseUrl, + accessToken, + scope, + description: `Remote OmniRoute (${host})`, + }; + cfg.currentContext = name; + saveContexts(cfg); + + printSuccess(`Connected to ${baseUrl} β€” context '${name}' (scope: ${scope})`); + printInfo("All commands now target this server."); + printInfo("Switch back to local with: omniroute contexts use default"); + return 0; +} + +export function registerConnect(program) { + program + .command("connect ") + .description( + t("connect.description") || "Connect to a remote OmniRoute server and enter remote mode" + ) + .option("--port ", "Server port when the host has none", "20128") + .option("--key ", "Use a pre-generated scoped access token (skips the password prompt)") + .option("--name ", "Context name to save (default: derived from host)") + .option("--scope ", "Requested scope for the password flow (read|write|admin)") + .action(async (host, opts) => { + const code = await runConnectCommand(host, opts); + if (code !== 0) process.exit(code); + }); +} diff --git a/bin/cli/commands/contexts.mjs b/bin/cli/commands/contexts.mjs index 63a3dde925..00f565ed96 100644 --- a/bin/cli/commands/contexts.mjs +++ b/bin/cli/commands/contexts.mjs @@ -1,6 +1,13 @@ import { t } from "../i18n.mjs"; import { emit } from "../output.mjs"; -import { loadContexts, saveContexts, configPath } from "../contexts.mjs"; +import { loadContexts, saveContexts, resolveActiveContext } from "../contexts.mjs"; + +/** Auth label for a context: prefers the scoped accessToken over the legacy apiKey. */ +function authLabel(c) { + if (c?.accessToken) return "token"; + if (c?.apiKey) return "key"; + return "βœ—"; +} async function confirm(msg) { const readline = await import("node:readline"); @@ -31,7 +38,8 @@ export function registerContexts(program) { active: name === (cfg.currentContext || "default") ? "●" : "", name, baseUrl: c.baseUrl || "", - auth: c.apiKey ? "βœ“" : "βœ—", + auth: authLabel(c), + scope: c.scope || "", description: c.description || "", })); emit(rows, globalOpts, [ @@ -39,6 +47,7 @@ export function registerContexts(program) { { key: "name", header: "Name" }, { key: "baseUrl", header: "Base URL" }, { key: "auth", header: "Auth" }, + { key: "scope", header: "Scope" }, { key: "description", header: "Description" }, ]); }); @@ -47,8 +56,11 @@ export function registerContexts(program) { .command("add ") .description("Add a new context") .requiredOption("--url ", "Base URL") - .option("--api-key ", "API key") + .option("--api-key ", "Legacy inference API key") .option("--api-key-stdin", "Read API key from stdin") + .option("--access-token ", "Scoped CLI access token (preferred over --api-key)") + .option("--access-token-stdin", "Read access token from stdin") + .option("--scope ", "Token scope hint for display (read|write|admin)") .option("--description ", "Context description") .action(async (name, opts) => { const cfg = loadContexts(); @@ -57,15 +69,20 @@ export function registerContexts(program) { process.exit(2); } let apiKey = opts.apiKey || null; - if (opts.apiKeyStdin) { + let accessToken = opts.accessToken || null; + if (opts.apiKeyStdin || opts.accessTokenStdin) { const chunks = []; for await (const c of process.stdin) chunks.push(c); - apiKey = chunks.join("").trim() || null; + const value = chunks.join("").trim() || null; + if (opts.accessTokenStdin) accessToken = value; + else apiKey = value; } cfg.contexts = cfg.contexts || {}; cfg.contexts[name] = { baseUrl: opts.url, + accessToken: accessToken || undefined, apiKey, + scope: opts.scope || undefined, description: opts.description || undefined, }; saveContexts(cfg); @@ -88,10 +105,27 @@ export function registerContexts(program) { ctx .command("current") - .description("Show current active context name") - .action(() => { + .description("Show the active context (server, auth, scope)") + .option("--name-only", "Print just the context name (legacy behavior)") + .action((opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); const cfg = loadContexts(); - process.stdout.write(`${cfg.currentContext || "default"}\n`); + const name = cfg.currentContext || cfg.activeProfile || "default"; + if (opts.nameOnly) { + process.stdout.write(`${name}\n`); + return; + } + const c = resolveActiveContext(name); + emit( + { + name, + baseUrl: c.baseUrl || "", + auth: authLabel(c), + scope: c.scope || "", + description: c.description || "", + }, + globalOpts + ); }); ctx @@ -108,7 +142,9 @@ export function registerContexts(program) { const display = { name, baseUrl: c.baseUrl, + accessToken: maskKey(c.accessToken), apiKey: maskKey(c.apiKey), + scope: c.scope, description: c.description, }; emit(display, globalOpts); @@ -172,6 +208,7 @@ export function registerContexts(program) { if (opts.noSecrets) { for (const c of Object.values(out.contexts || {})) { c.apiKey = null; + delete c.accessToken; } } const json = JSON.stringify(out, null, 2); @@ -209,7 +246,9 @@ export function registerContexts(program) { const c = raw && typeof raw === "object" ? /** @type {Record} */ (raw) : {}; cfg.contexts[name] = { baseUrl: typeof c.baseUrl === "string" ? c.baseUrl : "http://localhost:20128", + accessToken: typeof c.accessToken === "string" ? c.accessToken : undefined, apiKey: typeof c.apiKey === "string" ? c.apiKey : null, + scope: typeof c.scope === "string" ? c.scope : undefined, description: typeof c.description === "string" ? c.description : undefined, }; count++; diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index a99f64a730..f3d4f197a1 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -56,6 +56,9 @@ import { registerTray } from "./tray.mjs"; import { registerAutostart } from "./autostart.mjs"; import { registerRepl } from "./repl.mjs"; import { registerLaunch } from "./launch.mjs"; +import { registerConnect } from "./connect.mjs"; +import { registerTokens } from "./tokens.mjs"; +import { registerConfigure } from "./configure.mjs"; import { registerApiCommands } from "../api-commands/registry.mjs"; import { registerPlugin } from "./plugin.mjs"; @@ -119,6 +122,9 @@ export function registerCommands(program) { registerAutostart(program); registerRepl(program); registerLaunch(program); + registerConnect(program); + registerTokens(program); + registerConfigure(program); registerApiCommands(program); registerPlugin(program); } diff --git a/bin/cli/commands/tokens.mjs b/bin/cli/commands/tokens.mjs new file mode 100644 index 0000000000..31327cf1d6 --- /dev/null +++ b/bin/cli/commands/tokens.mjs @@ -0,0 +1,118 @@ +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { printSuccess, printError, printInfo } from "../io.mjs"; +import { t } from "../i18n.mjs"; + +/** + * `omniroute tokens` β€” manage scoped CLI access tokens on the active (usually + * remote) server. Requires an `admin` credential β€” the commands hit + * /api/cli/tokens which is admin-only. Uses the active context's auth via + * apiFetch automatically. + */ + +async function readErrorMessage(res) { + try { + const body = await res.json(); + return body?.error?.message || body?.error || `HTTP ${res.status}`; + } catch { + return `HTTP ${res.status}`; + } +} + +export function registerTokens(program) { + const tokens = program + .command("tokens") + .description(t("tokens.description") || "Manage scoped CLI access tokens (remote mode)"); + + tokens + .command("create") + .description("Create a new access token (requires admin scope)") + .requiredOption("--name ", "Human-readable token name") + .option("--scope ", "Scope: read | write | admin", "read") + .option("--expires ", "Expire after N days (default: never)") + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const body = { name: opts.name, scope: opts.scope }; + if (opts.expires) { + const days = Number(opts.expires); + if (!Number.isFinite(days) || days <= 0) { + printError("--expires must be a positive number of days."); + process.exit(2); + } + body.expiresInDays = days; + } + const res = await apiFetch("/api/cli/tokens", { + ...globalOpts, + method: "POST", + body, + acceptNotOk: true, + }); + if (!res.ok) { + printError(`Could not create token: ${await readErrorMessage(res)}`); + process.exit(res.exitCode || 1); + } + const b = await res.json(); + printSuccess(`Token '${b.name}' created (scope: ${b.scope}).`); + printInfo("Copy it now β€” it will NOT be shown again:"); + process.stdout.write(`${b.token}\n`); + }); + + tokens + .command("list") + .description("List access tokens (masked)") + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const res = await apiFetch("/api/cli/tokens", { ...globalOpts, acceptNotOk: true }); + if (!res.ok) { + printError(`Could not list tokens: ${await readErrorMessage(res)}`); + process.exit(res.exitCode || 1); + } + const b = await res.json(); + const rows = (b.tokens || []).map((tk) => ({ + id: tk.id, + name: tk.name, + scope: tk.scope, + prefix: tk.tokenPrefix, + created: tk.createdAt, + lastUsed: tk.lastUsedAt || "", + expires: tk.expiresAt || "", + status: tk.revokedAt ? "revoked" : "active", + })); + emit(rows, globalOpts, [ + { key: "id", header: "ID" }, + { key: "name", header: "Name" }, + { key: "scope", header: "Scope" }, + { key: "prefix", header: "Prefix" }, + { key: "status", header: "Status" }, + { key: "lastUsed", header: "Last Used" }, + { key: "expires", header: "Expires" }, + ]); + }); + + tokens + .command("revoke ") + .description("Revoke an access token by id or display prefix") + .action(async (idOrPrefix, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const res = await apiFetch(`/api/cli/tokens/${encodeURIComponent(idOrPrefix)}`, { + ...globalOpts, + method: "DELETE", + acceptNotOk: true, + }); + if (!res.ok) { + printError(`Could not revoke token: ${await readErrorMessage(res)}`); + process.exit(res.exitCode || 1); + } + printSuccess(`Revoked ${idOrPrefix}.`); + }); + + tokens + .command("scopes") + .description("Explain the three access-token scopes") + .action(() => { + printInfo("Access-token scopes (admin βŠƒ write βŠƒ read):"); + process.stdout.write(" read list/inspect only (models, status, logs, usage)\n"); + process.stdout.write(" write read + configure/apply (setup-codex, keys add, config set)\n"); + process.stdout.write(" admin write + manage (tokens, providers add, services, policy)\n"); + }); +} diff --git a/bin/cli/contexts.mjs b/bin/cli/contexts.mjs index 625114085f..2a691a1ef9 100644 --- a/bin/cli/contexts.mjs +++ b/bin/cli/contexts.mjs @@ -36,8 +36,25 @@ export function saveContexts(cfg) { } catch {} } +/** + * Resolve the active context for a CLI invocation. + * + * Canonical schema is `{ currentContext, contexts }` (written by + * `omniroute contexts ...`). For backward compatibility we also read the legacy + * `{ activeProfile, profiles }` shape and a bare top-level `baseUrl` β€” older + * configs and `api.mjs::getBaseUrl` used those before remote-mode unified the + * store. `overrideName` (from `--context`/`OMNIROUTE_CONTEXT`) wins when set. + * + * A context may carry `{ baseUrl, accessToken?, apiKey?, scope?, description? }`. + * `accessToken` is the scoped CLI access token (preferred); `apiKey` is the + * legacy inference key kept for back-compat. + */ export function resolveActiveContext(overrideName) { const cfg = loadContexts(); - const name = overrideName || cfg.currentContext || "default"; - return cfg.contexts?.[name] || cfg.contexts?.default || { baseUrl: "http://localhost:20128" }; + const contexts = cfg.contexts || cfg.profiles || {}; + const name = overrideName || cfg.currentContext || cfg.activeProfile || "default"; + const found = contexts[name] || contexts.default; + if (found) return found; + if (cfg.baseUrl) return { baseUrl: cfg.baseUrl }; + return { baseUrl: `http://localhost:${process.env.PORT || "20128"}` }; } diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index bc447aab51..cdecdabafe 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -1262,5 +1262,14 @@ "token": "API key the Claude client should send (ANTHROPIC_AUTH_TOKEN)", "notRunning": "OmniRoute is not running on port {port}. Start it with 'omniroute serve'.", "notFound": "The 'claude' CLI was not found in PATH." + }, + "connect": { + "description": "Connect to a remote OmniRoute server and enter remote mode" + }, + "tokens": { + "description": "Manage scoped CLI access tokens (remote mode)" + }, + "configure": { + "description": "Pick a provider+model from the active server and write a local CLI config" } } diff --git a/docs/guides/REMOTE-MODE.md b/docs/guides/REMOTE-MODE.md new file mode 100644 index 0000000000..4545909649 --- /dev/null +++ b/docs/guides/REMOTE-MODE.md @@ -0,0 +1,173 @@ +--- +title: "Remote Mode β€” Drive a remote OmniRoute from your laptop" +version: 3.8.29 +lastUpdated: 2026-06-19 +--- + +# Remote Mode + +Run the `omniroute` CLI on your laptop while OmniRoute itself runs somewhere else +(a VPS, a home server, another machine on your Tailnet). You log in once with +`omniroute connect`, and from then on **every** CLI command targets that remote +server β€” same commands, same output, just executed against the remote. + +There is no second tool to install: remote mode is the regular `omniroute` CLI +plus scoped **access tokens**. + +```bash +npm install -g omniroute # the normal CLI +omniroute connect 192.168.0.15 # log in (password β†’ scoped token) +omniroute models list # ← now lists the REMOTE server's models +omniroute configure codex # ← writes a local Codex profile from the remote catalog +``` + +--- + +## How it works + +``` +your laptop remote OmniRoute (VPS) +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ omniroute CLI β”‚ POST /api/cli/connect (password β†’ token) β”‚ +β”‚ context: vps β”‚ ───────────────► β”‚ mints a scoped access token β”‚ +β”‚ baseUrl, token β”‚ Authorization: Bearer oma_live_… β”‚ +β”‚ β”‚ ───────────────► β”‚ every management route, scope- β”‚ +β”‚ writes configs β”‚ ◄─────────────── β”‚ checked per the token's scope β”‚ +β”‚ LOCALLY β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +- **Contexts** store one server each (`~/.omniroute/config.json`, `chmod 600`). + `omniroute contexts use ` switches the active server; `default` is local. +- **Access tokens** (`oma_live_…`) authorize management commands. They are + distinct from inference API keys (`sk-…`, used for `/v1/chat/completions`). +- Only the SHA-256 hash of a token is stored server-side. The plaintext is shown + **once**, at creation. + +--- + +## Connecting + +### With the management password (bootstrap) + +```bash +omniroute connect 192.168.0.15 +# Management password for http://192.168.0.15:20128: ******** +# βœ” Connected to http://192.168.0.15:20128 β€” context '192.168.0.15' (scope: admin) +``` + +The password flow mints an **admin** token by default (you hold the password, so +you already have full control). Downscope with `--scope`: + +```bash +omniroute connect 192.168.0.15 --scope write +``` + +Options: `--port

` (when the host has none), `--name ` (context name), +`--scope read|write|admin`. A full URL is honoured as-is: +`omniroute connect https://omni.example.com`. + +### With a pre-generated token + +Generate a scoped token in the dashboard (or with `omniroute tokens create`) and +paste it β€” no password needed: + +```bash +omniroute connect 192.168.0.15 --key oma_live_xxxxxxxx +``` + +The CLI validates it via `GET /api/cli/whoami` and saves it as the active context. + +--- + +## Scopes + +Three levels, hierarchical (`admin βŠƒ write βŠƒ read`): + +| Scope | Can do | +|-------|--------| +| `read` | list/inspect β€” `models list`, `providers status`, `logs`, `usage`, `cost` | +| `write` | read **+** configure/apply β€” `setup-codex`, `keys add`, `config set`, combos | +| `admin` | write **+** manage β€” `tokens` CRUD, add providers, services, policy, oauth | + +The server infers the scope each route requires from the HTTP method +(`GET`β†’read, mutationsβ†’write) plus an admin allowlist for sensitive surfaces +(`/api/cli/tokens`, `/api/providers` mutations, `/api/oauth`, `/api/services`, …). +A token with insufficient scope gets `403` with a clear message. + +> Routes that spawn processes (`/api/services/*`, `/api/mcp/*`, …) stay +> **loopback-only** β€” a remote token can never reach them, regardless of scope. + +--- + +## Managing tokens + +```bash +omniroute tokens create --name "laptop" --scope write [--expires 30] +# ↳ prints the secret ONCE β€” copy it now +omniroute tokens list # masked: id, name, scope, prefix, status, expiry +omniroute tokens revoke # revoke immediately +omniroute tokens scopes # explain the three scopes +``` + +`tokens` commands require an **admin** credential. You can also manage tokens in +the dashboard under **Settings β†’ Access Tokens** (create, revoke, copy-once). + +--- + +## Configuring a coding CLI from the remote catalog + +`omniroute configure` reads the **active server's** live model catalog and writes +a config on **your** machine. + +```bash +omniroute configure codex +# Providers: glm, kmc, ollamacloud, opencode-go, … +# Provider: glm +# Model id: glm/glm-5.2 +# βœ” Wrote ~/.codex/glm52.config.toml +# Use it: codex --profile glm52 + +# non-interactive +omniroute configure codex --provider glm --model glm/glm-5.2 --name glm52 +``` + +The written profile references the inference key by env var +(`OMNIROUTE_API_KEY`) β€” the secret is never written to disk. For the one-time +base Codex setup (the `[model_providers.omniroute]` block), see +[CODEX-CLI-CONFIGURATION.md](./CODEX-CLI-CONFIGURATION.md). + +--- + +## Switching back to local + +```bash +omniroute contexts use default # back to localhost +omniroute context current # show active server, auth, scope +omniroute contexts list # all contexts +``` + +--- + +## Security notes + +- Token plaintext is shown once; only the SHA-256 hash is persisted (same as API keys). +- `omniroute connect` reuses the login brute-force lockout + audit logging. +- Prefer HTTPS or a Tailnet for the transport; a bare host defaults to `http://` + for LAN/Tailscale convenience β€” pass a full `https://…` URL for TLS. +- The local context file is `~/.omniroute/config.json` (`chmod 600`); tokens are + never printed in logs (masked to a prefix). + +--- + +## API endpoints (reference) + +| Method | Route | Auth | Scope | +|--------|-------|------|-------| +| POST | `/api/cli/connect` | management password | β€” (public, password-gated) | +| GET | `/api/cli/whoami` | access token | read | +| GET | `/api/cli/tokens` | access token | admin | +| POST | `/api/cli/tokens` | access token | admin | +| DELETE | `/api/cli/tokens/:id` | access token | admin | + +See [openapi.yaml](../reference/openapi.yaml) for full schemas. diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 94e9af90d5..519c20797a 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -2707,6 +2707,92 @@ paths: "200": description: Translation history entries + # ─── CLI Remote Mode ─────────────────────────────────────────── + + /api/cli/connect: + post: + tags: [CLI Remote Mode] + summary: Exchange the management password for a scoped CLI access token + description: > + Remote-mode bootstrap. Public (password-gated) route: verifies the + management password with brute-force lockout, then mints an `oma_` + access token. The plaintext token is returned once. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password] + properties: + password: { type: string } + name: { type: string } + scope: { type: string, enum: [read, write, admin] } + expiresInDays: { type: integer, minimum: 1, maximum: 3650 } + responses: + "200": + description: Token minted (token returned once) + "401": + description: Invalid password + "429": + description: Too many failed attempts + + /api/cli/whoami: + get: + tags: [CLI Remote Mode] + summary: Report the current credential (scope, name, expiry) + responses: + "200": + description: Authenticated; access-token details when applicable + "401": + description: Authentication required + + /api/cli/tokens: + get: + tags: [CLI Remote Mode] + summary: List access tokens (masked) β€” admin scope + responses: + "200": + description: Masked token list + "403": + description: Insufficient scope + post: + tags: [CLI Remote Mode] + summary: Create a scoped access token β€” admin scope + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string } + scope: { type: string, enum: [read, write, admin] } + expiresInDays: { type: integer, minimum: 1, maximum: 3650 } + responses: + "200": + description: Token created (token returned once) + "403": + description: Insufficient scope + + /api/cli/tokens/{id}: + delete: + tags: [CLI Remote Mode] + summary: Revoke an access token by id or display prefix β€” admin scope + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + "200": + description: Token revoked + "403": + description: Insufficient scope + "404": + description: Token not found or already revoked + # ─── CLI Tools ───────────────────────────────────────────────── /api/cli-tools/backups: diff --git a/scripts/check/check-db-rules.mjs b/scripts/check/check-db-rules.mjs index 43b89c41e0..93a3371f5f 100644 --- a/scripts/check/check-db-rules.mjs +++ b/scripts/check/check-db-rules.mjs @@ -39,6 +39,7 @@ const HANDLERS_DIR = path.join(cwd, "open-sse/handlers"); // sem investigaΓ§Γ£o β€” pode ser reserva de schema ou F2 pendente export const INTENTIONALLY_INTERNAL = new Set([ "_rowTypes", // type-only: 5 importers internos em db/ (AgentBridge/Inspector row types) + "accessTokens", // intentionally-internal: 4 rotas /api/cli/* (connect, whoami, tokens, tokens/[id]) + server/authz/accessTokenAuth.ts via import direto "@/lib/db/accessTokens" (Rule #2) "cleanup", // intentionally-internal: 3 API routes (purge-quota-snapshots, purge-call-logs, purge-detailed-logs) "cliToolState", // intentionally-internal: 14+ API routes em /api/cli-tools/*-settings "comboForecast", // intentionally-internal: src/lib/usage/comboForecast.ts diff --git a/src/app/(dashboard)/dashboard/settings/access-tokens/page.tsx b/src/app/(dashboard)/dashboard/settings/access-tokens/page.tsx new file mode 100644 index 0000000000..1a5c8354d8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/access-tokens/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import AccessTokensTab from "../components/AccessTokensTab"; + +export default function SettingsAccessTokensPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx b/src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx new file mode 100644 index 0000000000..d5eb058a73 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card, Button, Input, Select, Badge, Spinner, ConfirmModal } from "@/shared/components"; +import { useTranslations } from "next-intl"; + +interface AccessTokenRow { + id: string; + name: string; + scope: "read" | "write" | "admin"; + tokenPrefix: string; + createdAt: string; + lastUsedAt: string | null; + expiresAt: string | null; + revokedAt: string | null; +} + +const SCOPE_VARIANT: Record = { + read: "info", + write: "warning", + admin: "error", +}; + +export default function AccessTokensTab() { + const t = useTranslations("settings"); + // Graceful fallback so the tab renders in every locale before keys are translated. + const L = (key: string, fallback: string) => + typeof t.has === "function" && t.has(key) ? t(key) : fallback; + + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const [name, setName] = useState(""); + const [scope, setScope] = useState("read"); + const [expires, setExpires] = useState(""); + const [creating, setCreating] = useState(false); + const [newSecret, setNewSecret] = useState(null); + const [copied, setCopied] = useState(false); + + const [revokeTarget, setRevokeTarget] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(""); + try { + const res = await fetch("/api/cli/tokens"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + setTokens(Array.isArray(data.tokens) ? data.tokens : []); + } catch { + setError(L("accessTokensLoadError", "Could not load access tokens.")); + } finally { + setLoading(false); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + load(); + }, [load]); + + const createToken = async () => { + if (!name.trim()) return; + setCreating(true); + setError(""); + try { + const body: Record = { name: name.trim(), scope }; + const days = Number(expires); + if (expires && Number.isFinite(days) && days > 0) body.expiresInDays = days; + const res = await fetch("/api/cli/tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data?.error?.message || data?.error || `HTTP ${res.status}`); + } + setNewSecret(data.token); + setCopied(false); + setName(""); + setExpires(""); + setScope("read"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : L("accessTokensCreateError", "Could not create token.")); + } finally { + setCreating(false); + } + }; + + const confirmRevoke = async () => { + if (!revokeTarget) return; + const target = revokeTarget; + setRevokeTarget(null); + try { + const res = await fetch(`/api/cli/tokens/${encodeURIComponent(target.id)}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + await load(); + } catch { + setError(L("accessTokensRevokeError", "Could not revoke token.")); + } + }; + + const copySecret = async () => { + if (!newSecret) return; + try { + await navigator.clipboard.writeText(newSecret); + setCopied(true); + } catch { + /* clipboard unavailable β€” user can select manually */ + } + }; + + const fmt = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : "β€”"); + + return ( +

+ +
+

+ {L("accessTokensTitle", "Access Tokens")} +

+

+ {L( + "accessTokensDescription", + "Scoped tokens that let the omniroute CLI manage this server remotely. Distinct from inference API keys. The secret is shown once." + )} +

+
+
+ + {/* Create */} + +
+

+ {L("accessTokensCreateHeading", "Create a token")} +

+
+ setName(e.target.value)} + /> + setExpires(e.target.value)} + /> + +
+ + {newSecret && ( +
+

+ {L("accessTokensCopyNow", "Copy this token now β€” it will not be shown again:")} +

+
+ + {newSecret} + + + +
+
+ )} +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* List */} + +
+

+ {L("accessTokensExisting", "Existing tokens")} +

+ {loading ? ( +
+ +
+ ) : tokens.length === 0 ? ( +

+ {L("accessTokensEmpty", "No access tokens yet.")} +

+ ) : ( +
+ + + + + + + + + + + + + {tokens.map((tk) => { + const revoked = Boolean(tk.revokedAt); + return ( + + + + + + + + + + ); + })} + +
{L("accessTokensColName", "Name")}{L("accessTokensColScope", "Scope")}{L("accessTokensColPrefix", "Prefix")}{L("accessTokensColStatus", "Status")}{L("accessTokensColLastUsed", "Last used")}{L("accessTokensColExpires", "Expires")} +
{tk.name} + {tk.scope} + + {tk.tokenPrefix} + + + {revoked + ? L("accessTokensStatusRevoked", "revoked") + : L("accessTokensStatusActive", "active")} + + {fmt(tk.lastUsedAt)}{fmt(tk.expiresAt)} + {!revoked && ( + + )} +
+
+ )} +
+
+ + setRevokeTarget(null)} + onConfirm={confirmRevoke} + title={L("accessTokensRevokeTitle", "Revoke access token")} + message={L( + "accessTokensRevokeConfirm", + "This immediately invalidates the token. Any machine using it loses access." + )} + confirmText={L("accessTokensRevoke", "Revoke")} + variant="danger" + /> +
+ ); +} diff --git a/src/app/api/cli/connect/route.ts b/src/app/api/cli/connect/route.ts new file mode 100644 index 0000000000..76645a78a2 --- /dev/null +++ b/src/app/api/cli/connect/route.ts @@ -0,0 +1,156 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { getSettings } from "@/lib/localDb"; +import { + ensurePersistentManagementPasswordHash, + getStoredManagementPassword, + verifyManagementPassword, +} from "@/lib/auth/managementPassword"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { checkLoginGuard, clearLoginAttempts, recordLoginFailure } from "@/server/auth/loginGuard"; +import { createAccessToken } from "@/lib/db/accessTokens"; +import { ACCESS_SCOPES } from "@/lib/accessTokens/scopes"; + +/** + * POST /api/cli/connect β€” remote-mode bootstrap. + * + * Exchange the management password for a scoped CLI access token. Public route + * (no token exists yet) that does its OWN password verification + brute-force + * lockout, mirroring /api/auth/login β€” but mints an `oma_` access token instead + * of a dashboard JWT cookie. The plaintext token is returned exactly once. + * + * Default scope is `admin`: the password holder is the owner and can already do + * anything; the first token should be able to mint narrower tokens for other + * machines. Pass `scope` to downscope (e.g. a read-only CI token). + */ + +const connectSchema = z.object({ + password: z.string().min(1), + name: z.string().min(1).max(100).optional(), + scope: z.enum(ACCESS_SCOPES).optional(), + expiresInDays: z.number().int().positive().max(3650).optional(), +}); + +export async function POST(request: Request) { + const auditContext = getAuditRequestContext(request); + + try { + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const validation = validateBody(connectSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const { password, name, scope, expiresInDays } = validation.data; + + const settings = await getSettings(); + const bruteForceEnabled = settings.bruteForceProtection !== false; + const clientIp = auditContext.ipAddress || null; + + const guardCheck = checkLoginGuard(clientIp, { enabled: bruteForceEnabled }); + if (!guardCheck.allowed) { + logAuditEvent({ + action: "cli.connect.locked", + actor: "anonymous", + target: "cli-access-token", + resourceType: "auth_session", + status: "failed", + ipAddress: clientIp || undefined, + requestId: auditContext.requestId, + metadata: { retryAfterSeconds: guardCheck.retryAfterSeconds || 0 }, + }); + return NextResponse.json( + { error: "Too many failed attempts. Try again later." }, + { + status: 429, + headers: guardCheck.retryAfterSeconds + ? { "Retry-After": String(guardCheck.retryAfterSeconds) } + : {}, + } + ); + } + + const passwordState = await ensurePersistentManagementPasswordHash({ + settings, + source: "cli.connect", + }); + const storedHash = getStoredManagementPassword(passwordState.settings); + if (!storedHash) { + return NextResponse.json( + { error: "No password configured. Complete onboarding first.", needsSetup: true }, + { status: 403 } + ); + } + + const isValid = await verifyManagementPassword(password, storedHash); + if (!isValid) { + const failureDecision = recordLoginFailure(clientIp, { enabled: bruteForceEnabled }); + logAuditEvent({ + action: "cli.connect.failed", + actor: "anonymous", + target: "cli-access-token", + resourceType: "auth_session", + status: "failed", + ipAddress: clientIp || undefined, + requestId: auditContext.requestId, + metadata: { reason: "invalid_password", lockedOut: failureDecision.allowed === false }, + }); + if (!failureDecision.allowed) { + return NextResponse.json( + { error: "Too many failed attempts. Try again later." }, + { + status: 429, + headers: failureDecision.retryAfterSeconds + ? { "Retry-After": String(failureDecision.retryAfterSeconds) } + : {}, + } + ); + } + return NextResponse.json({ error: "Invalid password" }, { status: 401 }); + } + + clearLoginAttempts(clientIp); + + const tokenScope = scope ?? "admin"; + const tokenName = (name ?? "remote-cli").trim() || "remote-cli"; + const expiresAt = + typeof expiresInDays === "number" + ? new Date(Date.now() + expiresInDays * 86_400_000).toISOString() + : null; + + const { record, secret } = createAccessToken({ + name: tokenName, + scope: tokenScope, + expiresAt, + }); + + logAuditEvent({ + action: "cli.connect.success", + actor: "admin", + target: "cli-access-token", + resourceType: "auth_session", + status: "success", + ipAddress: clientIp || undefined, + requestId: auditContext.requestId, + metadata: { tokenId: record.id, scope: tokenScope }, + }); + + return NextResponse.json({ + success: true, + token: secret, + id: record.id, + name: record.name, + scope: record.scope, + expiresAt: record.expiresAt, + }); + } catch (error) { + console.error("[CLI] connect failed:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/cli/tokens/[id]/route.ts b/src/app/api/cli/tokens/[id]/route.ts new file mode 100644 index 0000000000..b227a7a4b5 --- /dev/null +++ b/src/app/api/cli/tokens/[id]/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { revokeAccessToken } from "@/lib/db/accessTokens"; + +/** + * DELETE /api/cli/tokens/:id β€” revoke an access token (by id or display prefix). + * Admin-only (same enforcement as the collection route). Idempotent: revoking + * an unknown/already-revoked token returns 404. + */ +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const { id } = await params; + const revoked = revokeAccessToken(id); + if (!revoked) { + return NextResponse.json({ error: "Token not found or already revoked" }, { status: 404 }); + } + return NextResponse.json({ success: true, id }); +} diff --git a/src/app/api/cli/tokens/route.ts b/src/app/api/cli/tokens/route.ts new file mode 100644 index 0000000000..f49740e0c1 --- /dev/null +++ b/src/app/api/cli/tokens/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createAccessToken, listAccessTokens } from "@/lib/db/accessTokens"; +import { ACCESS_SCOPES } from "@/lib/accessTokens/scopes"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; + +/** + * /api/cli/tokens β€” manage scoped CLI access tokens. Admin-only: the path is in + * ADMIN_SCOPE_PREFIXES, so the central pipeline + requireManagementAuth both + * require an `admin` credential (a read/write token gets 403 before here). + */ + +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + return NextResponse.json({ tokens: listAccessTokens() }); +} + +const createSchema = z.object({ + name: z.string().min(1).max(100), + scope: z.enum(ACCESS_SCOPES).optional(), + expiresInDays: z.number().int().positive().max(3650).optional(), +}); + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const validation = validateBody(createSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const { name, scope, expiresInDays } = validation.data; + const expiresAt = + typeof expiresInDays === "number" + ? new Date(Date.now() + expiresInDays * 86_400_000).toISOString() + : null; + + const { record, secret } = createAccessToken({ name, scope: scope ?? "read", expiresAt }); + + // `token` (the plaintext secret) is returned ONCE here and never again. + return NextResponse.json({ + success: true, + token: secret, + id: record.id, + name: record.name, + scope: record.scope, + tokenPrefix: record.tokenPrefix, + createdAt: record.createdAt, + expiresAt: record.expiresAt, + }); +} diff --git a/src/app/api/cli/whoami/route.ts b/src/app/api/cli/whoami/route.ts new file mode 100644 index 0000000000..08bf0615bf --- /dev/null +++ b/src/app/api/cli/whoami/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { extractBearer, ACCESS_TOKEN_PREFIX } from "@/server/authz/accessTokenAuth"; +import { verifyAccessToken, getAccessToken } from "@/lib/db/accessTokens"; + +/** + * GET /api/cli/whoami β€” report the current credential to the CLI. + * + * Requires a valid management credential (read scope is enough β€” it's a GET). + * When the caller used a scoped CLI access token, returns its name/scope/expiry + * so `omniroute connect --key` / `context current` can confirm what they hold. + * Other credentials (dashboard session, manage-scope API key, loopback CLI + * token) report `viaAccessToken: false`. + */ +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const bearer = extractBearer(request); + if (bearer && bearer.startsWith(ACCESS_TOKEN_PREFIX)) { + const verified = verifyAccessToken(bearer); + if (verified) { + const record = getAccessToken(verified.id); + return NextResponse.json({ + authenticated: true, + viaAccessToken: true, + id: verified.id, + name: verified.name, + scope: verified.scope, + createdAt: record?.createdAt ?? null, + lastUsedAt: record?.lastUsedAt ?? null, + expiresAt: record?.expiresAt ?? null, + }); + } + } + + return NextResponse.json({ authenticated: true, viaAccessToken: false, scope: null }); +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 735ed73e0a..637f27851f 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -995,6 +995,7 @@ "settingsAppearance": "Appearance", "settingsAi": "AI Settings", "settingsSecurity": "Security", + "settingsAccessTokens": "Access Tokens", "settingsFeatureFlags": "Feature Flags", "settingsAuthz": "Authz", "settingsRouting": "Routing", @@ -1078,6 +1079,7 @@ "settingsResilienceSubtitle": "Retries and breakers", "settingsAdvancedSubtitle": "Power user options", "settingsSecuritySubtitle": "Auth and encryption", + "settingsAccessTokensSubtitle": "Scoped CLI tokens for remote mode", "settingsFeatureFlagsSubtitle": "Toggle system capabilities", "settingsSidebar": "Sidebar", "settingsSidebarSubtitle": "Customize sidebar layout", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index a117958383..6dc337f3b6 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -6999,6 +6999,7 @@ "settingsAppearance": "Appearance", "settingsAi": "AI Settings", "settingsSecurity": "Security", + "settingsAccessTokens": "Tokens de Acesso", "settingsFeatureFlags": "Sinalizadores de recursos", "settingsAuthz": "AutorizaΓ§Γ£o", "settingsRouting": "Routing", @@ -7077,6 +7078,7 @@ "settingsResilienceSubtitle": "Retries e circuit breakers", "settingsAdvancedSubtitle": "OpΓ§Γ΅es avanΓ§adas", "settingsSecuritySubtitle": "Auth e criptografia", + "settingsAccessTokensSubtitle": "Tokens de CLI com escopo para modo remoto", "settingsFeatureFlagsSubtitle": "Alternar recursos do sistema", "settingsSidebar": "Sidebar", "settingsSidebarSubtitle": "Customize sidebar layout", diff --git a/src/lib/accessTokens/scopes.ts b/src/lib/accessTokens/scopes.ts new file mode 100644 index 0000000000..225ed8f3c9 --- /dev/null +++ b/src/lib/accessTokens/scopes.ts @@ -0,0 +1,51 @@ +/** + * CLI access-token scopes β€” the 3-level hierarchy used by remote mode. + * + * These tokens authorize the `omniroute` CLI (and dashboard) to run *management* + * commands against a (possibly remote) OmniRoute server. They are distinct from + * inference API keys (`api_keys`), which authorize `/v1/chat/completions` traffic. + * + * Hierarchy (admin βŠƒ write βŠƒ read): + * - read : list/inspect only (models list, providers status, logs, usage, cost) + * - write : read + configure/apply (setup-codex, keys add, config set, combo edit) + * - admin : write + sensitive management (tokens create/revoke, providers add, + * services install/start, policy, oauth) + * + * Loopback-only routes that spawn processes (`isLocalOnlyPath`) are NEVER reachable + * by a remote token regardless of scope β€” that enforcement happens before auth. + */ + +export const ACCESS_SCOPES = ["read", "write", "admin"] as const; + +export type AccessScope = (typeof ACCESS_SCOPES)[number]; + +/** Numeric rank for hierarchy comparisons. Higher = more privileged. */ +const SCOPE_RANK: Record = { + read: 1, + write: 2, + admin: 3, +}; + +/** Type guard: is `value` one of the three valid scopes? */ +export function isAccessScope(value: unknown): value is AccessScope { + return typeof value === "string" && (ACCESS_SCOPES as readonly string[]).includes(value); +} + +/** + * True when a token holding `have` is allowed to perform an action that requires + * `need`. Hierarchy is inclusive: an `admin` token satisfies `write` and `read`; + * a `write` token satisfies `read`. Unknown scopes never satisfy anything. + */ +export function scopeSatisfies(have: unknown, need: AccessScope): boolean { + if (!isAccessScope(have)) return false; + return SCOPE_RANK[have] >= SCOPE_RANK[need]; +} + +/** + * Normalize an arbitrary input into a valid scope, falling back to the safest + * default (`read`) when the value is missing or invalid. Used when reading a + * stored/declared scope that must never silently widen privileges. + */ +export function normalizeScope(value: unknown, fallback: AccessScope = "read"): AccessScope { + return isAccessScope(value) ? value : fallback; +} diff --git a/src/lib/api/requireManagementAuth.ts b/src/lib/api/requireManagementAuth.ts index 0970cc76bf..90b4fa7dd5 100644 --- a/src/lib/api/requireManagementAuth.ts +++ b/src/lib/api/requireManagementAuth.ts @@ -3,6 +3,7 @@ import { createErrorResponse } from "@/lib/api/errorResponse"; import { extractApiKey, isValidApiKey } from "@/sse/services/auth"; import { getApiKeyMetadata } from "@/lib/db/apiKeys"; import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth"; +import { evaluateAccessTokenAuth } from "@/server/authz/accessTokenAuth"; import { MANAGE_SCOPE, hasManageScope as hasManageScopeShared, @@ -34,6 +35,37 @@ export async function requireManagementAuth(request: Request): Promise`. The first chars are stored as + * `token_prefix` so tokens can be listed/identified without revealing the secret. + */ + +const TOKEN_RANDOM_BYTES = 32; +const TOKEN_SECRET_PREFIX = "oma_live_"; +/** How many leading chars of the secret are kept for display (prefix). */ +const DISPLAY_PREFIX_LEN = TOKEN_SECRET_PREFIX.length + 6; + +export interface AccessTokenRecord { + id: string; + name: string; + scope: AccessScope; + tokenPrefix: string; + createdAt: string; + lastUsedAt: string | null; + expiresAt: string | null; + revokedAt: string | null; +} + +/** Result of validating a presented token on a request. */ +export interface VerifiedAccessToken { + id: string; + name: string; + scope: AccessScope; +} + +interface AccessTokenRow { + id: string; + token_hash: string; + token_prefix: string; + name: string; + scope: string; + created_at: string; + last_used_at: string | null; + expires_at: string | null; + revoked_at: string | null; +} + +function rowToRecord(row: AccessTokenRow): AccessTokenRecord { + return { + id: row.id, + name: row.name, + scope: normalizeScope(row.scope), + tokenPrefix: row.token_prefix, + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + }; +} + +/** + * Hash a token secret for storage/lookup. + * + * SHA-256 is intentional: these are high-entropy random secrets compared by exact + * hash match for per-request validation, NOT user passwords. bcrypt/scrypt would + * add ~100ms per request for no security gain. Mirrors `api_keys` hashing. + * lgtm[js/insufficient-password-hash] + */ +export function hashAccessToken(secret: string): string { + return createHash("sha256").update(secret).digest("hex"); // nosemgrep: insufficient-password-hash +} + +/** True when an ISO timestamp is in the past (treats invalid dates as not-expired). */ +function isExpired(expiresAt: string | null): boolean { + if (!expiresAt) return false; + const ts = new Date(expiresAt).getTime(); + return Number.isFinite(ts) && ts <= Date.now(); +} + +/** + * Create a new access token. Returns the persisted record plus the plaintext + * secret β€” the ONLY time the secret is available. Caller must show it once and + * never store it server-side. + */ +export function createAccessToken(input: { + name: string; + scope?: AccessScope | string; + expiresAt?: string | null; +}): { record: AccessTokenRecord; secret: string } { + const name = (input.name ?? "").trim(); + if (!name) throw new Error("Access token name is required"); + + const db = getDbInstance(); + const scope = normalizeScope(input.scope, "read"); + const secret = `${TOKEN_SECRET_PREFIX}${randomBytes(TOKEN_RANDOM_BYTES).toString("base64url")}`; + const id = `tok_${randomUUID()}`; + const tokenHash = hashAccessToken(secret); + const tokenPrefix = secret.slice(0, DISPLAY_PREFIX_LEN); + const createdAt = new Date().toISOString(); + const expiresAt = input.expiresAt ?? null; + + db.prepare( + `INSERT INTO cli_access_tokens + (id, token_hash, token_prefix, name, scope, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(id, tokenHash, tokenPrefix, name, scope, createdAt, expiresAt); + + return { + secret, + record: { + id, + name, + scope, + tokenPrefix, + createdAt, + lastUsedAt: null, + expiresAt, + revokedAt: null, + }, + }; +} + +/** + * Validate a presented secret. Returns the token's identity + scope, or null when + * the secret is unknown, revoked, or expired. Touches `last_used_at` on success. + */ +export function verifyAccessToken(secret: string | null | undefined): VerifiedAccessToken | null { + if (!secret || typeof secret !== "string") return null; + const db = getDbInstance(); + const row = db + .prepare("SELECT * FROM cli_access_tokens WHERE token_hash = ?") + .get(hashAccessToken(secret)) as AccessTokenRow | undefined; + if (!row) return null; + if (row.revoked_at) return null; + if (isExpired(row.expires_at)) return null; + + // Best-effort usage stamp; never block validation on the write. + try { + db.prepare("UPDATE cli_access_tokens SET last_used_at = ? WHERE id = ?").run( + new Date().toISOString(), + row.id + ); + } catch { + /* non-fatal */ + } + + return { id: row.id, name: row.name, scope: normalizeScope(row.scope) }; +} + +/** List all tokens (masked β€” never includes the secret or its hash). */ +export function listAccessTokens(): AccessTokenRecord[] { + const db = getDbInstance(); + const rows = db + .prepare("SELECT * FROM cli_access_tokens ORDER BY created_at DESC") + .all() as AccessTokenRow[]; + return rows.map(rowToRecord); +} + +/** Fetch one token's masked record by id, or null. */ +export function getAccessToken(id: string): AccessTokenRecord | null { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM cli_access_tokens WHERE id = ?").get(id) as + | AccessTokenRow + | undefined; + return row ? rowToRecord(row) : null; +} + +/** + * Revoke a token by id or by its display prefix. Idempotent: revoking an + * already-revoked token is a no-op. Returns true when a row was newly revoked. + */ +export function revokeAccessToken(idOrPrefix: string): boolean { + if (!idOrPrefix) return false; + const db = getDbInstance(); + const res = db + .prepare( + `UPDATE cli_access_tokens SET revoked_at = ? + WHERE (id = ? OR token_prefix = ?) AND revoked_at IS NULL` + ) + .run(new Date().toISOString(), idOrPrefix, idOrPrefix); + return res.changes > 0; +} diff --git a/src/lib/db/migrations/100_cli_access_tokens.sql b/src/lib/db/migrations/100_cli_access_tokens.sql new file mode 100644 index 0000000000..3626ee1cb7 --- /dev/null +++ b/src/lib/db/migrations/100_cli_access_tokens.sql @@ -0,0 +1,18 @@ +-- CLI access tokens β€” scoped credentials for remote-mode management commands. +-- Distinct from `api_keys` (inference traffic): these authorize the `omniroute` +-- CLI / dashboard to run management operations against a (possibly remote) server. +-- Only the SHA-256 hash of the secret is stored; the plaintext is shown once at +-- creation. Scope is one of: 'read' | 'write' | 'admin' (admin βŠƒ write βŠƒ read). +CREATE TABLE IF NOT EXISTS cli_access_tokens ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL, + name TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'read', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used_at TEXT, + expires_at TEXT, + revoked_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_cli_access_tokens_hash ON cli_access_tokens(token_hash); diff --git a/src/server/authz/accessScopes.ts b/src/server/authz/accessScopes.ts new file mode 100644 index 0000000000..f5422a746b --- /dev/null +++ b/src/server/authz/accessScopes.ts @@ -0,0 +1,62 @@ +import { type AccessScope } from "@/lib/accessTokens/scopes"; + +/** + * Required-scope inference for remote CLI access tokens. + * + * Policy (owner-confirmed 2026-06-19): infer from HTTP method β€” + * - GET/HEAD/OPTIONS β†’ read + * - POST/PUT/PATCH/DELETE β†’ write + * with two admin overrides: + * - ADMIN_SCOPE_PREFIXES: admin for ANY method (inherently sensitive surfaces). + * - ADMIN_MUTATION_PREFIXES: admin only when mutating; GET/HEAD stay read so a + * `read` token can still inspect status under these prefixes. + * + * This covers every existing management route with zero per-route edits. A NEW + * mutating route nasce exigindo `write` por padrΓ£o (nΓ£o `admin`) β€” quando uma + * rota nova for sensΓ­vel, adicione seu prefixo a uma das listas abaixo. + * + * Note: this only governs the access-token credential path. Dashboard JWT, the + * loopback CLI machine-id token, and manage-scope API keys remain full-access. + * Loopback-only routes that spawn processes are blocked before auth regardless. + */ + +/** Sensitive management surfaces β€” require `admin` for ALL methods. */ +export const ADMIN_SCOPE_PREFIXES: readonly string[] = [ + "/api/cli/tokens", // access-token management (create/list/revoke) + "/api/oauth", // OAuth authorization flows + "/api/auth", // login / logout / session + "/api/policy", // policy engine + "/api/services", // embedded-service lifecycle (also loopback-blocked) + "/api/mcp", // MCP process surface (also loopback-blocked) +]; + +/** Require `admin` only for mutating methods; GET/HEAD under these stay `read`. */ +export const ADMIN_MUTATION_PREFIXES: readonly string[] = [ + "/api/providers", // POST add provider / rotate key = admin; GET status = read + "/api/cli-tools/apply", // writes config onto the host filesystem +]; + +const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +function matchesPrefix(path: string, prefixes: readonly string[]): boolean { + // Exact match or a true path-segment boundary (`pre/...`). A bare + // `startsWith(pre)` would over-match lookalikes (e.g. "/api/auth" vs + // "/api/authz-inventory"), so it is intentionally NOT used. + return prefixes.some((pre) => path === pre || path.startsWith(pre + "/")); +} + +/** + * Determine the minimum access-token scope required to call `method path`. + * Pure + deterministic β€” safe to unit test exhaustively. + */ +export function inferRequiredScope(method: string, path: string): AccessScope { + const m = (method || "GET").toUpperCase(); + const p = path || "/"; + + if (matchesPrefix(p, ADMIN_SCOPE_PREFIXES)) return "admin"; + + const isMutation = !READ_METHODS.has(m); + if (isMutation && matchesPrefix(p, ADMIN_MUTATION_PREFIXES)) return "admin"; + + return isMutation ? "write" : "read"; +} diff --git a/src/server/authz/accessTokenAuth.ts b/src/server/authz/accessTokenAuth.ts new file mode 100644 index 0000000000..cb7b98959f --- /dev/null +++ b/src/server/authz/accessTokenAuth.ts @@ -0,0 +1,67 @@ +import { verifyAccessToken } from "@/lib/db/accessTokens"; +import { scopeSatisfies, type AccessScope } from "@/lib/accessTokens/scopes"; +import { inferRequiredScope } from "@/server/authz/accessScopes"; + +/** + * Shared evaluation of a scoped CLI access token (`oma_...`) for remote mode. + * + * Used by BOTH the central authz pipeline (`managementPolicy` β€” the authoritative + * gate wired through `src/proxy.ts`) and the route-level `requireManagementAuth` + * (defense-in-depth + lets `/api/cli/whoami` learn its own scope). Keeping the + * logic here means the two gates can never drift. + * + * Returns a neutral verdict the caller maps to its own response shape + * (`allow()/reject()` in the policy, `null`/Response in requireManagementAuth). + */ + +/** Prefix that distinguishes a CLI access token from an inference API key. */ +export const ACCESS_TOKEN_PREFIX = "oma_"; + +export type AccessTokenVerdict = + | { kind: "absent" } // no oma_ bearer present β†’ caller continues other auth paths + | { kind: "error" } // auth backend (DB) threw β†’ 503, not an auth failure + | { kind: "invalid" } // oma_ present but unknown/expired/revoked β†’ 401 + | { kind: "insufficient"; have: AccessScope; need: AccessScope } // valid but scope too low β†’ 403 + | { kind: "ok"; scope: AccessScope; id: string; name: string }; // authorized β†’ allow + +/** Read a Bearer token from the Authorization header (header-only; never URL). */ +export function extractBearer(request: Request): string | null { + const header = request.headers.get("authorization") || ""; + const match = /^Bearer\s+(.+)$/i.exec(header.trim()); + return match ? match[1].trim() : null; +} + +function safePathname(url: string): string { + try { + return new URL(url).pathname; + } catch { + return "/"; + } +} + +/** + * Evaluate the request's access-token credential and required scope. + * Pure w.r.t. the request (only side effect is `verifyAccessToken` stamping + * `last_used_at`). Never throws β€” DB failures surface as `{ kind: "error" }`. + */ +export function evaluateAccessTokenAuth(request: Request): AccessTokenVerdict { + const bearer = extractBearer(request); + if (!bearer || !bearer.startsWith(ACCESS_TOKEN_PREFIX)) { + return { kind: "absent" }; + } + + let verified: ReturnType; + try { + verified = verifyAccessToken(bearer); + } catch { + return { kind: "error" }; + } + if (!verified) return { kind: "invalid" }; + + const need = inferRequiredScope(request.method, safePathname(request.url)); + if (!scopeSatisfies(verified.scope, need)) { + return { kind: "insufficient", have: verified.scope, need }; + } + + return { kind: "ok", scope: verified.scope, id: verified.id, name: verified.name }; +} diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index b5dd75caf9..d42ef4bfd8 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -7,6 +7,7 @@ import { allow, reject } from "../context"; import { extractApiKey, isValidApiKey } from "../../../sse/services/auth"; import { getApiKeyMetadata } from "../../../lib/db/apiKeys"; import { hasManageScope } from "../../../lib/api/requireManagementAuth"; +import { evaluateAccessTokenAuth } from "../accessTokenAuth"; import { CLI_TOKEN_HEADER, PEER_IP_HEADER } from "../headers"; import { resolveStampedPeer } from "../peerStamp"; import { @@ -200,6 +201,32 @@ export const managementPolicy: RoutePolicy = { // unhealthy, which is a 503, not a 403 β€” masking it as an auth failure // would tell callers their credentials are wrong when the real problem // is that the server cannot validate any credential right now. + // Scoped CLI access token (remote mode). Evaluated BEFORE the API-key branch + // because `oma_` tokens are management credentials, not inference API keys. + // Shared with `requireManagementAuth` (no drift). Scope enforced per the + // method+admin-allowlist policy (inferRequiredScope). + const accessVerdict = evaluateAccessTokenAuth(ctx.request as unknown as Request); + switch (accessVerdict.kind) { + case "ok": + return allow({ + kind: "management_key", + id: accessVerdict.id, + label: `access-token:${accessVerdict.scope}`, + }); + case "error": + return reject(503, "AUTH_BACKEND_UNAVAILABLE", "Service temporarily unavailable"); + case "invalid": + return reject(401, "AUTH_001", "Invalid or expired access token"); + case "insufficient": + return reject( + 403, + "AUTH_SCOPE", + `Access token scope '${accessVerdict.have}' is insufficient; '${accessVerdict.need}' required.` + ); + case "absent": + break; // no oma_ token β†’ fall through to API-key auth + } + // Management auth is header-only β€” a URL-borne token must not authenticate // a management route. See #3300 follow-up. const apiKey = extractApiKey(ctx.request as unknown as Request, { allowUrl: false }); diff --git a/src/shared/constants/publicApiRoutes.ts b/src/shared/constants/publicApiRoutes.ts index 0099c50241..4319195db0 100644 --- a/src/shared/constants/publicApiRoutes.ts +++ b/src/shared/constants/publicApiRoutes.ts @@ -10,6 +10,10 @@ const PUBLIC_API_ROUTE_PREFIXES = [ // Public, ticket-gated Codex device-flow completion (validate + persist). // The handler enforces its own single-use ticket check; no dashboard auth. "/api/codex/connect/", + // Remote-mode bootstrap: exchange the management password for a scoped CLI + // access token. The handler enforces its own password check + lockout β€” there + // is no token yet at this point, so it cannot require management auth. + "/api/cli/connect", ]; const PUBLIC_READONLY_API_ROUTE_PREFIXES = [ diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index 380de50a4c..47151c7dee 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -92,6 +92,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "settings-resilience", "settings-advanced", "settings-security", + "settings-access-tokens", "settings-feature-flags", "settings-sidebar", // Help @@ -787,6 +788,14 @@ const CONFIGURATION_ITEMS: readonly SidebarItemDefinition[] = [ subtitleKey: "settingsSecuritySubtitle", icon: "shield", }, + { + id: "settings-access-tokens", + href: "/dashboard/settings/access-tokens", + i18nKey: "settingsAccessTokens", + labelFallback: "Access Tokens", + subtitleKey: "settingsAccessTokensSubtitle", + icon: "key", + }, { id: "settings-feature-flags", href: "/dashboard/settings/feature-flags", @@ -999,6 +1008,7 @@ const ADMIN_SHOWN: ReadonlySet = new Set([ "settings-routing", "settings-resilience", "settings-security", + "settings-access-tokens", "settings-feature-flags", "settings-sidebar", "docs", diff --git a/tests/unit/access-scopes-infer.test.ts b/tests/unit/access-scopes-infer.test.ts new file mode 100644 index 0000000000..d9cdc65567 --- /dev/null +++ b/tests/unit/access-scopes-infer.test.ts @@ -0,0 +1,48 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { inferRequiredScope } from "../../src/server/authz/accessScopes.ts"; + +test("read methods default to read", () => { + assert.equal(inferRequiredScope("GET", "/api/v1/models"), "read"); + assert.equal(inferRequiredScope("HEAD", "/api/health"), "read"); + assert.equal(inferRequiredScope("OPTIONS", "/api/anything"), "read"); +}); + +test("mutating methods default to write", () => { + assert.equal(inferRequiredScope("POST", "/api/keys"), "write"); + assert.equal(inferRequiredScope("PUT", "/api/config"), "write"); + assert.equal(inferRequiredScope("PATCH", "/api/combo/x"), "write"); + assert.equal(inferRequiredScope("DELETE", "/api/keys/abc"), "write"); +}); + +test("admin-prefix routes require admin for ANY method", () => { + assert.equal(inferRequiredScope("GET", "/api/cli/tokens"), "admin"); + assert.equal(inferRequiredScope("POST", "/api/cli/tokens"), "admin"); + assert.equal(inferRequiredScope("DELETE", "/api/cli/tokens/tok_1"), "admin"); + assert.equal(inferRequiredScope("GET", "/api/oauth/start"), "admin"); + assert.equal(inferRequiredScope("POST", "/api/auth/login"), "admin"); + assert.equal(inferRequiredScope("POST", "/api/policy"), "admin"); + assert.equal(inferRequiredScope("POST", "/api/services/foo/start"), "admin"); +}); + +test("admin-mutation prefixes: GET stays read, mutations become admin", () => { + // providers: status is read, but creating/rotating is admin + assert.equal(inferRequiredScope("GET", "/api/providers/status"), "read"); + assert.equal(inferRequiredScope("GET", "/api/providers"), "read"); + assert.equal(inferRequiredScope("POST", "/api/providers"), "admin"); + assert.equal(inferRequiredScope("DELETE", "/api/providers/openai"), "admin"); + // cli-tools/apply writes to the host fs + assert.equal(inferRequiredScope("POST", "/api/cli-tools/apply"), "admin"); +}); + +test("a brand-new mutating route is write by default (not admin)", () => { + assert.equal(inferRequiredScope("POST", "/api/some-future-route"), "write"); + assert.equal(inferRequiredScope("GET", "/api/some-future-route"), "read"); +}); + +test("prefix matching does not over-match unrelated paths", () => { + // "/api/authz-inventory" must NOT be caught by the "/api/auth" admin prefix + assert.equal(inferRequiredScope("GET", "/api/authz-inventory"), "read"); + // "/api/services" itself and its children are admin, but a lookalike is not + assert.equal(inferRequiredScope("GET", "/api/services-catalog"), "read"); +}); diff --git a/tests/unit/access-token-scopes.test.ts b/tests/unit/access-token-scopes.test.ts new file mode 100644 index 0000000000..656a905343 --- /dev/null +++ b/tests/unit/access-token-scopes.test.ts @@ -0,0 +1,58 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + ACCESS_SCOPES, + isAccessScope, + scopeSatisfies, + normalizeScope, +} from "../../src/lib/accessTokens/scopes.ts"; + +test("ACCESS_SCOPES are exactly read/write/admin", () => { + assert.deepEqual([...ACCESS_SCOPES], ["read", "write", "admin"]); +}); + +test("isAccessScope accepts valid scopes and rejects anything else", () => { + assert.equal(isAccessScope("read"), true); + assert.equal(isAccessScope("write"), true); + assert.equal(isAccessScope("admin"), true); + assert.equal(isAccessScope("superuser"), false); + assert.equal(isAccessScope(""), false); + assert.equal(isAccessScope(null), false); + assert.equal(isAccessScope(undefined), false); + assert.equal(isAccessScope(3), false); +}); + +test("scopeSatisfies enforces the admin βŠƒ write βŠƒ read hierarchy", () => { + // admin satisfies everything + assert.equal(scopeSatisfies("admin", "read"), true); + assert.equal(scopeSatisfies("admin", "write"), true); + assert.equal(scopeSatisfies("admin", "admin"), true); + // write satisfies read+write, not admin + assert.equal(scopeSatisfies("write", "read"), true); + assert.equal(scopeSatisfies("write", "write"), true); + assert.equal(scopeSatisfies("write", "admin"), false); + // read satisfies only read + assert.equal(scopeSatisfies("read", "read"), true); + assert.equal(scopeSatisfies("read", "write"), false); + assert.equal(scopeSatisfies("read", "admin"), false); +}); + +test("scopeSatisfies returns false for invalid `have` scopes (fail closed)", () => { + assert.equal(scopeSatisfies("bogus", "read"), false); + assert.equal(scopeSatisfies(null, "read"), false); + assert.equal(scopeSatisfies(undefined, "read"), false); + assert.equal(scopeSatisfies("", "read"), false); +}); + +test("normalizeScope falls back to read by default for invalid input", () => { + assert.equal(normalizeScope("write"), "write"); + assert.equal(normalizeScope("admin"), "admin"); + assert.equal(normalizeScope("bogus"), "read"); + assert.equal(normalizeScope(undefined), "read"); + assert.equal(normalizeScope(null), "read"); +}); + +test("normalizeScope honors a custom fallback", () => { + assert.equal(normalizeScope("bogus", "write"), "write"); + assert.equal(normalizeScope(undefined, "admin"), "admin"); +}); diff --git a/tests/unit/access-tokens-db.test.ts b/tests/unit/access-tokens-db.test.ts new file mode 100644 index 0000000000..9593b51613 --- /dev/null +++ b/tests/unit/access-tokens-db.test.ts @@ -0,0 +1,104 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// DB-backed access-token store. Uses an isolated DATA_DIR + closes the handle in +// test.after (CLAUDE.md "Database Handles in Tests" β€” otherwise Node's runner hangs). +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-access-tokens-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../src/lib/db/core.ts"); +const at = await import("../../src/lib/db/accessTokens.ts"); + +test.after(() => { + try { + core.resetDbInstance(); + } catch {} + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } catch {} +}); + +test("createAccessToken returns a secret prefixed oma_live_ and a masked record", () => { + const { record, secret } = at.createAccessToken({ name: "laptop", scope: "write" }); + assert.match(secret, /^oma_live_/); + assert.equal(record.name, "laptop"); + assert.equal(record.scope, "write"); + assert.ok(record.id.startsWith("tok_")); + assert.ok(secret.startsWith(record.tokenPrefix), "prefix must be a prefix of the secret"); + assert.equal(record.revokedAt, null); +}); + +test("createAccessToken defaults to the safest scope (read) for invalid input", () => { + const { record } = at.createAccessToken({ name: "x", scope: "bogus" }); + assert.equal(record.scope, "read"); +}); + +test("createAccessToken rejects an empty name", () => { + assert.throws(() => at.createAccessToken({ name: " ", scope: "read" }), /name is required/); +}); + +test("verifyAccessToken returns identity+scope for a valid secret, null for wrong", () => { + const { secret } = at.createAccessToken({ name: "verify-me", scope: "admin" }); + const v = at.verifyAccessToken(secret); + assert.ok(v); + assert.equal(v?.scope, "admin"); + assert.equal(v?.name, "verify-me"); + assert.equal(at.verifyAccessToken("oma_live_wrong"), null); + assert.equal(at.verifyAccessToken(""), null); + assert.equal(at.verifyAccessToken(null), null); +}); + +test("only the hash is stored β€” the plaintext secret never lands in the DB", () => { + const { secret, record } = at.createAccessToken({ name: "secrecy", scope: "read" }); + const db = core.getDbInstance(); + const row = db + .prepare("SELECT token_hash, token_prefix FROM cli_access_tokens WHERE id = ?") + .get(record.id) as { token_hash: string; token_prefix: string }; + assert.notEqual(row.token_hash, secret, "must store hash, not plaintext"); + assert.equal(row.token_hash, at.hashAccessToken(secret)); + assert.equal(row.token_hash.length, 64, "sha-256 hex"); +}); + +test("verifyAccessToken stamps last_used_at", () => { + const { secret, record } = at.createAccessToken({ name: "touch", scope: "read" }); + assert.equal(at.getAccessToken(record.id)?.lastUsedAt, null); + at.verifyAccessToken(secret); + assert.notEqual(at.getAccessToken(record.id)?.lastUsedAt, null); +}); + +test("revoked tokens fail verification", () => { + const { secret, record } = at.createAccessToken({ name: "to-revoke", scope: "write" }); + assert.ok(at.verifyAccessToken(secret)); + assert.equal(at.revokeAccessToken(record.id), true); + assert.equal(at.verifyAccessToken(secret), null); + // idempotent: revoking again is a no-op + assert.equal(at.revokeAccessToken(record.id), false); +}); + +test("revokeAccessToken works by display prefix too", () => { + const { secret, record } = at.createAccessToken({ name: "by-prefix", scope: "read" }); + assert.equal(at.revokeAccessToken(record.tokenPrefix), true); + assert.equal(at.verifyAccessToken(secret), null); +}); + +test("expired tokens fail verification", () => { + const past = new Date(Date.now() - 60_000).toISOString(); + const { secret } = at.createAccessToken({ name: "expired", scope: "admin", expiresAt: past }); + assert.equal(at.verifyAccessToken(secret), null); +}); + +test("listAccessTokens returns masked records (no secret/hash field)", () => { + at.createAccessToken({ name: "listed", scope: "read" }); + const list = at.listAccessTokens(); + assert.ok(list.length >= 1); + for (const rec of list) { + assert.ok("tokenPrefix" in rec); + assert.ok(!("secret" in rec)); + assert.ok(!("tokenHash" in rec)); + assert.ok(!("token_hash" in rec)); + } +}); diff --git a/tests/unit/cli-connect-helpers.test.ts b/tests/unit/cli-connect-helpers.test.ts new file mode 100644 index 0000000000..628aeb1f4f --- /dev/null +++ b/tests/unit/cli-connect-helpers.test.ts @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { normalizeBaseUrl, hostLabel } from "../../bin/cli/commands/connect.mjs"; +import { profileNameFromModel } from "../../bin/cli/commands/configure.mjs"; + +test("normalizeBaseUrl: bare host gets http:// and the default port", () => { + assert.equal(normalizeBaseUrl("192.168.0.15", "20128"), "http://192.168.0.15:20128"); +}); + +test("normalizeBaseUrl: host with explicit port keeps it", () => { + assert.equal(normalizeBaseUrl("192.168.0.15:9000", "20128"), "http://192.168.0.15:9000"); +}); + +test("normalizeBaseUrl: full https URL is preserved as origin", () => { + assert.equal(normalizeBaseUrl("https://omni.example.com", "20128"), "https://omni.example.com"); + assert.equal(normalizeBaseUrl("http://host:1234/path", "20128"), "http://host:1234"); +}); + +test("normalizeBaseUrl: empty input returns empty string", () => { + assert.equal(normalizeBaseUrl("", "20128"), ""); +}); + +test("hostLabel strips scheme and port", () => { + assert.equal(hostLabel("https://omni.example.com:20128"), "omni.example.com"); + assert.equal(hostLabel("192.168.0.15:20128"), "192.168.0.15"); + assert.equal(hostLabel("http://10.0.0.1"), "10.0.0.1"); +}); + +test("profileNameFromModel strips the provider prefix and non-alphanumerics", () => { + assert.equal(profileNameFromModel("glm/glm-5.2"), "glm52"); + assert.equal(profileNameFromModel("kmc/kimi-k2.7"), "kimik27"); + assert.equal(profileNameFromModel("ollamacloud/gpt-oss:20b"), "gptoss20b"); + assert.equal(profileNameFromModel("cx/gpt-5.5"), "gpt55"); + assert.equal(profileNameFromModel("bare-model"), "baremodel"); +}); diff --git a/tests/unit/cli-remote-mode.test.ts b/tests/unit/cli-remote-mode.test.ts new file mode 100644 index 0000000000..e368fc7d69 --- /dev/null +++ b/tests/unit/cli-remote-mode.test.ts @@ -0,0 +1,178 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// Remote-mode core: the CLI must resolve BOTH baseUrl and auth from the active +// context (canonical `contexts`/`currentContext` schema, with legacy +// `profiles`/`activeProfile` fallback). Before this work, getBaseUrl read only +// the legacy `profiles` schema and buildHeaders never read the context's +// credential at all β€” so `omniroute contexts use ` silently failed to +// route auth to the remote server. + +let tmpDir: string; +let origDataDir: string | undefined; +let origBaseUrl: string | undefined; +let origApiKey: string | undefined; +let origContext: string | undefined; + +function writeConfig(cfg: unknown): void { + mkdirSync(tmpDir, { recursive: true }); + writeFileSync(join(tmpDir, "config.json"), JSON.stringify(cfg, null, 2)); +} + +test.before(() => { + tmpDir = mkdtempSync(join(tmpdir(), "omniroute-remote-test-")); + origDataDir = process.env.DATA_DIR; + origBaseUrl = process.env.OMNIROUTE_BASE_URL; + origApiKey = process.env.OMNIROUTE_API_KEY; + origContext = process.env.OMNIROUTE_CONTEXT; + process.env.DATA_DIR = tmpDir; + delete process.env.OMNIROUTE_BASE_URL; + delete process.env.OMNIROUTE_API_KEY; + delete process.env.OMNIROUTE_CONTEXT; +}); + +test.after(() => { + if (origDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = origDataDir; + if (origBaseUrl === undefined) delete process.env.OMNIROUTE_BASE_URL; + else process.env.OMNIROUTE_BASE_URL = origBaseUrl; + if (origApiKey === undefined) delete process.env.OMNIROUTE_API_KEY; + else process.env.OMNIROUTE_API_KEY = origApiKey; + if (origContext === undefined) delete process.env.OMNIROUTE_CONTEXT; + else process.env.OMNIROUTE_CONTEXT = origContext; + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch {} +}); + +// ── getBaseUrl ──────────────────────────────────────────────────────────────── + +test("getBaseUrl reads baseUrl from the active context (canonical schema)", async () => { + writeConfig({ + version: 1, + currentContext: "vps", + contexts: { + default: { baseUrl: "http://localhost:20128", apiKey: null }, + vps: { baseUrl: "https://vps.example.com:20128", accessToken: "oma_live_x", scope: "write" }, + }, + }); + const { getBaseUrl } = await import("../../bin/cli/api.mjs"); + assert.equal(getBaseUrl(), "https://vps.example.com:20128"); +}); + +test("getBaseUrl honors the --context override", async () => { + writeConfig({ + version: 1, + currentContext: "default", + contexts: { + default: { baseUrl: "http://localhost:20128", apiKey: null }, + staging: { baseUrl: "http://staging:20128", apiKey: null }, + }, + }); + const { getBaseUrl } = await import("../../bin/cli/api.mjs"); + assert.equal(getBaseUrl({ context: "staging" }), "http://staging:20128"); +}); + +test("getBaseUrl is backward-compatible with the legacy profiles schema", async () => { + writeConfig({ + version: 1, + activeProfile: "old", + profiles: { old: { baseUrl: "http://legacy:20128" } }, + }); + const { getBaseUrl } = await import("../../bin/cli/api.mjs"); + assert.equal(getBaseUrl(), "http://legacy:20128"); +}); + +test("getBaseUrl: opts.baseUrl wins over the active context", async () => { + writeConfig({ + version: 1, + currentContext: "vps", + contexts: { vps: { baseUrl: "https://vps.example.com" } }, + }); + const { getBaseUrl } = await import("../../bin/cli/api.mjs"); + assert.equal(getBaseUrl({ baseUrl: "http://override:1234" }), "http://override:1234"); +}); + +// ── buildHeaders (auth resolution) ───────────────────────────────────────────── + +test("buildHeaders injects Bearer from the active context accessToken", async () => { + writeConfig({ + version: 1, + currentContext: "vps", + contexts: { vps: { baseUrl: "https://vps.example.com", accessToken: "oma_live_secret" } }, + }); + const { buildHeaders } = await import("../../bin/cli/api.mjs"); + const headers = await buildHeaders({ cliToken: "" }); + assert.equal(headers.get("authorization"), "Bearer oma_live_secret"); +}); + +test("buildHeaders prefers accessToken over apiKey in the same context", async () => { + writeConfig({ + version: 1, + currentContext: "vps", + contexts: { + vps: { baseUrl: "https://vps.example.com", accessToken: "oma_token", apiKey: "sk-legacy" }, + }, + }); + const { buildHeaders } = await import("../../bin/cli/api.mjs"); + const headers = await buildHeaders({ cliToken: "" }); + assert.equal(headers.get("authorization"), "Bearer oma_token"); +}); + +test("buildHeaders falls back to the context apiKey when no accessToken", async () => { + writeConfig({ + version: 1, + currentContext: "vps", + contexts: { vps: { baseUrl: "https://vps.example.com", apiKey: "sk-ctx" } }, + }); + const { buildHeaders } = await import("../../bin/cli/api.mjs"); + const headers = await buildHeaders({ cliToken: "" }); + assert.equal(headers.get("authorization"), "Bearer sk-ctx"); +}); + +test("buildHeaders: explicit opts.apiKey wins over the context credential", async () => { + writeConfig({ + version: 1, + currentContext: "vps", + contexts: { vps: { baseUrl: "https://vps.example.com", accessToken: "oma_token" } }, + }); + const { buildHeaders } = await import("../../bin/cli/api.mjs"); + const headers = await buildHeaders({ cliToken: "", apiKey: "sk-explicit" }); + assert.equal(headers.get("authorization"), "Bearer sk-explicit"); +}); + +// ── context current command ───────────────────────────────────────────────────── + +test("commands/contexts.mjs registers a `current` subcommand", async () => { + const { registerContexts } = await import("../../bin/cli/commands/contexts.mjs"); + // Minimal fake commander program to capture subcommand registration. + const sub: string[] = []; + const fakeCtx: any = { + command(name: string) { + sub.push(name.split(" ")[0]); + return this; + }, + description() { + return this; + }, + requiredOption() { + return this; + }, + option() { + return this; + }, + action() { + return this; + }, + }; + const fakeProgram: any = { + command() { + return fakeCtx; + }, + }; + registerContexts(fakeProgram); + assert.ok(sub.includes("current"), `expected a 'current' subcommand, got: ${sub.join(", ")}`); +}); diff --git a/tests/unit/require-management-auth-access-token.test.ts b/tests/unit/require-management-auth-access-token.test.ts new file mode 100644 index 0000000000..985e0d39c1 --- /dev/null +++ b/tests/unit/require-management-auth-access-token.test.ts @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Integration: the management auth gate must accept scoped CLI access tokens +// (`oma_...`) and enforce the method+admin-allowlist scope policy. Other +// credential paths (dashboard JWT, loopback CLI token, manage-scope API key) +// are unaffected. Isolated DATA_DIR + DB handle closed in test.after. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-mgmt-auth-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +// Force isAuthRequired() === true deterministically (config'd password present). +process.env.INITIAL_PASSWORD = "test-pass"; + +const core = await import("../../src/lib/db/core.ts"); +const at = await import("../../src/lib/db/accessTokens.ts"); +const { requireManagementAuth } = await import("../../src/lib/api/requireManagementAuth.ts"); + +const BASE = "http://localhost:20128"; + +function req(method: string, pathname: string, token?: string): Request { + const headers: Record = {}; + if (token) headers.authorization = `Bearer ${token}`; + return new Request(`${BASE}${pathname}`, { method, headers }); +} + +test.after(() => { + try { + core.resetDbInstance(); + } catch {} + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } catch {} + delete process.env.INITIAL_PASSWORD; +}); + +test("read token: allowed on GET, rejected (403) on a write route", async () => { + const { secret } = at.createAccessToken({ name: "read-tok", scope: "read" }); + assert.equal(await requireManagementAuth(req("GET", "/api/v1/models", secret)), null); + + const denied = await requireManagementAuth(req("POST", "/api/keys", secret)); + assert.ok(denied, "expected a rejection Response"); + assert.equal(denied?.status, 403); +}); + +test("write token: allowed on write route, rejected (403) on admin route", async () => { + const { secret } = at.createAccessToken({ name: "write-tok", scope: "write" }); + assert.equal(await requireManagementAuth(req("POST", "/api/keys", secret)), null); + assert.equal(await requireManagementAuth(req("GET", "/api/v1/models", secret)), null); + + const denied = await requireManagementAuth(req("POST", "/api/cli/tokens", secret)); + assert.equal(denied?.status, 403); +}); + +test("admin token: allowed on admin route", async () => { + const { secret } = at.createAccessToken({ name: "admin-tok", scope: "admin" }); + assert.equal(await requireManagementAuth(req("POST", "/api/cli/tokens", secret)), null); + assert.equal(await requireManagementAuth(req("POST", "/api/providers", secret)), null); +}); + +test("invalid/expired access token is rejected with 401", async () => { + const bad = await requireManagementAuth(req("GET", "/api/v1/models", "oma_live_not_a_real_token")); + assert.equal(bad?.status, 401); + + const past = new Date(Date.now() - 60_000).toISOString(); + const { secret } = at.createAccessToken({ name: "exp", scope: "admin", expiresAt: past }); + const expired = await requireManagementAuth(req("GET", "/api/v1/models", secret)); + assert.equal(expired?.status, 401); +}); + +test("revoked access token is rejected", async () => { + const { secret, record } = at.createAccessToken({ name: "rev", scope: "admin" }); + assert.equal(await requireManagementAuth(req("GET", "/api/v1/models", secret)), null); + at.revokeAccessToken(record.id); + const denied = await requireManagementAuth(req("GET", "/api/v1/models", secret)); + assert.equal(denied?.status, 401); +}); + +test("no credential at all β†’ 401 (auth still required)", async () => { + const denied = await requireManagementAuth(req("GET", "/api/v1/models")); + assert.equal(denied?.status, 401); +});