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)}
+ />
+
+
+ {newSecret && (
+
+
+ {L("accessTokensCopyNow", "Copy this token now β it will not be shown again:")}
+