feat(cli): relay-like CLI closure — target manifest, Codex TOML, Gemini launcher, guards

- canonical executable manifest (bin/cli/cli-manifest.mjs): run/configure/completion
  derive targets, aliases and --model wiring from one table; drift test cross-checks
  manifest x cliRuntime x UI catalog (tests/unit/cli/cli-manifest-drift.test.ts)
- dashboard Codex generator converged to ~/.codex/config.toml (modern Codex v0.137+,
  verified against codex-cli 0.147.0): conservative merge, env_key auth (key never
  written), refuses invalid TOML, reports legacy config.yaml as migration note
- omniroute run gemini: launcher over OmniRoute's /v1beta surface via
  GOOGLE_GEMINI_BASE_URL + isolated GEMINI_CLI_HOME forcing gemini-api-key auth
  (contract proven against @google/gemini-cli 0.50.0); ACP registration kept distinct
- opt-in real smoke harness for upstream CLIs (RUN_CLI_SMOKE=1, credential by env
  NAME, redacted output): tests/integration/upstream-cli-smoke.int.test.ts
- container-guard homologation for POST /api/cli-tools/apply (422 in container,
  dry-run preview allowed, host write passes) + docs; guard untouched
- typecheck: omniglyphAdapter union narrowing, usageTracking typed signatures
  (UsageLike, no any), models.ts isValidModel params — typecheck:core and
  typecheck:noimplicit:core now clean
- relay core (prior session of this effort): omniroute run for 6 CLIs, configure
  picker with per-context favorites/recents, contexts with optional keychain +
  0600 fallback, provider CRUD with recursive redaction, completion updates, docs
This commit is contained in:
Xiangzhe
2026-08-18 08:24:05 -03:00
parent 34bb018d21
commit 0a74bfbdea
50 changed files with 3499 additions and 284 deletions

View File

@@ -800,6 +800,13 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Used by: bin/cli/program.mjs, bin/cli/api.mjs (remote mode). # Used by: bin/cli/program.mjs, bin/cli/api.mjs (remote mode).
# OMNIROUTE_CONTEXT= # OMNIROUTE_CONTEXT=
# Disable the optional OS keychain backend for CLI remote-context credentials.
# When enabled, context tokens stay in config.json with mode 0600 and the CLI
# prints a one-time fallback warning. Useful for deliberate headless/container
# operation; leave unset to use keytar when the native backend is available.
# Used by: bin/cli/contexts.mjs.
# OMNIROUTE_CONTEXT_KEYCHAIN_DISABLED=0
# Enforce scope-based access control on MCP tool calls. # Enforce scope-based access control on MCP tool calls.
# Used by: open-sse/mcp-server/server.ts — rejects calls outside allowed scopes. # Used by: open-sse/mcp-server/server.ts — rejects calls outside allowed scopes.
# OMNIROUTE_MCP_ENFORCE_SCOPES=false # OMNIROUTE_MCP_ENFORCE_SCOPES=false

3
.gitignore vendored
View File

@@ -288,3 +288,6 @@ docker-compose.yml.bak
# CLI local cache/state # CLI local cache/state
.playwright-cli .playwright-cli
# Ad-hoc test sandboxes (never tracked — may contain local DBs)
/.sandbox/

View File

@@ -1,6 +1,6 @@
import { setTimeout as sleep } from "node:timers/promises"; import { setTimeout as sleep } from "node:timers/promises";
import { getCliToken, CLI_TOKEN_HEADER } from "./utils/cliToken.mjs"; import { getCliToken, CLI_TOKEN_HEADER } from "./utils/cliToken.mjs";
import { resolveActiveContext } from "./contexts.mjs"; import { resolveActiveContext, resolveActiveContextAsync } from "./contexts.mjs";
export const RETRY_DEFAULTS = Object.freeze({ export const RETRY_DEFAULTS = Object.freeze({
maxAttempts: 3, maxAttempts: 3,
@@ -77,7 +77,7 @@ export async function buildHeaders(opts) {
let auth = explicitKey; let auth = explicitKey;
if (!auth) { if (!auth) {
try { try {
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); const ctx = await resolveActiveContextAsync(opts.context ?? process.env.OMNIROUTE_CONTEXT);
auth = ctx?.accessToken || ctx?.apiKey || null; auth = ctx?.accessToken || ctx?.apiKey || null;
} catch { } catch {
// No context credential available — fall through to the ambient fallback. // No context credential available — fall through to the ambient fallback.

138
bin/cli/cli-manifest.mjs Normal file
View File

@@ -0,0 +1,138 @@
/**
* Canonical executable manifest for the OmniRoute CLI command surfaces.
*
* One entry per canonical target id. `run.mjs`, `configure.mjs` and
* `completion.mjs` derive their target lists, alias resolution and model-flag
* wiring from this table instead of keeping private copies, so a new target
* (or a renamed alias) is declared exactly once.
*
* The server-side runtime catalog (`src/shared/services/cliRuntime.ts`) stays
* the source of truth for binaries, config paths and health checks; the drift
* test `tests/unit/cli/cli-manifest-drift.test.ts` asserts the two worlds and
* every consumer surface stay in sync.
*
* Capability semantics:
* - `run`: launchable through `omniroute run <target>`.
* - `configure`: supported by the `omniroute configure <target>` picker.
* - `runModel`: how `run` injects `--model` for the target (`null` when the
* model travels via env/provider args instead of a CLI flag).
*/
export const CLI_TARGET_MANIFEST = Object.freeze({
claude: Object.freeze({
description: "Claude Code",
aliases: Object.freeze(["claude-code", "cc", "anthropic"]),
run: true,
configure: true,
runModel: null, // injected via ANTHROPIC_MODEL env by the launcher
}),
codex: Object.freeze({
description: "OpenAI Codex CLI",
aliases: Object.freeze(["codex-cli", "openai-codex", "openai"]),
run: true,
configure: true,
runModel: null, // injected via -c model_providers.omniroute.* args
}),
aider: Object.freeze({
description: "Aider",
aliases: Object.freeze([]),
run: true,
configure: true,
runModel: Object.freeze({ flag: "--model", prefix: "openai/" }),
}),
goose: Object.freeze({
description: "Goose",
aliases: Object.freeze(["goose-cli"]),
run: true,
configure: true,
runModel: null, // injected via GOOSE_MODEL env
}),
opencode: Object.freeze({
description: "OpenCode",
aliases: Object.freeze(["open-code"]),
run: true,
configure: true,
runModel: Object.freeze({ flag: "--model", prefix: "omniroute/" }),
}),
qwen: Object.freeze({
description: "Qwen Code",
aliases: Object.freeze(["qwen-code"]),
run: true,
configure: true,
runModel: Object.freeze({ flag: "--model", prefix: "", required: true }),
}),
gemini: Object.freeze({
// Launch contract verified against @google/gemini-cli 0.50.0:
// GOOGLE_GEMINI_BASE_URL points the SDK at OmniRoute's /v1beta surface,
// GEMINI_API_KEY + isolated GEMINI_CLI_HOME (settings selectedType
// "gemini-api-key") force API-key auth over any stored OAuth session.
description: "Google Gemini CLI",
aliases: Object.freeze(["gemini-cli"]),
run: true,
configure: false,
runModel: Object.freeze({ flag: "--model", prefix: "" }),
}),
cline: Object.freeze({
description: "Cline",
aliases: Object.freeze([]),
run: false,
configure: true,
runModel: null,
}),
continue: Object.freeze({
description: "Continue",
aliases: Object.freeze(["cn"]),
run: false,
configure: true,
runModel: null,
}),
kilo: Object.freeze({
description: "Kilo Code",
aliases: Object.freeze(["kilocode", "kilo-code", "kilo_cli"]),
run: false,
configure: true,
runModel: null,
}),
});
/**
* List canonical target ids, optionally filtered by capability
* (`"run"` or `"configure"`). Order follows manifest declaration order.
*/
export function listManifestTargets(capability) {
return Object.entries(CLI_TARGET_MANIFEST)
.filter(([, entry]) => !capability || entry[capability])
.map(([id]) => id);
}
/**
* Resolve a user-supplied target (canonical id or alias) to its canonical id.
* Returns `undefined` when the target is unknown or lacks the capability.
*/
export function resolveManifestTarget(rawTarget, capability) {
const normalized = String(rawTarget || "")
.trim()
.toLowerCase();
if (!normalized) return undefined;
for (const [id, entry] of Object.entries(CLI_TARGET_MANIFEST)) {
if (id === normalized || entry.aliases.includes(normalized)) {
if (capability && !entry[capability]) return undefined;
return id;
}
}
return undefined;
}
/** Model CLI-flag arguments for a `run` target, derived from the manifest. */
export function manifestModelArgs(targetId, model) {
if (!model) return [];
const spec = CLI_TARGET_MANIFEST[targetId]?.runModel;
if (!spec) return [];
const value = spec.prefix && !model.startsWith(spec.prefix) ? `${spec.prefix}${model}` : model;
return [spec.flag, value];
}
/** Whether a `run` target refuses to launch without an explicit model. */
export function manifestRequiresModel(targetId) {
return Boolean(CLI_TARGET_MANIFEST[targetId]?.runModel?.required);
}

View File

@@ -4,6 +4,12 @@ import { homedir } from "node:os";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
import { apiFetch } from "../api.mjs"; import { apiFetch } from "../api.mjs";
import { resolveDataDir } from "../data-dir.mjs"; import { resolveDataDir } from "../data-dir.mjs";
import { listManifestTargets } from "../cli-manifest.mjs";
// Target lists shared with `omniroute run` / `omniroute configure` — always
// derived from the canonical manifest so the completion scripts cannot drift.
const RUN_TARGET_WORDS = listManifestTargets("run").join(" ");
const CONFIGURE_TARGET_WORDS = listManifestTargets("configure").join(" ");
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h const CACHE_TTL_MS = 60 * 60 * 1000; // 1h
@@ -129,6 +135,14 @@ _omniroute() {
'completion:Shell completion' 'completion:Shell completion'
'memory:Manage memory store' 'memory:Manage memory store'
'skills:Manage skills' 'skills:Manage skills'
'connect:Connect to a local or remote OmniRoute server'
'contexts:Manage local and remote server contexts'
'configure:Configure a supported AI CLI'
'launch:Launch an AI CLI through OmniRoute'
'launch-codex:Launch Codex through OmniRoute'
'run:Run a supported AI CLI through OmniRoute'
'runtime:Inspect CLI runtime capabilities'
'repair:Repair native runtime dependencies'
) )
_arguments -C \\ _arguments -C \\
@@ -153,7 +167,7 @@ _omniroute() {
local -a providers local -a providers
providers=($(_omniroute_get_cache providers)) providers=($(_omniroute_get_cache providers))
_describe 'provider' providers ;; _describe 'provider' providers ;;
*) _arguments '1:subcommand:(list add remove test)' ;; *) _arguments '1:subcommand:(available list test test-all validate rotate status add import auth remove edit metrics metric)' ;;
esac ;; esac ;;
chat|stream) chat|stream)
_arguments \\ _arguments \\
@@ -165,6 +179,12 @@ _omniroute() {
_arguments '1:resource:(combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience)' ;; _arguments '1:resource:(combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience)' ;;
completion) _arguments '1:subcommand:(zsh bash fish install refresh)' ;; completion) _arguments '1:subcommand:(zsh bash fish install refresh)' ;;
config) _arguments '1:subcommand:(list get set validate contexts)' ;; config) _arguments '1:subcommand:(list get set validate contexts)' ;;
contexts) _arguments '1:subcommand:(list add use current show remove rename export import migrate)' ;;
configure) _arguments '1:target:(${CONFIGURE_TARGET_WORDS})' ;;
run) _arguments '1:target:(${RUN_TARGET_WORDS})' ;;
connect) _arguments '1:host:' ;;
launch|launch-codex) _arguments '--remote[Use a remote server]' '--context[Context name]:' '--model[Model ID]:' ;;
runtime) _arguments '1:subcommand:(check repair clean)' ;;
*) ;; *) ;;
esac esac
case $state in case $state in
@@ -208,15 +228,19 @@ _omniroute() {
COMPREPLY=() COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}" cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}" prev="\${COMP_WORDS[COMP_CWORD-1]}"
cmds="setup doctor status logs providers config test update serve stop restart keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills run" cmds="setup doctor status logs providers config test update serve stop restart keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills connect contexts configure launch launch-codex run runtime repair"
case "\${prev}" in case "\${prev}" in
combo) COMPREPLY=($(compgen -W "list switch create delete show suggest" -- "\${cur}")); return 0 ;; combo) COMPREPLY=($(compgen -W "list switch create delete show suggest" -- "\${cur}")); return 0 ;;
keys) COMPREPLY=($(compgen -W "add list remove regenerate revoke reveal usage" -- "\${cur}")); return 0 ;; keys) COMPREPLY=($(compgen -W "add list remove regenerate revoke reveal usage" -- "\${cur}")); return 0 ;;
providers) COMPREPLY=($(compgen -W "available list test test-all" -- "\${cur}")); return 0 ;; providers) COMPREPLY=($(compgen -W "available list test test-all validate rotate status add import auth remove edit metrics metric" -- "\${cur}")); return 0 ;;
config) COMPREPLY=($(compgen -W "list get set validate contexts" -- "\${cur}")); return 0 ;; config) COMPREPLY=($(compgen -W "list get set validate contexts" -- "\${cur}")); return 0 ;;
completion) COMPREPLY=($(compgen -W "zsh bash fish install refresh" -- "\${cur}")); return 0 ;; completion) COMPREPLY=($(compgen -W "zsh bash fish install refresh" -- "\${cur}")); return 0 ;;
open) COMPREPLY=($(compgen -W "combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience" -- "\${cur}")); return 0 ;; open) COMPREPLY=($(compgen -W "combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience" -- "\${cur}")); return 0 ;;
contexts) COMPREPLY=($(compgen -W "list add use current show remove rename export import migrate" -- "\${cur}")); return 0 ;;
configure) COMPREPLY=($(compgen -W "${CONFIGURE_TARGET_WORDS}" -- "\${cur}")); return 0 ;;
run) COMPREPLY=($(compgen -W "${RUN_TARGET_WORDS}" -- "\${cur}")); return 0 ;;
runtime) COMPREPLY=($(compgen -W "check repair clean" -- "\${cur}")); return 0 ;;
--model) --model)
local models local models
models=$(_omniroute_get_cache models) models=$(_omniroute_get_cache models)
@@ -242,7 +266,7 @@ function generateFishScript() {
return `# OmniRoute CLI fish completion (dynamic) return `# OmniRoute CLI fish completion (dynamic)
complete -c omniroute -f complete -c omniroute -f
set -l commands serve stop restart setup doctor status logs providers config keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills update test run set -l commands serve stop restart setup doctor status logs providers config keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills connect contexts configure launch launch-codex update test run runtime repair
for cmd in $commands for cmd in $commands
complete -c omniroute -n '__fish_is_nth_token 1' -a $cmd complete -c omniroute -n '__fish_is_nth_token 1' -a $cmd
@@ -251,10 +275,14 @@ end
# Subcommands # Subcommands
complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'list switch create delete show suggest' complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'list switch create delete show suggest'
complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'add list remove regenerate revoke reveal usage' complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'add list remove regenerate revoke reveal usage'
complete -c omniroute -n '__fish_seen_subcommand_from providers' -a 'available list test test-all' complete -c omniroute -n '__fish_seen_subcommand_from providers' -a 'available list test test-all validate rotate status add import auth remove edit metrics metric'
complete -c omniroute -n '__fish_seen_subcommand_from config' -a 'list get set validate contexts' complete -c omniroute -n '__fish_seen_subcommand_from config' -a 'list get set validate contexts'
complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'zsh bash fish install refresh' complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'zsh bash fish install refresh'
complete -c omniroute -n '__fish_seen_subcommand_from open' -a 'combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience' complete -c omniroute -n '__fish_seen_subcommand_from open' -a 'combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience'
complete -c omniroute -n '__fish_seen_subcommand_from contexts' -a 'list add use current show remove rename export import migrate'
complete -c omniroute -n '__fish_seen_subcommand_from configure' -a '${CONFIGURE_TARGET_WORDS}'
complete -c omniroute -n '__fish_seen_subcommand_from run' -a '${RUN_TARGET_WORDS}'
complete -c omniroute -n '__fish_seen_subcommand_from runtime' -a 'check repair clean'
# Dynamic completions from cache (requires python3) # Dynamic completions from cache (requires python3)
function __omniroute_cache_get function __omniroute_cache_get

View File

@@ -2,9 +2,17 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs"; import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs";
import { apiFetch } from "../api.mjs"; import { apiFetch } from "../api.mjs";
import { loadContexts, resolveActiveContext } from "../contexts.mjs";
import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs"; import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
import {
getModelPreferenceState,
loadModelPreferences,
rankPreferredModels,
recordModelPreference,
} from "../model-preferences.mjs";
import { listManifestTargets, resolveManifestTarget } from "../cli-manifest.mjs";
/** /**
* `omniroute configure <cli>` — interactive provider+model picker that writes a * `omniroute configure <cli>` — interactive provider+model picker that writes a
@@ -14,11 +22,80 @@ import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
* are in remote mode (`omniroute connect ...`) you pick from the remote server's * are in remote mode (`omniroute connect ...`) you pick from the remote server's
* live models and the profile is written on THIS machine. * live models and the profile is written on THIS machine.
* *
* v1 targets the Codex CLI (writes ~/.codex/<name>.config.toml). The credential * Codex keeps its profile-specific TOML files. Other targets delegate to their
* is referenced by env var (OMNIROUTE_API_KEY) — never written to disk. * existing setup-* recipe after the same provider/model selection, so the
* picker remains a read-only orchestration layer and does not duplicate config
* merge logic.
*/ */
const SUPPORTED = ["codex"]; const SUPPORTED = listManifestTargets("configure");
export const SETUP_MODULES = {
claude: { module: "./setup-claude.mjs", exportName: "runSetupClaudeCommand" },
opencode: { module: "./setup-opencode.mjs", exportName: "runSetupOpencodeCommand" },
qwen: { module: "./setup-qwen.mjs", exportName: "runSetupQwenCommand" },
aider: { module: "./setup-aider.mjs", exportName: "runSetupAiderCommand" },
goose: { module: "./setup-goose.mjs", exportName: "runSetupGooseCommand" },
cline: { module: "./setup-cline.mjs", exportName: "runSetupClineCommand" },
continue: { module: "./setup-continue.mjs", exportName: "runSetupContinueCommand" },
kilo: { module: "./setup-kilo.mjs", exportName: "runSetupKiloCommand" },
};
/**
* Materialize the active server before delegating to a setup recipe.
*
* `apiFetch` knows how to prefer a named context over an ambient
* `OMNIROUTE_API_KEY`, but the older setup modules receive plain options and
* resolve those themselves. Passing the resolved URL/key here keeps the
* picker and the delegated recipe on the same local/remote target, including
* Claude Code which predates context-aware setup resolution.
*/
export function resolveConfigureTargetOptions(opts = {}) {
const resolved = { ...opts };
const ambientKey = process.env.OMNIROUTE_API_KEY || "";
const explicitRemote = opts.remote || opts.baseUrl;
let context;
try {
context = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
} catch {
// A missing/corrupt context file should retain the normal local fallback.
}
if (!explicitRemote) {
const localDefault = `http://localhost:${opts.port || process.env.PORT || "20128"}`;
const contextBase = String(context?.baseUrl || "").replace(/\/+$/, "");
if (contextBase && contextBase !== localDefault) {
resolved.remote = contextBase;
} else if (opts.port) {
resolved.remote = localDefault;
}
} else if (!resolved.remote && resolved.baseUrl) {
resolved.remote = resolved.baseUrl;
}
const contextKey = context?.accessToken || context?.apiKey;
if (contextKey && (!opts.apiKey || opts.apiKey === ambientKey)) {
resolved.apiKey = contextKey;
}
return resolved;
}
export function listConfigureTargets() {
return [...SUPPORTED];
}
export { getModelPreferenceState, rankPreferredModels };
function preferenceContextName(opts = {}) {
if (opts.context || process.env.OMNIROUTE_CONTEXT) {
return String(opts.context || process.env.OMNIROUTE_CONTEXT);
}
try {
return String(loadContexts().currentContext || "default");
} catch {
return "default";
}
}
/** Derive a short, filesystem-safe profile name from a model id. */ /** Derive a short, filesystem-safe profile name from a model id. */
export function profileNameFromModel(modelId) { export function profileNameFromModel(modelId) {
@@ -80,8 +157,15 @@ async function configureCodex(modelId, ctxWindow, opts) {
toolLabel: "Codex", toolLabel: "Codex",
hostCommand: "omniroute configure codex", hostCommand: "omniroute configure codex",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]), allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun: Boolean(opts.dryRun ?? opts["dry-run"]),
}); });
if (guard !== 0) return guard; if (guard !== 0) return guard;
if (opts.dryRun ?? opts["dry-run"]) {
const profile = opts.name || profileNameFromModel(modelId);
const filePath = path.join(codexHome, `${profile}.config.toml`);
printInfo(`[dry-run] would write ${filePath}`);
return 0;
}
if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true }); if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true });
const profile = opts.name || profileNameFromModel(modelId); const profile = opts.name || profileNameFromModel(modelId);
const filePath = path.join(codexHome, `${profile}.config.toml`); const filePath = path.join(codexHome, `${profile}.config.toml`);
@@ -97,16 +181,22 @@ async function configureCodex(modelId, ctxWindow, opts) {
} }
export async function runConfigureCommand(cli, opts = {}, cmd) { export async function runConfigureCommand(cli, opts = {}, cmd) {
const target = String(cli || "").toLowerCase(); const target = resolveManifestTarget(cli, "configure");
if (!SUPPORTED.includes(target)) { if (!target) {
printError(`Unsupported CLI '${cli}'. Supported: ${SUPPORTED.join(", ")}.`); printError(`Unsupported CLI '${cli}'. Supported: ${SUPPORTED.join(", ")}.`);
return 2; return 2;
} }
if (opts.favorite && opts.unfavorite) {
printError("Choose only one of --favorite or --unfavorite.");
return 2;
}
const globalOpts = cmd ? cmd.optsWithGlobals() : {}; const globalOpts = cmd ? cmd.optsWithGlobals() : {};
const requestOpts = resolveConfigureTargetOptions({ ...globalOpts, ...opts });
const contextKey = preferenceContextName({ ...globalOpts, ...opts });
let models; let models;
try { try {
models = await fetchModels(globalOpts); models = await fetchModels(requestOpts);
} catch (e) { } catch (e) {
printError(e instanceof Error ? e.message : String(e)); printError(e instanceof Error ? e.message : String(e));
return 1; return 1;
@@ -122,12 +212,15 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
chosenId = `${opts.provider}/${chosenId}`; chosenId = `${opts.provider}/${chosenId}`;
} }
if (!chosenId) { if (!chosenId && !opts.yes) {
const ids = models.map((m) => (typeof m === "string" ? m : m.id)); const ids = models.map((m) => (typeof m === "string" ? m : m.id));
const preferences = loadModelPreferences();
const rankedIds = rankPreferredModels(target, ids, preferences, contextKey);
const preferenceState = getModelPreferenceState(target, preferences, contextKey);
const providers = [...new Set(models.map(providerOf))].sort(); const providers = [...new Set(models.map(providerOf))].sort();
const prompt = createPrompt(); const prompt = createPrompt();
try { try {
printHeading("Configure Codex CLI"); printHeading(`Configure ${target} CLI`);
let providerList = providers; let providerList = providers;
if (opts.provider) { if (opts.provider) {
providerList = providers.filter((p) => p === opts.provider); providerList = providers.filter((p) => p === opts.provider);
@@ -136,8 +229,18 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
const p = await prompt.ask("Provider"); const p = await prompt.ask("Provider");
if (p) providerList = providers.filter((x) => x === p); if (p) providerList = providers.filter((x) => x === p);
} }
const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id)))); const inProvider = rankedIds.filter((id) =>
const candidates = inProvider.length ? inProvider : ids; providerList.includes(providerOf(byId(models, id)))
);
const candidates = inProvider.length ? inProvider : rankedIds;
if (preferenceState.favorites.length) {
printInfo(
`Favorites: ${preferenceState.favorites.filter((id) => ids.includes(id)).join(", ")}`
);
}
if (preferenceState.recent.length) {
printInfo(`Recent: ${preferenceState.recent.filter((id) => ids.includes(id)).join(", ")}`);
}
printInfo( printInfo(
`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}` `Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`
); );
@@ -158,10 +261,48 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
} }
const ctxWindow = contextWindowOf(entry); const ctxWindow = contextWindowOf(entry);
let result;
if (target === "codex") { if (target === "codex") {
return await configureCodex(chosenId, ctxWindow, opts); result = await configureCodex(chosenId, ctxWindow, opts);
} else {
const setup = SETUP_MODULES[target];
if (!setup) {
printError(`No setup recipe is registered for '${target}'.`);
return 2;
}
try {
const module = await import(setup.module);
const runSetup = module[setup.exportName];
if (typeof runSetup !== "function") {
printError(`Setup recipe '${target}' is unavailable.`);
return 1;
}
const setupOpts = {
...requestOpts,
...opts,
model: chosenId,
// The picker already selected a model. Setup recipes that can generate
// a model subset receive an exact filter; the others use `model`.
...(target === "claude" || target === "continue" ? { only: chosenId } : {}),
yes: true,
};
result = await runSetup(setupOpts);
} catch (error) {
printError(error instanceof Error ? error.message : String(error));
return 1;
}
} }
return 0;
if (result === 0 && !(opts.dryRun ?? opts["dry-run"])) {
recordModelPreference(target, chosenId, {
favorite: Boolean(opts.favorite),
unfavorite: Boolean(opts.unfavorite),
context: contextKey,
});
}
return result;
} }
function byId(models, id) { function byId(models, id) {
@@ -177,12 +318,20 @@ export function registerConfigure(program) {
.command("configure <cli>") .command("configure <cli>")
.description( .description(
t("configure.description") || t("configure.description") ||
"Pick a provider+model from the active server and write a local CLI config (v1: codex)" "Pick a provider+model from the active server and configure a supported local CLI"
) )
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL")
.option("--context <name>", "Named local/remote context")
.option("--api-key <key>", "OmniRoute API key (defaults to the active context/env)")
.option("--provider <id>", "Provider id (skips the interactive provider prompt)") .option("--provider <id>", "Provider id (skips the interactive provider prompt)")
.option("--model <id>", "Model id (skips the interactive model prompt)") .option("--model <id>", "Model id (skips the interactive model prompt)")
.option("--name <name>", "Profile name to write (default: derived from model)") .option("--name <name>", "Profile name to write (default: derived from model)")
.option("--codex-home <dir>", "Codex home dir (default: ~/.codex)") .option("--codex-home <dir>", "Codex home dir (default: ~/.codex)")
.option("--yes", "Non-interactive; requires --model")
.option("--favorite", "Remember the selected model as a favorite for this CLI")
.option("--unfavorite", "Remove the selected model from this CLI's favorites")
.option("--dry-run", "Preview the generated config without writing")
.option( .option(
"--allow-container-write", "--allow-container-write",
"Write the config even when OmniRoute runs in a container and the target is not mounted from the host" "Write the config even when OmniRoute runs in a container and the target is not mounted from the host"

View File

@@ -1,5 +1,5 @@
import { apiFetch } from "../api.mjs"; import { apiFetch } from "../api.mjs";
import { loadContexts, saveContexts } from "../contexts.mjs"; import { loadContexts, saveContextsSecure } from "../contexts.mjs";
import { createPrompt, printSuccess, printError, printInfo } from "../io.mjs"; import { createPrompt, printSuccess, printError, printInfo } from "../io.mjs";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
@@ -31,7 +31,9 @@ export function normalizeBaseUrl(host, port) {
/** Derive a clean context name from a host (strip scheme/port). */ /** Derive a clean context name from a host (strip scheme/port). */
export function hostLabel(host) { export function hostLabel(host) {
let value = String(host || "").trim().replace(/^https?:\/\//i, ""); let value = String(host || "")
.trim()
.replace(/^https?:\/\//i, "");
value = value.split("/")[0].split(":")[0]; value = value.split("/")[0].split(":")[0];
return value || "remote"; return value || "remote";
} }
@@ -107,7 +109,7 @@ export async function runConnectCommand(host, opts = {}) {
description: `Remote OmniRoute (${host})`, description: `Remote OmniRoute (${host})`,
}; };
cfg.currentContext = name; cfg.currentContext = name;
saveContexts(cfg); await saveContextsSecure(cfg);
printSuccess(`Connected to ${baseUrl} — context '${name}' (scope: ${scope})`); printSuccess(`Connected to ${baseUrl} — context '${name}' (scope: ${scope})`);
printInfo("All commands now target this server."); printInfo("All commands now target this server.");

View File

@@ -1,21 +1,34 @@
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
import { emit } from "../output.mjs"; import { emit } from "../output.mjs";
import { loadContexts, saveContexts, resolveActiveContext } from "../contexts.mjs"; import {
loadContexts,
saveContextsSecure,
deleteContextCredential,
migrateContextCredentials,
resolveActiveContext,
} from "../contexts.mjs";
/** Auth label for a context: prefers the scoped accessToken over the legacy apiKey. */ /** Auth label for a context: prefers the scoped accessToken over the legacy apiKey. */
function authLabel(c) { function authLabel(c) {
if (c?.accessToken) return "token"; if (c?.accessToken) return "token";
if (c?.apiKey) return "key"; if (c?.apiKey) return "key";
if (c?.credentialRef) return "keychain";
return "✗"; return "✗";
} }
function contextMap(config) {
return config.contexts || config.profiles || {};
}
export async function confirm(msg) { export async function confirm(msg) {
// Non-interactive stdin (pipe, CI, EOF) cannot answer a [y/N] prompt. Asking // Non-interactive stdin (pipe, CI, EOF) cannot answer a [y/N] prompt. Asking
// anyway leaves the readline question pending forever — Node then warns about an // anyway leaves the readline question pending forever — Node then warns about an
// "unsettled top-level await" at exit. Decline cleanly instead and point at the // "unsettled top-level await" at exit. Decline cleanly instead and point at the
// non-interactive escape hatch so scripted callers fail safe rather than hang. // non-interactive escape hatch so scripted callers fail safe rather than hang.
if (!process.stdin.isTTY) { if (!process.stdin.isTTY) {
process.stderr.write(`${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n`); process.stderr.write(
`${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n`
);
return false; return false;
} }
const readline = await import("node:readline"); const readline = await import("node:readline");
@@ -31,6 +44,18 @@ function maskKey(k) {
return `${k.slice(0, 6)}***${k.slice(-4)}`; return `${k.slice(0, 6)}***${k.slice(-4)}`;
} }
/** Return an export-safe copy without legacy or canonical context credentials. */
export function redactContextSecrets(config) {
const out = JSON.parse(JSON.stringify(config || {}));
for (const collection of [out.contexts, out.profiles]) {
for (const context of Object.values(collection || {})) {
context.apiKey = null;
delete context.accessToken;
}
}
return out;
}
export function registerContexts(program) { export function registerContexts(program) {
const ctx = program const ctx = program
.command("contexts") .command("contexts")
@@ -43,7 +68,7 @@ export function registerContexts(program) {
.action(async (opts, cmd) => { .action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals(); const globalOpts = cmd.optsWithGlobals();
const cfg = loadContexts(); const cfg = loadContexts();
const rows = Object.entries(cfg.contexts || {}).map(([name, c]) => ({ const rows = Object.entries(contextMap(cfg)).map(([name, c]) => ({
active: name === (cfg.currentContext || "default") ? "●" : "", active: name === (cfg.currentContext || "default") ? "●" : "",
name, name,
baseUrl: c.baseUrl || "", baseUrl: c.baseUrl || "",
@@ -73,7 +98,7 @@ export function registerContexts(program) {
.option("--description <d>", "Context description") .option("--description <d>", "Context description")
.action(async (name, opts) => { .action(async (name, opts) => {
const cfg = loadContexts(); const cfg = loadContexts();
if (cfg.contexts?.[name]) { if (contextMap(cfg)[name]) {
process.stderr.write(`Context '${name}' already exists. Remove or rename first.\n`); process.stderr.write(`Context '${name}' already exists. Remove or rename first.\n`);
process.exit(2); process.exit(2);
} }
@@ -86,29 +111,29 @@ export function registerContexts(program) {
if (opts.accessTokenStdin) accessToken = value; if (opts.accessTokenStdin) accessToken = value;
else apiKey = value; else apiKey = value;
} }
cfg.contexts = cfg.contexts || {}; const contexts = contextMap(cfg);
cfg.contexts[name] = { contexts[name] = {
baseUrl: opts.url, baseUrl: opts.url,
accessToken: accessToken || undefined, accessToken: accessToken || undefined,
apiKey, apiKey,
scope: opts.scope || undefined, scope: opts.scope || undefined,
description: opts.description || undefined, description: opts.description || undefined,
}; };
saveContexts(cfg); await saveContextsSecure(cfg);
process.stdout.write(`Added context '${name}'\n`); process.stdout.write(`Added context '${name}'\n`);
}); });
ctx ctx
.command("use <name>") .command("use <name>")
.description("Switch active context") .description("Switch active context")
.action((name) => { .action(async (name) => {
const cfg = loadContexts(); const cfg = loadContexts();
if (!cfg.contexts?.[name]) { if (!contextMap(cfg)[name]) {
process.stderr.write(`No such context: ${name}\n`); process.stderr.write(`No such context: ${name}\n`);
process.exit(2); process.exit(2);
} }
cfg.currentContext = name; cfg.currentContext = name;
saveContexts(cfg); await saveContextsSecure(cfg);
process.stdout.write(`Active context: ${name}\n`); process.stdout.write(`Active context: ${name}\n`);
}); });
@@ -143,7 +168,7 @@ export function registerContexts(program) {
.action((name, opts, cmd) => { .action((name, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals(); const globalOpts = cmd.optsWithGlobals();
const cfg = loadContexts(); const cfg = loadContexts();
const c = cfg.contexts?.[name]; const c = contextMap(cfg)[name];
if (!c) { if (!c) {
process.stderr.write(`No such context: ${name}\n`); process.stderr.write(`No such context: ${name}\n`);
process.exit(2); process.exit(2);
@@ -151,6 +176,8 @@ export function registerContexts(program) {
const display = { const display = {
name, name,
baseUrl: c.baseUrl, baseUrl: c.baseUrl,
auth: authLabel(c),
credentialRef: c.credentialRef || null,
accessToken: maskKey(c.accessToken), accessToken: maskKey(c.accessToken),
apiKey: maskKey(c.apiKey), apiKey: maskKey(c.apiKey),
scope: c.scope, scope: c.scope,
@@ -172,7 +199,7 @@ export function registerContexts(program) {
} }
} }
const cfg = loadContexts(); const cfg = loadContexts();
if (!cfg.contexts?.[name]) { if (!contextMap(cfg)[name]) {
process.stderr.write(`No such context: ${name}\n`); process.stderr.write(`No such context: ${name}\n`);
process.exit(2); process.exit(2);
} }
@@ -180,29 +207,37 @@ export function registerContexts(program) {
process.stderr.write("Cannot remove default context.\n"); process.stderr.write("Cannot remove default context.\n");
process.exit(2); process.exit(2);
} }
delete cfg.contexts[name]; const contexts = contextMap(cfg);
const deletedCredential = await deleteContextCredential(name, contexts[name]);
if (contexts[name].credentialRef && !deletedCredential) {
process.stderr.write(
"Warning: could not remove the OS-keychain entry; the context reference was removed locally.\n"
);
}
delete contexts[name];
if (cfg.currentContext === name) cfg.currentContext = "default"; if (cfg.currentContext === name) cfg.currentContext = "default";
saveContexts(cfg); await saveContextsSecure(cfg);
process.stdout.write(`Removed context '${name}'\n`); process.stdout.write(`Removed context '${name}'\n`);
}); });
ctx ctx
.command("rename <old> <new>") .command("rename <old> <new>")
.description("Rename a context") .description("Rename a context")
.action((oldName, newName) => { .action(async (oldName, newName) => {
const cfg = loadContexts(); const cfg = loadContexts();
if (!cfg.contexts?.[oldName]) { const contexts = contextMap(cfg);
if (!contexts[oldName]) {
process.stderr.write(`No such context: ${oldName}\n`); process.stderr.write(`No such context: ${oldName}\n`);
process.exit(2); process.exit(2);
} }
if (cfg.contexts[newName]) { if (contexts[newName]) {
process.stderr.write(`Context '${newName}' already exists.\n`); process.stderr.write(`Context '${newName}' already exists.\n`);
process.exit(2); process.exit(2);
} }
cfg.contexts[newName] = cfg.contexts[oldName]; contexts[newName] = contexts[oldName];
delete cfg.contexts[oldName]; delete contexts[oldName];
if (cfg.currentContext === oldName) cfg.currentContext = newName; if (cfg.currentContext === oldName) cfg.currentContext = newName;
saveContexts(cfg); await saveContextsSecure(cfg);
process.stdout.write(`Renamed '${oldName}' → '${newName}'\n`); process.stdout.write(`Renamed '${oldName}' → '${newName}'\n`);
}); });
@@ -213,13 +248,7 @@ export function registerContexts(program) {
.option("--no-secrets", "Omit API keys from export") .option("--no-secrets", "Omit API keys from export")
.action(async (opts, cmd) => { .action(async (opts, cmd) => {
const cfg = loadContexts(); const cfg = loadContexts();
const out = JSON.parse(JSON.stringify(cfg)); const out = opts.noSecrets ? redactContextSecrets(cfg) : JSON.parse(JSON.stringify(cfg));
if (opts.noSecrets) {
for (const c of Object.values(out.contexts || {})) {
c.apiKey = null;
delete c.accessToken;
}
}
const json = JSON.stringify(out, null, 2); const json = JSON.stringify(out, null, 2);
if (opts.out) { if (opts.out) {
const { writeFileSync } = await import("node:fs"); const { writeFileSync } = await import("node:fs");
@@ -248,7 +277,12 @@ export function registerContexts(program) {
const cfg = opts.merge const cfg = opts.merge
? loadContexts() ? loadContexts()
: { version: 1, currentContext: "default", contexts: {} }; : { version: 1, currentContext: "default", contexts: {} };
const incoming = imported.contexts || {}; if (!cfg.contexts && cfg.profiles) {
cfg.contexts = cfg.profiles;
delete cfg.profiles;
}
cfg.contexts = cfg.contexts || {};
const incoming = imported.contexts || imported.profiles || {};
let count = 0; let count = 0;
for (const [name, raw] of Object.entries(incoming)) { for (const [name, raw] of Object.entries(incoming)) {
if (typeof name !== "string" || !name) continue; if (typeof name !== "string" || !name) continue;
@@ -265,7 +299,38 @@ export function registerContexts(program) {
if (!opts.merge && typeof imported.currentContext === "string") { if (!opts.merge && typeof imported.currentContext === "string") {
cfg.currentContext = imported.currentContext; cfg.currentContext = imported.currentContext;
} }
saveContexts(cfg); await saveContextsSecure(cfg);
process.stdout.write(`Imported ${count} context(s)\n`); process.stdout.write(`Imported ${count} context(s)\n`);
}); });
ctx
.command("migrate")
.description("Move legacy plaintext context credentials to the OS keychain")
.option("--yes", "Confirm migration in non-interactive scripts")
.action(async (opts) => {
const cfg = loadContexts();
const pending = Object.entries(cfg.contexts || cfg.profiles || {}).filter(
([, context]) => context?.accessToken || context?.apiKey
);
if (!pending.length) {
process.stdout.write("No plaintext context credentials found.\n");
return;
}
if (
!opts.yes &&
!(await confirm(`Migrate ${pending.length} context credential(s) to keychain?`))
) {
process.stdout.write("Cancelled.\n");
return;
}
const result = await migrateContextCredentials();
if (!result.migrated) {
process.stderr.write(
"OS keychain unavailable; credentials remain in config.json mode 0600.\n"
);
process.exitCode = 2;
return;
}
process.stdout.write(`Migrated ${pending.length} context credential(s) to keychain.\n`);
});
} }

View File

@@ -229,18 +229,45 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
stdio: "inherit", stdio: "inherit",
shell: shellValue, shell: shellValue,
}); });
let settled = false;
const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
const signalHandlers = {};
const cleanupSignalHandlers = () => {
for (const signal of Object.keys(signalExitCode)) {
process.removeListener(signal, signalHandlers[signal]);
}
};
const finish = (code) => {
if (settled) return;
settled = true;
cleanupSignalHandlers();
resolve(code);
};
for (const signal of Object.keys(signalExitCode)) {
signalHandlers[signal] = () => {
try {
child.kill(signal);
} catch {
// The child may have already exited between the signal and cleanup.
}
finish(signalExitCode[signal]);
};
process.once(signal, signalHandlers[signal]);
}
child.on("error", (err) => { child.on("error", (err) => {
if (err?.code === "ENOENT") { if (err?.code === "ENOENT") {
console.error( console.error(
"The 'codex' CLI was not found in PATH. Install with:\n npm install -g @openai/codex" "The 'codex' CLI was not found in PATH. Install with:\n npm install -g @openai/codex"
); );
resolve(127); finish(127);
} else { } else {
console.error(String(err?.message || err)); console.error(String(err?.message || err));
resolve(1); finish(1);
} }
}); });
child.on("exit", (code) => resolve(code ?? 0)); child.on("exit", (code, signalName) => {
finish(code ?? signalExitCode[signalName] ?? 0);
});
}); });
} }

View File

@@ -204,16 +204,43 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
shell, shell,
...(process.platform === "win32" ? { windowsHide: true } : {}), ...(process.platform === "win32" ? { windowsHide: true } : {}),
}); });
let settled = false;
const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
const signalHandlers = {};
const cleanupSignalHandlers = () => {
for (const signal of Object.keys(signalExitCode)) {
process.removeListener(signal, signalHandlers[signal]);
}
};
const finish = (code) => {
if (settled) return;
settled = true;
cleanupSignalHandlers();
resolve(code);
};
for (const signal of Object.keys(signalExitCode)) {
signalHandlers[signal] = () => {
try {
child.kill(signal);
} catch {
// The child may have already exited between the signal and cleanup.
}
finish(signalExitCode[signal]);
};
process.once(signal, signalHandlers[signal]);
}
child.on("error", (err) => { child.on("error", (err) => {
if (err && err.code === "ENOENT") { if (err && err.code === "ENOENT") {
console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH."); console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH.");
resolve(127); finish(127);
} else { } else {
console.error(String(err?.message || err)); console.error(String(err?.message || err));
resolve(1); finish(1);
} }
}); });
child.on("exit", (code) => resolve(code ?? 0)); child.on("exit", (code, signalName) => {
finish(code ?? signalExitCode[signalName] ?? 0);
});
}); });
} }

View File

@@ -54,11 +54,20 @@ async function openBrowser(url) {
} }
} }
async function pollStatus(endpoint, timeoutMs) { function targetApiOptions(opts = {}) {
return {
baseUrl: opts.baseUrl,
context: opts.context,
apiKey: opts.apiKey,
timeout: opts.timeout,
};
}
async function pollStatus(endpoint, timeoutMs, opts = {}) {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
await sleep(2000); await sleep(2000);
const res = await apiFetch(endpoint); const res = await apiFetch(endpoint, targetApiOptions(opts));
if (!res.ok) continue; if (!res.ok) continue;
const data = await res.json(); const data = await res.json();
if (data.status === "complete" || data.status === "completed") return data; if (data.status === "complete" || data.status === "completed") return data;
@@ -85,7 +94,7 @@ async function runBrowserFlow(def, opts) {
const authorizeUrl = `/api/oauth/${backendKey}/authorize${ const authorizeUrl = `/api/oauth/${backendKey}/authorize${
redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : "" redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""
}`; }`;
const startRes = await apiFetch(authorizeUrl, { method: "GET" }); const startRes = await apiFetch(authorizeUrl, { ...targetApiOptions(opts), method: "GET" });
if (!startRes.ok) { if (!startRes.ok) {
const detail = await safeErrorBody(startRes); const detail = await safeErrorBody(startRes);
process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}${detail}\n`); process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}${detail}\n`);
@@ -143,6 +152,7 @@ async function runBrowserFlow(def, opts) {
} }
const exchangeRes = await apiFetch(`/api/oauth/${backendKey}/exchange`, { const exchangeRes = await apiFetch(`/api/oauth/${backendKey}/exchange`, {
...targetApiOptions(opts),
method: "POST", method: "POST",
body: { body: {
code, code,
@@ -179,7 +189,7 @@ async function runImportFlow(def, opts) {
const endpoint = opts.importFromSystem const endpoint = opts.importFromSystem
? `/api/oauth/${def.id}/auto-import` ? `/api/oauth/${def.id}/auto-import`
: `/api/oauth/${def.id}/import`; : `/api/oauth/${def.id}/import`;
const res = await apiFetch(endpoint, { method: "POST" }); const res = await apiFetch(endpoint, { ...targetApiOptions(opts), method: "POST" });
if (!res.ok) { if (!res.ok) {
process.stderr.write(`Import failed: ${res.status}\n`); process.stderr.write(`Import failed: ${res.status}\n`);
process.exit(1); process.exit(1);
@@ -195,6 +205,7 @@ async function runSocialFlow(def, opts) {
process.exit(2); process.exit(2);
} }
const startRes = await apiFetch(`/api/oauth/${def.id}/social-authorize`, { const startRes = await apiFetch(`/api/oauth/${def.id}/social-authorize`, {
...targetApiOptions(opts),
method: "POST", method: "POST",
body: { social }, body: { social },
}); });
@@ -209,14 +220,18 @@ async function runSocialFlow(def, opts) {
process.stderr.write("Waiting for social authorization...\n"); process.stderr.write("Waiting for social authorization...\n");
const result = await pollStatus( const result = await pollStatus(
`/api/oauth/${def.id}/social-exchange?state=${encodeURIComponent(start.state ?? "")}`, `/api/oauth/${def.id}/social-exchange?state=${encodeURIComponent(start.state ?? "")}`,
opts.timeout ?? 300000 opts.timeout ?? 300000,
opts
); );
process.stdout.write(`Authorized: ${result.email ?? result.userId ?? "connected"}\n`); process.stdout.write(`Authorized: ${result.email ?? result.userId ?? "connected"}\n`);
} }
async function runDeviceFlow(def, opts) { async function runDeviceFlow(def, opts) {
const providerKey = resolveBackendKey(def.id); const providerKey = resolveBackendKey(def.id);
const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, { method: "POST" }); const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, {
...targetApiOptions(opts),
method: "POST",
});
if (!startRes.ok) { if (!startRes.ok) {
process.stderr.write(`Failed to start device flow: ${startRes.status}\n`); process.stderr.write(`Failed to start device flow: ${startRes.status}\n`);
process.exit(1); process.exit(1);
@@ -233,12 +248,14 @@ async function runDeviceFlow(def, opts) {
while (Date.now() < deadline) { while (Date.now() < deadline) {
await sleep(intervalMs); await sleep(intervalMs);
const statusRes = await apiFetch( const statusRes = await apiFetch(
`/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}` `/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}`,
targetApiOptions(opts)
); );
if (!statusRes.ok) continue; if (!statusRes.ok) continue;
const status = await statusRes.json(); const status = await statusRes.json();
if (status.status === "complete" || status.status === "authorized") { if (status.status === "complete" || status.status === "authorized") {
await apiFetch(`/api/providers/${providerKey}/auth/apply`, { await apiFetch(`/api/providers/${providerKey}/auth/apply`, {
...targetApiOptions(opts),
method: "POST", method: "POST",
body: { state: start.state }, body: { state: start.state },
}); });
@@ -255,6 +272,7 @@ async function runDeviceFlow(def, opts) {
} }
export async function runOAuthStart(opts, cmd) { export async function runOAuthStart(opts, cmd) {
opts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts };
const def = PROVIDERS_WITH_OAUTH.find((p) => p.id === opts.provider); const def = PROVIDERS_WITH_OAUTH.find((p) => p.id === opts.provider);
if (!def) { if (!def) {
process.stderr.write( process.stderr.write(
@@ -275,10 +293,10 @@ export async function runOAuthStart(opts, cmd) {
} }
export async function runOAuthStatus(opts, cmd) { export async function runOAuthStatus(opts, cmd) {
const globalOpts = cmd.optsWithGlobals(); const globalOpts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts };
const params = new URLSearchParams(); const params = new URLSearchParams();
if (opts.provider) params.set("provider", opts.provider); if (opts.provider) params.set("provider", opts.provider);
const res = await apiFetch(`/api/providers?${params}`); const res = await apiFetch(`/api/providers?${params}`, targetApiOptions(globalOpts));
if (!res.ok) { if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`); process.stderr.write(`Error: ${res.status}\n`);
process.exit(1); process.exit(1);
@@ -291,6 +309,7 @@ export async function runOAuthStatus(opts, cmd) {
} }
export async function runOAuthRevoke(opts, cmd) { export async function runOAuthRevoke(opts, cmd) {
opts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts };
if (!opts.yes) { if (!opts.yes) {
process.stdout.write( process.stdout.write(
`Revoke OAuth for ${opts.provider}${opts.connectionId ? ` (${opts.connectionId})` : ""}? (yes/no) ` `Revoke OAuth for ${opts.provider}${opts.connectionId ? ` (${opts.connectionId})` : ""}? (yes/no) `
@@ -303,8 +322,11 @@ export async function runOAuthRevoke(opts, cmd) {
} }
const id = opts.connectionId; const id = opts.connectionId;
const res = id const res = id
? await apiFetch(`/api/providers/${id}`, { method: "DELETE" }) ? await apiFetch(`/api/providers/${id}`, { ...targetApiOptions(opts), method: "DELETE" })
: await apiFetch(`/api/oauth/${opts.provider}/revoke`, { method: "POST" }); : await apiFetch(`/api/oauth/${opts.provider}/revoke`, {
...targetApiOptions(opts),
method: "POST",
});
if (!res.ok) { if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`); process.stderr.write(`Error: ${res.status}\n`);
process.exit(1); process.exit(1);

View File

@@ -13,6 +13,9 @@ export function registerProvider(program) {
omniroute providers test <name> — test a provider connection omniroute providers test <name> — test a provider connection
omniroute providers test-all — test all active connections omniroute providers test-all — test all active connections
omniroute providers validate — validate local configuration omniroute providers validate — validate local configuration
omniroute providers add <id> — add an API-key connection
omniroute providers auth <id> — start an existing OAuth flow
omniroute providers remove <id> — remove a connection (requires confirmation)
`); `);
}); });
} }

View File

@@ -0,0 +1,498 @@
import { readFileSync } from "node:fs";
import { apiFetch, statusToExitCode } from "../api.mjs";
import { createPrompt, printError, printInfo, printSuccess } from "../io.mjs";
import { runOAuthStart } from "./oauth.mjs";
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
function isBlank(value) {
return value === undefined || value === null || String(value).trim() === "";
}
function credentialShape(value) {
if (isBlank(value)) return { present: false, length: 0 };
return { present: true, length: String(value).length };
}
const SENSITIVE_FIELD_RE =
/^(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|secret|client[_-]?secret|credential|authorization)$/i;
/**
* Redact provider responses before they reach human or JSON output.
*
* The API normally masks credentials, but the CLI must remain safe when an
* operator enables a server-side reveal/debug option or when a compatible
* remote implementation returns a raw field. Presence and length are useful
* for diagnostics; the value itself must never be printed.
*/
export function redactProviderResponse(value, key = "") {
if (SENSITIVE_FIELD_RE.test(key)) {
if (value === null || value === undefined || value === "") return null;
return typeof value === "string" ? credentialShape(value) : "[redacted]";
}
if (Array.isArray(value)) return value.map((entry) => redactProviderResponse(entry));
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => [
entryKey,
redactProviderResponse(entryValue, entryKey),
])
);
}
/**
* Extract a provider connection from the response returned by /api/providers.
* The server deliberately masks credentials, so this helper never needs to
* inspect or log a secret.
*/
export function findConnectionFromResponse(body, selector) {
const rows = Array.isArray(body?.connections)
? body.connections
: Array.isArray(body?.providers)
? body.providers
: Array.isArray(body)
? body
: [];
const needle = String(selector || "")
.trim()
.toLowerCase();
if (!needle) return null;
return (
rows.find((row) => String(row?.id || "").toLowerCase() === needle) ||
rows.find((row) =>
String(row?.id || "")
.toLowerCase()
.startsWith(needle)
) ||
rows.find((row) => String(row?.name || "").toLowerCase() === needle) ||
rows.find((row) => String(row?.provider || "").toLowerCase() === needle) ||
null
);
}
/** Build the API body without accepting management auth as a provider secret. */
export function buildProviderPayload(provider, opts = {}, credential) {
const body = {
provider: String(provider || "").trim(),
name: String(opts.name || provider || "").trim(),
};
if (!body.name) throw new Error("Provider name is required.");
if (!isBlank(credential)) body.apiKey = String(credential);
if (!isBlank(opts.defaultModel)) body.defaultModel = String(opts.defaultModel).trim();
if (!isBlank(opts.priority)) {
const priority = Number(opts.priority);
if (!Number.isInteger(priority) || priority < 1) {
throw new Error("--priority must be a positive integer.");
}
body.priority = priority;
}
if (opts.providerSpecificData) {
const raw = typeof opts.providerSpecificData === "string" ? opts.providerSpecificData : null;
try {
const parsed = raw ? JSON.parse(raw) : opts.providerSpecificData;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("must be a JSON object");
}
body.providerSpecificData = parsed;
} catch (error) {
throw new Error(
`--provider-specific-data must be a JSON object (${error instanceof Error ? error.message : String(error)})`
);
}
}
return body;
}
/** Resolve a credential from an explicit value, env reference, stdin, or prompt. */
export async function resolveProviderCredential(opts = {}, { prompt = true } = {}) {
// Commander represents the negated `--no-credential` option as
// `credential === false`. It is a control flag, never the literal provider
// credential "false".
if (opts.credential === false || opts.noCredential === true) return undefined;
if (!isBlank(opts.credential)) return String(opts.credential).trim();
const envName = String(opts.credentialEnv || opts["credential-env"] || "").trim();
if (envName) {
if (!ENV_NAME_RE.test(envName)) throw new Error("--credential-env must be a valid env name.");
const value = process.env[envName];
if (isBlank(value)) throw new Error(`Environment variable ${envName} is empty or unset.`);
return String(value).trim();
}
if (opts.credentialStdin || opts["credential-stdin"]) {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const value = chunks.join("").trim();
if (!value) throw new Error("Credential stdin was empty.");
return value;
}
if (!prompt) return undefined;
const input = createPrompt();
try {
const value = await input.askSecret("Provider credential (hidden)");
const trimmed = String(value || "").trim();
if (!trimmed) throw new Error("Provider credential is required.");
return trimmed;
} finally {
input.close();
}
}
function targetOptions(opts = {}) {
return {
// Passing the global values through lets api.mjs apply its context-first
// auth precedence. A caller-supplied --base-url remains an explicit target.
baseUrl: opts.baseUrl,
context: opts.context,
apiKey: opts.apiKey,
timeout: opts.timeout,
};
}
async function readApiError(response) {
try {
const body = await response.json();
const message = body?.error?.message || body?.error || body?.message;
return message ? String(message) : `HTTP ${response.status}`;
} catch {
return `HTTP ${response.status}`;
}
}
async function listRemoteConnections(opts) {
return apiFetch("/api/providers?limit=5000", {
...targetOptions(opts),
acceptNotOk: true,
retry: false,
});
}
async function resolveRemoteConnection(selector, opts) {
const response = await listRemoteConnections(opts);
if (!response.ok) {
throw new Error(await readApiError(response));
}
const connection = findConnectionFromResponse(await response.json(), selector);
if (!connection) throw new Error(`Provider connection not found: ${selector}`);
return connection;
}
export async function runProviderAddCommand(provider, opts = {}) {
const normalized = String(provider || "").trim();
if (!normalized) {
printError("Provider id is required.");
return 2;
}
if (opts.oauth) {
if (opts.dryRun) {
if (!opts.silent) {
const preview = { action: "providers.auth", provider: normalized };
if (opts.json) console.log(JSON.stringify(preview, null, 2));
else printInfo(`dry-run: would start OAuth for ${normalized}`);
}
return 0;
}
return runOAuthStart({ ...opts, provider: normalized }, opts.command);
}
const allowNoCredential = Boolean(
opts.allowNoCredential || opts.noCredential || opts.credential === false
);
let credential;
try {
credential = await resolveProviderCredential(opts, {
prompt: !opts.dryRun && !opts.yes && !allowNoCredential,
});
if (!credential && !opts.dryRun && !allowNoCredential) {
throw new Error(
"Provider credential is required (use --credential-stdin or --credential-env)."
);
}
const payload = buildProviderPayload(normalized, opts, credential);
if (opts.dryRun) {
const preview = {
action: "providers.add",
provider: payload.provider,
name: payload.name,
defaultModel: payload.defaultModel || null,
credential: credentialShape(credential),
providerSpecificData: payload.providerSpecificData
? redactProviderResponse(payload.providerSpecificData)
: null,
};
if (!opts.silent) {
if (opts.json) console.log(JSON.stringify(preview, null, 2));
else printInfo(`dry-run: would add ${payload.provider}/${payload.name}`);
}
return 0;
}
const response = await apiFetch("/api/providers", {
...targetOptions(opts),
method: "POST",
body: payload,
acceptNotOk: true,
retry: false,
});
if (!response.ok) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
const body = await response.json().catch(() => ({}));
if (!opts.silent) {
if (opts.json) console.log(JSON.stringify(redactProviderResponse(body), null, 2));
else printSuccess(`Added provider connection '${body?.connection?.name || payload.name}'.`);
}
return 0;
} catch (error) {
printError(error instanceof Error ? error.message : String(error));
return 1;
}
}
export async function runProviderImportCommand(file, opts = {}) {
let parsed;
try {
parsed = JSON.parse(readFileSync(file, "utf8"));
} catch (error) {
printError(
`Cannot read provider import file: ${error instanceof Error ? error.message : String(error)}`
);
return 1;
}
const entries = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.providers)
? parsed.providers
: [parsed];
if (!entries.length) {
printError("Provider import file contains no entries.");
return 2;
}
const results = [];
for (const entry of entries) {
if (!entry || typeof entry !== "object" || !entry.provider) {
results.push({ ok: false, error: "entry.provider is required" });
if (!opts.continueOnError) break;
continue;
}
const code = await runProviderAddCommand(entry.provider, {
...opts,
...entry,
credential: entry.apiKey ?? entry.credential,
dryRun: opts.dryRun,
yes: true,
silent: true,
allowNoCredential: entry.allowNoCredential ?? opts.allowNoCredential,
});
results.push({ provider: entry.provider, ok: code === 0, code });
if (code !== 0 && !opts.continueOnError) break;
}
if (opts.json) console.log(JSON.stringify({ file, results }, null, 2));
return results.every((result) => result.ok) ? 0 : 1;
}
async function confirmRemoval(label, opts) {
if (opts.yes) return true;
if (!process.stdin.isTTY) {
printError(`Removal of '${label}' declined on non-interactive stdin; pass --yes to confirm.`);
return false;
}
const prompt = createPrompt();
try {
const answer = await prompt.ask(`Remove provider connection '${label}'? [y/N] `);
return /^y(?:es)?$/i.test(String(answer || "").trim());
} finally {
prompt.close();
}
}
export async function runProviderRemoveCommand(selector, opts = {}) {
if (!selector) {
printError("Provider connection id, name, or provider is required.");
return 2;
}
try {
if (opts.dryRun) {
const connection = await resolveRemoteConnection(selector, opts);
if (opts.json) {
console.log(
JSON.stringify(
redactProviderResponse({ action: "providers.remove", connection }),
null,
2
)
);
} else printInfo(`dry-run: would remove ${connection.name || connection.id}`);
return 0;
}
const connection = await resolveRemoteConnection(selector, opts);
if (!(await confirmRemoval(connection.name || connection.id, opts))) return 0;
const response = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, {
...targetOptions(opts),
method: "DELETE",
acceptNotOk: true,
retry: false,
});
if (!response.ok) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
if (opts.json)
console.log(JSON.stringify(redactProviderResponse({ removed: connection }), null, 2));
else printSuccess(`Removed provider connection '${connection.name || connection.id}'.`);
return 0;
} catch (error) {
printError(error instanceof Error ? error.message : String(error));
return 1;
}
}
export async function runProviderEditCommand(selector, opts = {}) {
try {
const connection = await resolveRemoteConnection(selector, opts);
const body = {};
if (opts.name !== undefined) body.name = opts.name;
if (opts.defaultModel !== undefined) body.defaultModel = opts.defaultModel || null;
if (opts.priority !== undefined) body.priority = Number(opts.priority);
if (opts.active !== undefined) body.isActive = Boolean(opts.active);
if (opts.inactive !== undefined) body.isActive = false;
const credential = await resolveProviderCredential(opts, { prompt: false });
if (credential) body.apiKey = credential;
if (Object.keys(body).length === 0) {
printError(
"At least one edit field is required (--name, --default-model, --priority, --active/--inactive, or credential)."
);
return 2;
}
if (opts.dryRun) {
const preview = {
action: "providers.edit",
connection: redactProviderResponse(connection),
changes: { ...body, apiKey: credentialShape(body.apiKey) },
};
if (opts.json) console.log(JSON.stringify(preview, null, 2));
else printInfo(`dry-run: would edit ${connection.name || connection.id}`);
return 0;
}
const response = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, {
...targetOptions(opts),
method: "PUT",
body,
acceptNotOk: true,
retry: false,
});
if (!response.ok) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
const result = await response.json().catch(() => ({}));
if (opts.json) console.log(JSON.stringify(redactProviderResponse(result), null, 2));
else printSuccess(`Updated provider connection '${connection.name || connection.id}'.`);
return 0;
} catch (error) {
printError(error instanceof Error ? error.message : String(error));
return 1;
}
}
export async function runProviderAuthCommand(provider, opts = {}, cmd) {
return runOAuthStart({ ...opts, provider }, cmd);
}
export function registerProviderCrud(providers) {
providers
.command("add <provider>")
.description("Add an API-key provider connection through the active local/remote server")
.option("--name <name>", "Connection name (defaults to provider id)")
.option(
"--credential <key>",
"Provider credential (prefer --credential-stdin or --credential-env)"
)
.option("--credential-env <name>", "Read provider credential from an environment variable")
.option("--credential-stdin", "Read provider credential from stdin")
.option("--allow-no-credential", "Allow providers whose catalog marks the credential optional")
.option("--no-credential", "Allow providers whose catalog marks the credential optional")
.option("--default-model <id>", "Default model for this connection")
.option("--priority <n>", "Connection priority", Number)
.option("--provider-specific-data <json>", "Provider-specific settings as a JSON object")
.option("--oauth", "Start the provider's existing OAuth flow instead")
.option("--yes", "Do not prompt for a credential")
.option("--dry-run", "Preview the request without writing")
.option("--json", "Print machine-readable output")
.action(async (provider, opts, cmd) => {
const code = await runProviderAddCommand(provider, {
...cmd.parent.optsWithGlobals(),
...opts,
command: cmd,
});
if (code !== 0) process.exit(code);
});
providers
.command("import <file>")
.description("Import provider connections from a JSON file")
.option("--continue-on-error", "Continue importing after a failed entry")
.option("--dry-run", "Preview requests without writing")
.option("--json", "Print machine-readable output")
.action(async (file, opts, cmd) => {
const code = await runProviderImportCommand(file, {
...cmd.parent.optsWithGlobals(),
...opts,
});
if (code !== 0) process.exit(code);
});
providers
.command("auth <provider>")
.description("Start an existing OAuth flow for a provider")
.option("--no-browser", "Print the authorization URL instead of opening a browser")
.option("--import-from-system", "Import credentials from the local system when supported")
.option("--social <provider>", "Use a social-login flow when supported")
.option("--timeout <ms>", "OAuth timeout", Number, 300000)
.action(async (provider, opts, cmd) => {
const code = await runProviderAuthCommand(
provider,
{ ...cmd.parent.optsWithGlobals(), ...opts },
cmd
);
if (code !== 0) process.exit(code);
});
providers
.command("remove <idOrName>")
.description("Remove one provider connection from the active local/remote server")
.option("--yes", "Confirm removal")
.option("--dry-run", "Preview the removal without writing")
.option("--json", "Print machine-readable output")
.action(async (idOrName, opts, cmd) => {
const code = await runProviderRemoveCommand(idOrName, {
...cmd.parent.optsWithGlobals(),
...opts,
});
if (code !== 0) process.exit(code);
});
providers
.command("edit <idOrName>")
.description("Edit one provider connection on the active local/remote server")
.option("--name <name>", "New connection name")
.option("--default-model <id>", "New default model")
.option("--priority <n>", "New connection priority", Number)
.option("--active", "Activate the connection")
.option("--inactive", "Deactivate the connection")
.option("--credential <key>", "Replace provider credential")
.option("--credential-env <name>", "Read replacement credential from an environment variable")
.option("--credential-stdin", "Read replacement credential from stdin")
.option("--dry-run", "Preview the edit without writing")
.option("--json", "Print machine-readable output")
.action(async (idOrName, opts, cmd) => {
const code = await runProviderEditCommand(idOrName, {
...cmd.parent.optsWithGlobals(),
...opts,
});
if (code !== 0) process.exit(code);
});
}

View File

@@ -13,6 +13,7 @@ import {
import { encryptCredential } from "../encryption.mjs"; import { encryptCredential } from "../encryption.mjs";
import { openOmniRouteDb } from "../sqlite.mjs"; import { openOmniRouteDb } from "../sqlite.mjs";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
import { registerProviderCrud } from "./provider-crud.mjs";
function publicConnection(connection) { function publicConnection(connection) {
return { return {
@@ -604,6 +605,8 @@ export function registerProviders(program) {
if (exitCode !== 0) process.exit(exitCode); if (exitCode !== 0) process.exit(exitCode);
}); });
registerProviderCrud(providers);
extendProvidersMetrics(providers); extendProvidersMetrics(providers);
} }

View File

@@ -16,28 +16,16 @@ import {
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
import os from "node:os"; import os from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { spawn, execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { quoteShellArgs } from "../utils/winShellArgs.mjs";
const RUN_TARGETS = { import {
claude: { listManifestTargets,
aliases: ["claude", "claude-code", "cc"], manifestModelArgs,
description: "Claude Code", manifestRequiresModel,
}, resolveManifestTarget,
codex: { } from "../cli-manifest.mjs";
aliases: ["codex", "openai-codex", "openai"],
description: "OpenAI Codex CLI",
},
};
/** @type {Record<string,string>} */
const RUN_TARGET_ALIAS_TO_CANONICAL = {
claude: "claude",
"claude-code": "claude",
cc: "claude",
codex: "codex",
"openai-codex": "codex",
openai: "codex",
};
function isBlank(value) { function isBlank(value) {
return value === undefined || value === null || String(value).trim() === ""; return value === undefined || value === null || String(value).trim() === "";
@@ -48,6 +36,11 @@ function toAuthSource(targetOpts) {
!isBlank(targetOpts.token) || !isBlank(targetOpts.apiKey) || !isBlank(targetOpts["api-key"]); !isBlank(targetOpts.token) || !isBlank(targetOpts.apiKey) || !isBlank(targetOpts["api-key"]);
if (explicit) return "option"; if (explicit) return "option";
const envName = String(targetOpts.apiKeyEnv || targetOpts["api-key-env"] || "").trim();
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName) && !isBlank(process.env[envName])) {
return "env";
}
try { try {
const context = resolveActiveContext(targetOpts.context || process.env.OMNIROUTE_CONTEXT); const context = resolveActiveContext(targetOpts.context || process.env.OMNIROUTE_CONTEXT);
if (context && (context.accessToken || context.apiKey)) return "context"; if (context && (context.accessToken || context.apiKey)) return "context";
@@ -60,16 +53,23 @@ function toAuthSource(targetOpts) {
return "none"; return "none";
} }
/** Resolve supported target to canonical id. */ /** Resolve a token option without ever printing its value in a plan. */
function resolveAuthTokenOption(targetOpts = {}) {
const direct = targetOpts.token || targetOpts.apiKey || targetOpts["api-key"];
if (!isBlank(direct)) return direct;
const envName = String(targetOpts.apiKeyEnv || targetOpts["api-key-env"] || "").trim();
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName)) return process.env[envName];
return undefined;
}
/** Resolve supported target (id or alias) to canonical id via the manifest. */
export function resolveRunTarget(target) { export function resolveRunTarget(target) {
const raw = String(target || "") return resolveManifestTarget(target, "run");
.trim()
.toLowerCase();
return RUN_TARGET_ALIAS_TO_CANONICAL[raw];
} }
export function listRunTargets() { export function listRunTargets() {
return Object.keys(RUN_TARGETS); return listManifestTargets("run");
} }
/** /**
@@ -116,8 +116,8 @@ async function buildClaudePlan(rawOpts, args = []) {
const merged = { const merged = {
...rawOpts, ...rawOpts,
model, model,
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token, apiKey: resolveAuthTokenOption(rawOpts),
token: rawOpts.token || rawOpts.apiKey || rawOpts["api-key"], token: resolveAuthTokenOption(rawOpts),
profile: rawOpts.profile ?? rawOpts.p, profile: rawOpts.profile ?? rawOpts.p,
}; };
@@ -142,7 +142,7 @@ async function buildClaudePlan(rawOpts, args = []) {
args: quotedArgs, args: quotedArgs,
model: merged.model || undefined, model: merged.model || undefined,
envDiff: envPreview(process.env, env), envDiff: envPreview(process.env, env),
authSource: toAuthSource(merged), authSource: toAuthSource(rawOpts),
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell), commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
}; };
} }
@@ -151,7 +151,7 @@ async function buildCodexPlan(rawOpts, args = []) {
const model = resolveModelFromTargetOptions(rawOpts); const model = resolveModelFromTargetOptions(rawOpts);
const merged = { const merged = {
...rawOpts, ...rawOpts,
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token, apiKey: resolveAuthTokenOption(rawOpts),
model, model,
profile: rawOpts.profile ?? rawOpts.p, profile: rawOpts.profile ?? rawOpts.p,
}; };
@@ -174,25 +174,303 @@ async function buildCodexPlan(rawOpts, args = []) {
args: quotedArgs, args: quotedArgs,
model: merged.model || undefined, model: merged.model || undefined,
envDiff: envPreview(process.env, env), envDiff: envPreview(process.env, env),
authSource: toAuthSource(merged), authSource: toAuthSource(rawOpts),
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell), commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
providerArgs, providerArgs,
profileArgs, profileArgs,
}; };
} }
const NO_AUTH_SENTINEL = "omniroute-no-auth";
function resolveGenericSpawn(command) {
if (process.platform !== "win32") return { command, shell: undefined };
try {
const output = execFileSync("where.exe", [command], {
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
timeout: 3000,
windowsHide: true,
});
const matches = output
.split(/\r?\n/)
.map((value) => value.trim())
.filter(Boolean);
const preferred = matches.find((value) => /\.exe$/i.test(value));
if (preferred) return { command: preferred, shell: undefined };
const shim = matches.find((value) => /\.(?:cmd|bat)$/i.test(value));
if (shim) return { command: shim, shell: true };
} catch {
// Fall through to the conventional npm shim.
}
return { command: `${command}.cmd`, shell: true };
}
function genericEnv(baseEnv, kind, baseUrl, authToken, model) {
const env = { ...baseEnv };
for (const key of Object.keys(env)) {
if (kind === "aider" && /^(OPENAI_API_KEY|OPENAI_API_BASE|OPENAI_BASE_URL)$/.test(key)) {
delete env[key];
}
if (
kind === "goose" &&
(/^(OPENAI_API_KEY|OPENAI_API_BASE|OPENAI_BASE_URL)$/.test(key) || key.startsWith("GOOSE_"))
) {
delete env[key];
}
if (kind === "opencode" && key === "OPENCODE_CONFIG_CONTENT") delete env[key];
if (kind === "qwen" && (key === "QWEN_HOME" || key === "OMNIROUTE_API_KEY")) {
delete env[key];
}
if (
kind === "gemini" &&
/^(GOOGLE_GEMINI_BASE_URL|GEMINI_API_KEY|GOOGLE_API_KEY|GEMINI_CLI_HOME|GEMINI_DEFAULT_AUTH_TYPE|GOOGLE_GENAI_USE_VERTEXAI|GOOGLE_GENAI_USE_GCA)$/.test(
key
)
) {
delete env[key];
}
}
const token = (authToken && String(authToken).trim()) || NO_AUTH_SENTINEL;
if (kind === "aider") {
env.OPENAI_API_BASE = baseUrl;
env.OPENAI_API_KEY = token;
} else if (kind === "goose") {
env.GOOSE_PROVIDER = "openai";
env.OPENAI_HOST = baseUrl;
env.OPENAI_API_KEY = token;
if (model) env.GOOSE_MODEL = model;
} else if (kind === "opencode") {
env.OMNIROUTE_API_KEY = token;
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
omniroute: {
npm: "@ai-sdk/openai-compatible",
name: "OmniRoute",
options: {
baseURL: ensureV1BaseUrl(baseUrl),
apiKey: "{env:OMNIROUTE_API_KEY}",
},
...(model ? { models: { [model]: { name: model } } } : {}),
},
},
});
} else if (kind === "qwen") {
env.OMNIROUTE_API_KEY = token;
} else if (kind === "gemini") {
// Verified against @google/gemini-cli 0.50.0: the SDK appends
// /v1beta/models/<model>:generateContent to this base URL, which is
// OmniRoute's native Gemini surface. Auth is the API-key path; the
// isolated GEMINI_CLI_HOME (set at spawn time) keeps any stored OAuth
// session from overriding it.
env.GOOGLE_GEMINI_BASE_URL = baseUrl;
env.GEMINI_API_KEY = token;
env.GEMINI_DEFAULT_AUTH_TYPE = "gemini-api-key";
}
return env;
}
function ensureV1BaseUrl(baseUrl) {
const normalized = String(baseUrl || "").replace(/\/+$/, "");
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
}
function modelArgsForTarget(target, model) {
return manifestModelArgs(target, model);
}
function buildGeminiSettings() {
// Force API-key auth in the isolated home so the operator's stored OAuth
// session (Code Assist) never leaks into an OmniRoute-directed launch.
return JSON.stringify({ security: { auth: { selectedType: "gemini-api-key" } } }, null, 2);
}
function buildQwenSettings(baseUrl, model) {
const qwenBaseUrl = ensureV1BaseUrl(baseUrl);
return JSON.stringify(
{
modelProviders: {
openai: [
{
id: model,
name: `${model} (OmniRoute)`,
envKey: "OMNIROUTE_API_KEY",
baseUrl: qwenBaseUrl,
},
],
},
security: { auth: { selectedType: "openai" } },
model: { name: model, baseUrl: qwenBaseUrl },
},
null,
2
);
}
async function buildGenericPlan(target, rawOpts, args = []) {
const { baseUrl, authToken } = resolveLaunchTarget({
...rawOpts,
apiKey: resolveAuthTokenOption(rawOpts),
});
const commandSpec = resolveGenericSpawn(target);
const model = resolveModelFromTargetOptions(rawOpts);
if (manifestRequiresModel(target) && !model) {
throw new Error("Qwen Code requires --model in non-interactive OmniRoute launches");
}
const modelArgs = modelArgsForTarget(target, model);
const fullArgs = [...modelArgs, ...args];
const env = genericEnv(process.env, target, baseUrl, authToken, model);
return {
target,
baseUrl,
command: commandSpec.command,
shell: commandSpec.shell,
args: quoteShellArgs(fullArgs, process.platform),
model: model || undefined,
envDiff: envPreview(process.env, env),
authSource: toAuthSource(rawOpts),
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
modelArgs,
configOverlay:
target === "qwen"
? "temporary QWEN_HOME (removed after exit)"
: target === "gemini"
? "temporary GEMINI_CLI_HOME (removed after exit)"
: target === "opencode"
? "OPENCODE_CONFIG_CONTENT (process environment only)"
: undefined,
};
}
async function healthCheckForRun(baseUrl) {
try {
const response = await fetch(`${baseUrl}/api/monitoring/health`, {
signal: AbortSignal.timeout(3000),
});
return response.ok;
} catch {
return false;
}
}
async function runGenericTarget(target, rawOpts, args) {
const { baseUrl, authToken } = resolveLaunchTarget({
...rawOpts,
apiKey: resolveAuthTokenOption(rawOpts),
});
if (!(await healthCheckForRun(baseUrl))) {
console.error(`OmniRoute is not reachable at ${baseUrl}. Start it or check --remote.`);
return 1;
}
const model = resolveModelFromTargetOptions(rawOpts);
if (manifestRequiresModel(target) && !model) {
console.error("Qwen Code requires --model in non-interactive OmniRoute launches.");
return 2;
}
const modelArgs = modelArgsForTarget(target, model);
const commandSpec = resolveGenericSpawn(target);
const childEnv = genericEnv(process.env, target, baseUrl, authToken, model);
let overlayHome;
if (target === "qwen") {
overlayHome = mkdtempSync(join(os.tmpdir(), "omniroute-qwen-run-"));
writeFileSync(join(overlayHome, "settings.json"), buildQwenSettings(baseUrl, model), {
encoding: "utf8",
mode: 0o600,
});
childEnv.QWEN_HOME = overlayHome;
} else if (target === "gemini") {
overlayHome = mkdtempSync(join(os.tmpdir(), "omniroute-gemini-run-"));
mkdirSync(join(overlayHome, ".gemini"), { recursive: true });
writeFileSync(join(overlayHome, ".gemini", "settings.json"), buildGeminiSettings(), {
encoding: "utf8",
mode: 0o600,
});
childEnv.GEMINI_CLI_HOME = overlayHome;
}
const child = spawn(
commandSpec.command,
quoteShellArgs([...modelArgs, ...args], process.platform),
{
env: childEnv,
stdio: "inherit",
shell: commandSpec.shell,
...(process.platform === "win32" ? { windowsHide: true } : {}),
}
);
const cleanup = () => {
if (!overlayHome) return;
try {
rmSync(overlayHome, { recursive: true, force: true });
} catch {
// Best-effort cleanup; the directory contains no persistent credentials.
}
};
return await new Promise((resolve) => {
let settled = false;
const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
const finish = (code) => {
if (settled) return;
settled = true;
for (const signal of Object.keys(signalExitCode)) {
process.removeListener(signal, signalHandlers[signal]);
}
cleanup();
resolve(code);
};
const signalHandlers = {};
for (const signal of Object.keys(signalExitCode)) {
signalHandlers[signal] = () => {
try {
child.kill(signal);
} catch {
// The child may have already exited between the signal and cleanup.
}
finish(signalExitCode[signal]);
};
process.once(signal, signalHandlers[signal]);
}
child.on("error", (error) => {
if (error?.code === "ENOENT") {
console.error(`The '${target}' CLI was not found in PATH.`);
finish(127);
} else {
console.error(String(error?.message || error));
finish(1);
}
});
child.on("exit", (code, signal) => {
finish(code ?? signalExitCode[signal] ?? 0);
});
});
}
/** Build a launch plan and redact any resolved secret values. */ /** Build a launch plan and redact any resolved secret values. */
export async function buildRunPlan(target, rawOpts = {}, args = []) { export async function buildRunPlan(target, rawOpts = {}, args = []) {
const canonical = resolveRunTarget(target); const canonical = resolveRunTarget(target);
if (!canonical) { if (!canonical) {
throw new Error("unsupported target"); throw new Error(
`Unsupported target '${target}'. Supported targets: ${listRunTargets().join(", ")}`
);
} }
if (canonical === "claude") { if (canonical === "claude") {
return buildClaudePlan(rawOpts, args); return buildClaudePlan(rawOpts, args);
} }
return buildCodexPlan(rawOpts, args); if (canonical === "codex") {
return buildCodexPlan(rawOpts, args);
}
return buildGenericPlan(canonical, rawOpts, args);
} }
function writeDryRunOutput(plan, opts = {}) { function writeDryRunOutput(plan, opts = {}) {
@@ -207,6 +485,7 @@ function writeDryRunOutput(plan, opts = {}) {
}, },
shell: !!plan.shell, shell: !!plan.shell,
model: plan.model || null, model: plan.model || null,
configOverlay: plan.configOverlay || null,
env: { env: {
changedOrAdded: plan.envDiff.changedOrAdded, changedOrAdded: plan.envDiff.changedOrAdded,
removed: plan.envDiff.removed, removed: plan.envDiff.removed,
@@ -224,6 +503,7 @@ function writeDryRunOutput(plan, opts = {}) {
console.log(`args: ${JSON.stringify(output.args)}`); console.log(`args: ${JSON.stringify(output.args)}`);
console.log(`auth: ${JSON.stringify(output.auth)}`); console.log(`auth: ${JSON.stringify(output.auth)}`);
console.log(`model: ${output.model || "(not set)"}`); console.log(`model: ${output.model || "(not set)"}`);
if (output.configOverlay) console.log(`config overlay: ${output.configOverlay}`);
if (output.env.changedOrAdded.length) { if (output.env.changedOrAdded.length) {
console.log(`env added/changed: ${output.env.changedOrAdded.join(", ")}`); console.log(`env added/changed: ${output.env.changedOrAdded.join(", ")}`);
} }
@@ -237,8 +517,8 @@ function buildExecutionOptionsForClaude(rawOpts) {
return { return {
...rawOpts, ...rawOpts,
model: resolveModelFromTargetOptions(rawOpts), model: resolveModelFromTargetOptions(rawOpts),
token: rawOpts.token || rawOpts.apiKey || rawOpts["api-key"], token: resolveAuthTokenOption(rawOpts),
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token, apiKey: resolveAuthTokenOption(rawOpts),
profile: rawOpts.profile || rawOpts.p, profile: rawOpts.profile || rawOpts.p,
}; };
} }
@@ -247,7 +527,7 @@ function buildExecutionOptionsForCodex(rawOpts) {
return { return {
...rawOpts, ...rawOpts,
model: resolveModelFromTargetOptions(rawOpts), model: resolveModelFromTargetOptions(rawOpts),
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token, apiKey: resolveAuthTokenOption(rawOpts),
profile: rawOpts.profile || rawOpts.p, profile: rawOpts.profile || rawOpts.p,
}; };
} }
@@ -262,12 +542,18 @@ export async function runCliTarget(target, opts = {}, args = []) {
const canonical = resolveRunTarget(target); const canonical = resolveRunTarget(target);
if (!canonical) { if (!canonical) {
process.stderr.write( process.stderr.write(
`Unsupported target '${target}'. Supported targets: ${Object.keys(RUN_TARGETS).join(", ")}\n` `Unsupported target '${target}'. Supported targets: ${listRunTargets().join(", ")}\n`
); );
return 2; return 2;
} }
const plan = await buildRunPlan(target, opts, args); let plan;
try {
plan = await buildRunPlan(target, opts, args);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 2;
}
if (opts.dryRun) { if (opts.dryRun) {
writeDryRunOutput(plan, opts); writeDryRunOutput(plan, opts);
@@ -278,7 +564,11 @@ export async function runCliTarget(target, opts = {}, args = []) {
return await runLaunchClaudeCommand(buildExecutionOptionsForClaude(opts), args); return await runLaunchClaudeCommand(buildExecutionOptionsForClaude(opts), args);
} }
return await runLaunchCodexCommand(buildExecutionOptionsForCodex(opts), args); if (canonical === "codex") {
return await runLaunchCodexCommand(buildExecutionOptionsForCodex(opts), args);
}
return await runGenericTarget(canonical, opts, args);
} }
export function registerRun(program) { export function registerRun(program) {
@@ -294,12 +584,15 @@ export function registerRun(program) {
"--remote <url>", "--remote <url>",
"Remote OmniRoute base URL (overrides --port, --base-url, and the active context)" "Remote OmniRoute base URL (overrides --port, --base-url, and the active context)"
) )
.option("--base-url <url>", "OmniRoute base URL (alias for --remote)")
.option("--context <name>", "Named local/remote context to use for URL and credentials")
.option("--provider <id>", "Provider id for shorthand model composition") .option("--provider <id>", "Provider id for shorthand model composition")
.option("--model <id>", "Model id to inject in the launched target where supported") .option("--model <id>", "Model id to inject in the launched target where supported")
.option("--profile <name>", "Profile/alias argument for target launchers that support it") .option("--profile <name>", "Profile/alias argument for target launchers that support it")
.option("-p, --p <name>", "Alias for --profile") .option("-p, --p <name>", "Alias for --profile")
.option("--token <token>", "Authentication token for the launched target (same as --api-key)") .option("--token <token>", "Authentication token for the launched target (same as --api-key)")
.option("--api-key <key>", "Authentication token for the launched target") .option("--api-key <key>", "Authentication token for the launched target")
.option("--api-key-env <name>", "Read the launch token from an environment variable")
.option("--dry-run", "Show planned command and env keys without executing") .option("--dry-run", "Show planned command and env keys without executing")
.option("--json", "Return dry-run output in machine-readable format") .option("--json", "Return dry-run output in machine-readable format")
.allowUnknownOption(true) .allowUnknownOption(true)

View File

@@ -3,6 +3,108 @@ import { join, dirname } from "node:path";
import { resolveDataDir } from "./data-dir.mjs"; import { resolveDataDir } from "./data-dir.mjs";
const CONFIG_VERSION = 1; const CONFIG_VERSION = 1;
const KEYCHAIN_SERVICE = "omniroute-cli";
const KEYCHAIN_DISABLED = /^(1|true|yes|on)$/i.test(
String(process.env.OMNIROUTE_CONTEXT_KEYCHAIN_DISABLED || "")
);
// `keytar` is optional and native. Keeping it behind a small interface lets
// headless installs use the same CLI without requiring libsecret/Keychain at
// install time, while tests can inject a deterministic fake backend.
let keychainBackend = null;
let keychainOperational = true;
let warnedPlaintextFallback = false;
const credentialCache = new Map();
function isKeychainBackend(value) {
return (
value &&
typeof value.getPassword === "function" &&
typeof value.setPassword === "function" &&
typeof value.deletePassword === "function"
);
}
async function loadKeychainBackend() {
if (KEYCHAIN_DISABLED) return null;
try {
const imported = await import("keytar");
const candidate = isKeychainBackend(imported?.default) ? imported.default : imported;
return isKeychainBackend(candidate) ? candidate : null;
} catch {
// Native keychain modules are optional and commonly unavailable in
// containers. The secure file fallback is handled explicitly below.
return null;
}
}
function parseCredential(value) {
if (!value || typeof value !== "string") return null;
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const result = {};
if (typeof parsed.accessToken === "string" && parsed.accessToken) {
result.accessToken = parsed.accessToken;
}
if (typeof parsed.apiKey === "string" && parsed.apiKey) result.apiKey = parsed.apiKey;
return result.accessToken || result.apiKey ? result : null;
} catch {
// Older/externally managed entries may contain one raw token.
return { accessToken: value };
}
}
function credentialForContext(context) {
const ref = context && typeof context.credentialRef === "string" ? context.credentialRef : "";
return ref ? credentialCache.get(ref) || null : null;
}
function applyCachedCredential(context) {
const cached = credentialForContext(context);
if (!cached) return { ...context };
return { ...context, ...cached };
}
async function hydrateCredentialCache(cfg) {
if (!keychainBackend || !keychainOperational) return;
const contexts = cfg?.contexts || cfg?.profiles || {};
for (const context of Object.values(contexts)) {
const ref = context && typeof context === "object" ? context.credentialRef : null;
if (!ref || credentialCache.has(ref)) continue;
try {
const parsed = parseCredential(await keychainBackend.getPassword(KEYCHAIN_SERVICE, ref));
if (parsed) credentialCache.set(ref, parsed);
} catch {
keychainOperational = false;
break;
}
}
}
function warnPlaintextFallback() {
if (warnedPlaintextFallback) return;
warnedPlaintextFallback = true;
process.stderr.write(
"Warning: OS keychain unavailable; context credentials use config.json mode 0600 fallback.\n"
);
}
function readConfigFile() {
try {
if (!existsSync(configPath())) return defaultConfig();
const parsed = JSON.parse(readFileSync(configPath(), "utf8"));
return parsed && typeof parsed === "object" ? parsed : defaultConfig();
} catch {
return defaultConfig();
}
}
// Resolve keychain state before importing commands can call the synchronous
// compatibility helpers below. Credentials themselves stay in memory; only a
// stable reference is persisted in config.json when keytar is available.
keychainBackend = await loadKeychainBackend();
await hydrateCredentialCache(readConfigFile());
export function configPath() { export function configPath() {
return join(resolveDataDir(), "config.json"); return join(resolveDataDir(), "config.json");
@@ -19,14 +121,13 @@ function defaultConfig() {
} }
export function loadContexts() { export function loadContexts() {
try { return readConfigFile();
if (!existsSync(configPath())) return defaultConfig();
return JSON.parse(readFileSync(configPath(), "utf8"));
} catch {
return defaultConfig();
}
} }
/**
* Synchronous compatibility writer. New credential-bearing code should use
* `saveContextsSecure()` so tokens are moved to the OS keychain when possible.
*/
export function saveContexts(cfg) { export function saveContexts(cfg) {
const path = configPath(); const path = configPath();
mkdirSync(dirname(path), { recursive: true }); mkdirSync(dirname(path), { recursive: true });
@@ -36,6 +137,116 @@ export function saveContexts(cfg) {
} catch {} } catch {}
} }
/** Stable keychain reference; the reference itself is safe to persist in JSON. */
export function contextCredentialRef(name) {
return `${KEYCHAIN_SERVICE}:context:${encodeURIComponent(String(name))}`;
}
/** Expose a non-secret capability status for diagnostics and tests. */
export function getContextKeychainStatus() {
return {
available: Boolean(keychainBackend && keychainOperational),
disabled: KEYCHAIN_DISABLED,
fallback: !keychainBackend || !keychainOperational,
};
}
/**
* Store context credentials through keytar and write only a credentialRef to
* config.json. If keytar cannot be used, preserve the credential in the
* mode-0600 file and emit one explicit warning instead of breaking headless
* installs.
*/
export async function saveContextsSecure(cfg) {
const source = cfg && typeof cfg === "object" ? cfg : defaultConfig();
const next = JSON.parse(JSON.stringify(source));
next.version = next.version || CONFIG_VERSION;
if (!next.contexts && next.profiles) {
next.contexts = next.profiles;
delete next.profiles;
}
next.contexts = next.contexts || {};
for (const [name, raw] of Object.entries(next.contexts)) {
const context = raw && typeof raw === "object" ? raw : {};
const accessToken = typeof context.accessToken === "string" ? context.accessToken : "";
const apiKey = typeof context.apiKey === "string" ? context.apiKey : "";
const hasCredential = Boolean(accessToken || apiKey);
if (hasCredential && keychainBackend && keychainOperational) {
const ref =
typeof context.credentialRef === "string" && context.credentialRef
? context.credentialRef
: contextCredentialRef(name);
try {
await keychainBackend.setPassword(
KEYCHAIN_SERVICE,
ref,
JSON.stringify({
...(accessToken ? { accessToken } : {}),
...(apiKey ? { apiKey } : {}),
})
);
credentialCache.set(ref, {
...(accessToken ? { accessToken } : {}),
...(apiKey ? { apiKey } : {}),
});
context.credentialRef = ref;
delete context.accessToken;
delete context.apiKey;
} catch {
keychainOperational = false;
warnPlaintextFallback();
}
} else if (hasCredential) {
warnPlaintextFallback();
}
next.contexts[name] = context;
}
saveContexts(next);
return {
usedKeychain: Boolean(keychainBackend && keychainOperational),
config: next,
};
}
/** Remove the keychain entry associated with a context, if one exists. */
export async function deleteContextCredential(name, context) {
const cfg = loadContexts();
const candidate = context || cfg.contexts?.[name] || cfg.profiles?.[name] || {};
const ref = candidate.credentialRef || contextCredentialRef(name);
credentialCache.delete(ref);
if (!keychainBackend || !keychainOperational) return false;
try {
await keychainBackend.deletePassword(KEYCHAIN_SERVICE, ref);
return true;
} catch {
keychainOperational = false;
return false;
}
}
/** Explicitly migrate legacy plaintext context credentials. */
export async function migrateContextCredentials() {
const cfg = loadContexts();
const pending = Object.values(cfg.contexts || cfg.profiles || {}).some(
(context) => context?.accessToken || context?.apiKey
);
if (!pending) return { migrated: false, pending: false, ...getContextKeychainStatus() };
const result = await saveContextsSecure(cfg);
return { migrated: result.usedKeychain, pending: true, ...getContextKeychainStatus() };
}
/** Test-only backend injection; no secret is returned by this function. */
export async function setContextKeychainBackendForTests(backend) {
keychainBackend = isKeychainBackend(backend) ? backend : null;
keychainOperational = true;
credentialCache.clear();
await hydrateCredentialCache(readConfigFile());
}
/** /**
* Resolve the active context for a CLI invocation. * Resolve the active context for a CLI invocation.
* *
@@ -54,7 +265,13 @@ export function resolveActiveContext(overrideName) {
const contexts = cfg.contexts || cfg.profiles || {}; const contexts = cfg.contexts || cfg.profiles || {};
const name = overrideName || cfg.currentContext || cfg.activeProfile || "default"; const name = overrideName || cfg.currentContext || cfg.activeProfile || "default";
const found = contexts[name] || contexts.default; const found = contexts[name] || contexts.default;
if (found) return found; if (found) return applyCachedCredential(found);
if (cfg.baseUrl) return { baseUrl: cfg.baseUrl }; if (cfg.baseUrl) return { baseUrl: cfg.baseUrl };
return { baseUrl: `http://localhost:${process.env.PORT || "20128"}` }; return { baseUrl: `http://localhost:${process.env.PORT || "20128"}` };
} }
/** Async variant for callers that need to observe a just-created keychain entry. */
export async function resolveActiveContextAsync(overrideName) {
await hydrateCredentialCache(readConfigFile());
return resolveActiveContext(overrideName);
}

View File

@@ -1300,7 +1300,7 @@
"description": "Manage scoped CLI access tokens (remote mode)" "description": "Manage scoped CLI access tokens (remote mode)"
}, },
"configure": { "configure": {
"description": "Pick a provider+model from the active server and write a local CLI config" "description": "Pick a provider+model from the active server and configure a supported local CLI"
}, },
"launchCodex": { "launchCodex": {
"description": "Launch Codex CLI pointed at OmniRoute (local or remote VPS)" "description": "Launch Codex CLI pointed at OmniRoute (local or remote VPS)"

View File

@@ -1297,7 +1297,7 @@
"description": "Gerencia tokens de acesso CLI com escopo (modo remoto)" "description": "Gerencia tokens de acesso CLI com escopo (modo remoto)"
}, },
"configure": { "configure": {
"description": "Escolhe um provedor+modelo do servidor ativo e grava uma configuração de CLI local" "description": "Escolhe um provedor+modelo do servidor ativo e configura uma CLI local compatível"
}, },
"launchCodex": { "launchCodex": {
"description": "Inicia o Codex CLI apontando para o OmniRoute (local ou VPS remoto)" "description": "Inicia o Codex CLI apontando para o OmniRoute (local ou VPS remoto)"

View File

@@ -0,0 +1,109 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
import { join, dirname } from "node:path";
import { resolveDataDir } from "./data-dir.mjs";
const PREFERENCES_VERSION = 1;
const MAX_RECENT = 12;
const MAX_FAVORITES = 32;
export function modelPreferencesPath() {
return join(resolveDataDir(), "model-preferences.json");
}
function defaultPreferences() {
return { version: PREFERENCES_VERSION, targets: {}, contexts: {} };
}
export function loadModelPreferences() {
try {
const path = modelPreferencesPath();
if (!existsSync(path)) return defaultPreferences();
const parsed = JSON.parse(readFileSync(path, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return defaultPreferences();
}
return {
version: PREFERENCES_VERSION,
targets: parsed.targets && typeof parsed.targets === "object" ? parsed.targets : {},
contexts: parsed.contexts && typeof parsed.contexts === "object" ? parsed.contexts : {},
};
} catch {
return defaultPreferences();
}
}
function saveModelPreferences(preferences) {
const path = modelPreferencesPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(preferences, null, 2));
try {
chmodSync(path, 0o600);
} catch {
// Best effort on platforms without POSIX modes.
}
}
function normalizeIds(values) {
return [...new Set((Array.isArray(values) ? values : []).filter((id) => typeof id === "string"))];
}
function targetState(preferences, target, contextKey) {
const raw = contextKey
? preferences.contexts?.[contextKey]?.[target] ||
(contextKey === "default" ? preferences.targets?.[target] : undefined)
: preferences.targets?.[target];
return {
favorites: normalizeIds(raw?.favorites),
recent: normalizeIds(raw?.recent),
};
}
function writeTargetState(preferences, target, contextKey) {
if (!contextKey) {
preferences.targets[target] = targetState(preferences, target);
return preferences.targets[target];
}
preferences.contexts = preferences.contexts || {};
preferences.contexts[contextKey] = preferences.contexts[contextKey] || {};
preferences.contexts[contextKey][target] = targetState(preferences, target, contextKey);
return preferences.contexts[contextKey][target];
}
/** Rank catalog IDs with favorites first, then recent choices, then catalog order. */
export function rankPreferredModels(
target,
modelIds,
preferences = loadModelPreferences(),
contextKey = ""
) {
const ids = normalizeIds(modelIds);
const state = targetState(preferences, target, contextKey);
const available = new Set(ids);
const preferred = [...state.favorites, ...state.recent].filter((id) => available.has(id));
return [...new Set([...preferred, ...ids])];
}
/** Record a successful selection without storing server URLs or credentials. */
export function recordModelPreference(target, modelId, options = {}) {
if (!target || !modelId) return loadModelPreferences();
const preferences = loadModelPreferences();
const state = writeTargetState(preferences, target, options.context || "");
state.recent = [modelId, ...state.recent.filter((id) => id !== modelId)].slice(0, MAX_RECENT);
if (options.favorite) {
state.favorites = [modelId, ...state.favorites.filter((id) => id !== modelId)].slice(
0,
MAX_FAVORITES
);
}
if (options.unfavorite) state.favorites = state.favorites.filter((id) => id !== modelId);
saveModelPreferences(preferences);
return preferences;
}
export function getModelPreferenceState(
target,
preferences = loadModelPreferences(),
contextKey = ""
) {
return targetState(preferences, target, contextKey);
}

View File

@@ -18,6 +18,23 @@ There are also two launchers — `omniroute launch` (Claude Code) and
`omniroute launch-codex` (Codex) — that spawn the CLI with the right env injected, `omniroute launch-codex` (Codex) — that spawn the CLI with the right env injected,
without writing any config at all. without writing any config at all.
Provider onboarding is available from the same local/remote context. The
API-first commands below keep management authentication separate from provider
credentials and never print a credential in structured output:
```bash
omniroute providers add glm --credential-env GLM_API_KEY --name work
omniroute providers import ./providers.json --dry-run --json
omniroute providers auth openai
omniroute providers edit <connection-id> --default-model glm/glm-5.2
omniroute providers remove <connection-id> --yes
```
For scripts, prefer `--credential-stdin` or `--credential-env`; `--credential`
is retained for controlled local use. `providers remove` requires `--yes` on a
non-interactive terminal, and all five commands honor the active context or the
global `--base-url`/`--api-key` options.
For the one-time, hand-written base setup of the two richest integrations, see the For the one-time, hand-written base setup of the two richest integrations, see the
per-tool deep dives: per-tool deep dives:
@@ -37,23 +54,23 @@ Every command honours the **active context** (set with `omniroute connect`, see
with `--remote` (or an active remote context) it fetches the catalog from that with `--remote` (or an active remote context) it fetches the catalog from that
server and writes the config locally. server and writes the config locally.
| Command | Tool | What it writes | Key flags | Local vs remote | | Command | Tool | What it writes | Key flags | Local vs remote |
| -------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------- | | -------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
| `omniroute setup-codex` | OpenAI Codex CLI | `~/.codex/<name>.config.toml` — one profile per compatible text model (`codex --profile <name>`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--codex-home` | Both | | `omniroute setup-codex` | OpenAI Codex CLI | `~/.codex/<name>.config.toml` — one profile per compatible text model (`codex --profile <name>`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--codex-home` | Both |
| `omniroute setup-claude` | Claude Code | `~/.claude/profiles/<name>/settings.json` — one profile per matched model (`CLAUDE_CONFIG_DIR`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--claude-home` | Both | | `omniroute setup-claude` | Claude Code | `~/.claude/profiles/<name>/settings.json` — one profile per matched model (`CLAUDE_CONFIG_DIR`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--claude-home` | Both |
| `omniroute setup-opencode` | OpenCode (openai-compatible) | `~/.config/opencode/opencode.json``omniroute` provider with every catalog model (`opencode -m omniroute/<model>`) | `--remote` `--api-key` `--only` `--model` `--dry-run` `--port` | Both | | `omniroute setup-opencode` | OpenCode (openai-compatible) | `~/.config/opencode/opencode.json``omniroute` provider with every catalog model (`opencode -m omniroute/<model>`) | `--remote` `--api-key` `--only` `--model` `--dry-run` `--port` | Both |
| `omniroute setup-cline` | Cline | `~/.cline/data/{globalState,secrets}.json` (CLI mode) + prints VS Code extension settings | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--cline-dir` | Both | | `omniroute setup-cline` | Cline | `~/.cline/data/{globalState,secrets}.json` (CLI mode) + prints VS Code extension settings | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--cline-dir` | Both |
| `omniroute setup-kilo` | Kilo Code | `~/.local/share/kilo/auth.json` (CLI) + merges `kilocode.*` into VS Code `settings.json` if present | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--auth-path` `--vscode-settings` | Both | | `omniroute setup-kilo` | Kilo Code | `~/.local/share/kilo/auth.json` (CLI) + merges `kilocode.*` into VS Code `settings.json` if present | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--auth-path` `--vscode-settings` | Both |
| `omniroute setup-continue` | Continue / `cn` CLI | `~/.continue/config.yaml``provider: openai` models, key via `${{ secrets.OMNIROUTE_API_KEY }}` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both | | `omniroute setup-continue` | Continue / `cn` CLI | `~/.continue/config.yaml``provider: openai` models, key via `${{ secrets.OMNIROUTE_API_KEY }}` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-cursor` | Cursor | Nothing — prints the in-app steps (Cursor config is opaque SQLite) | `--remote` `--api-key` `--only` `--port` | Both | | `omniroute setup-cursor` | Cursor | Nothing — prints the in-app steps (Cursor config is opaque SQLite) | `--remote` `--api-key` `--only` `--port` | Both |
| `omniroute setup-roo` | Roo Code | `~/.omniroute/roo-settings.json` (import doc) + sets `roo-cline.autoImportSettingsPath` if a VS Code `settings.json` exists | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--import-path` `--vscode-settings` | Both | | `omniroute setup-roo` | Roo Code | `~/.omniroute/roo-settings.json` (import doc) + sets `roo-cline.autoImportSettingsPath` if a VS Code `settings.json` exists | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--import-path` `--vscode-settings` | Both |
| `omniroute setup-crush` | Crush | `~/.config/crush/crush.json``openai-compat` provider, key via `$OMNIROUTE_API_KEY` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both | | `omniroute setup-crush` | Crush | `~/.config/crush/crush.json``openai-compat` provider, key via `$OMNIROUTE_API_KEY` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-goose` | Goose | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER`/`OPENAI_HOST`/`GOOSE_MODEL`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both | | `omniroute setup-goose` | Goose | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER`/`OPENAI_HOST`/`GOOSE_MODEL`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-aider` | Aider | `~/.aider.conf.yml` (`openai-api-base` + `model: openai/<id>`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both | | `omniroute setup-aider` | Aider | `~/.aider.conf.yml` (`openai-api-base` + `model: openai/<id>`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-qwen` | Qwen Code | `~/.qwen/settings.json` — V4 `modelProviders.openai` array + `OMNIROUTE_API_KEY` in `~/.qwen/.env` | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` `--env-path` | Both | | `omniroute setup-qwen` | Qwen Code | `~/.qwen/settings.json` — V4 `modelProviders.openai` array + `OMNIROUTE_API_KEY` in `~/.qwen/.env` | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` `--env-path` | Both |
| `omniroute run <target>` | Runtime launch (generic) | Nothing — spawn `claude`/`codex` with the right env and args | `--remote` `--base-url` `--context` `--provider` `--model` `--api-key` `--dry-run` `--json` `--port` `--profile` `--token` | Both | | `omniroute run <target>` | Runtime launch (generic) | Nothing — spawn `claude`/`codex`/`aider`/`goose`/`opencode`/`qwen` with the right env and args; Qwen uses a temporary isolated home | `--remote` `--base-url` `--context` `--provider` `--model` `--api-key` `--api-key-env` `--dry-run` `--json` `--port` `--profile` `--token` | Both |
| `omniroute launch` | Claude Code | Nothing — spawns `claude` with `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` injected | `--remote` `--api-key` `--token` `--profile` `--port` | Both | | `omniroute launch` | Claude Code | Nothing — spawns `claude` with `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` injected | `--remote` `--api-key` `--token` `--profile` `--port` | Both |
| `omniroute launch-codex` | OpenAI Codex CLI | Nothing — spawns `codex` with the `omniroute` provider injected via `-c` flags | `--remote` `--api-key` `--profile` (`-p`) `--port` | Both | | `omniroute launch-codex` | OpenAI Codex CLI | Nothing — spawns `codex` with the `omniroute` provider injected via `-c` flags | `--remote` `--api-key` `--profile` (`-p`) `--port` | Both |
Notes on flags (verified in the command source): Notes on flags (verified in the command source):
@@ -76,6 +93,20 @@ Notes on flags (verified in the command source):
a profile written by `setup-claude` / `setup-codex`, plus pass-through args for a profile written by `setup-claude` / `setup-codex`, plus pass-through args for
the underlying `claude` / `codex` binary. the underlying `claude` / `codex` binary.
The interactive picker is also shared by the setup recipes:
```bash
# Pick from the active local or remote model catalog and configure the target.
omniroute configure claude
omniroute configure opencode --provider glm
omniroute configure qwen --model qwen/qwen3.8-max-preview --yes
```
`configure` currently delegates to the tested recipes for `codex`, `claude`,
`opencode`, `qwen`, `aider`, `goose`, `cline`, `continue`, and `kilo`. IDE-only,
MITM, and guide-only catalog entries remain explicit `setup-*`/manual flows and
are not presented as launchable targets.
> `setup-opencode` is the **lightweight openai-compatible** OpenCode integration. > `setup-opencode` is the **lightweight openai-compatible** OpenCode integration.
> There is also a richer plugin integration — `omniroute setup opencode` — which > There is also a richer plugin integration — `omniroute setup opencode` — which
> installs `@omniroute/opencode-plugin`. They are different commands; the table > installs `@omniroute/opencode-plugin`. They are different commands; the table
@@ -118,6 +149,10 @@ omniroute launch-codex # Codex CLI → local OmniRoute
omniroute launch-codex --profile glm52 omniroute launch-codex --profile glm52
omniroute run claude --model openai/gpt-5.4 omniroute run claude --model openai/gpt-5.4
omniroute run codex --model openai/gpt-5.4 --dry-run --json omniroute run codex --model openai/gpt-5.4 --dry-run --json
omniroute run aider --model glm/glm-5.2 -- --message "reply OK"
omniroute run goose --model glm/glm-5.2
omniroute run opencode --model glm/glm-5.2 -- run "reply OK"
omniroute run qwen --model glm/glm-5.2 -- -p "reply OK"
# Explicit command path: pass through whatever comes after -- # Explicit command path: pass through whatever comes after --
omniroute run claude -- --print-system-prompt "review this diff" omniroute run claude -- --print-system-prompt "review this diff"
@@ -202,6 +237,56 @@ outdated), `--apply` (install without prompting), `--changelog`, `--no-backup`,
--- ---
## Google Gemini CLI via `omniroute run gemini`
Contract verified against `@google/gemini-cli` 0.50.0: the CLI honors
`GOOGLE_GEMINI_BASE_URL` and issues `POST /v1beta/models/<model>:generateContent`
(and `:streamGenerateContent?alt=sse`) against it — exactly OmniRoute's native
Gemini surface (`/v1beta`). `omniroute run gemini` wires that automatically:
- `GOOGLE_GEMINI_BASE_URL` → the active OmniRoute base URL (root, no `/v1`);
- `GEMINI_API_KEY` → the resolved OmniRoute credential (option/env/context);
- a **temporary isolated `GEMINI_CLI_HOME`** whose `.gemini/settings.json`
selects `gemini-api-key` auth, so a stored Google OAuth session (Code Assist)
never overrides the OmniRoute-directed launch — removed after exit;
- `--model <id>` injection from `--provider`/`--model`.
```bash
omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "hello"
```
Gemini's workspace-trust guard still applies in headless mode — pass
`--skip-trust` (or trust the directory interactively) yourself; the launcher
deliberately does not bypass it. This launcher is distinct from the **ACP
registration** (`src/lib/acp/registry.ts`, `gemini --acp`), which remains the
agent-protocol integration for `/dashboard/acp-agents`.
---
## Real smoke sweep (opt-in)
Deterministic launch-plan regression runs in CI (`tests/unit/cli/run-command.test.ts`,
`tests/unit/cli/run-execution.test.ts`). To validate the REAL binaries against a REAL
OmniRoute server, an opt-in harness exists at
`tests/integration/upstream-cli-smoke.int.test.ts`. It never runs automatically
(every sub-test skips unless `RUN_CLI_SMOKE=1`), passes the credential by env-var
NAME (never by value), redacts key-shaped strings from any recorded output, skips
targets whose binary is not installed, and classifies failures as
auth / upstream / config instead of a bare boolean:
```bash
RUN_CLI_SMOKE=1 \
OMNIROUTE_SMOKE_BASE_URL="http://localhost:20128" \
OMNIROUTE_SMOKE_MODEL="<provider/model>" \
OMNIROUTE_SMOKE_API_KEY_ENV="OMNIROUTE_API_KEY" \
node --import tsx/esm --test tests/integration/upstream-cli-smoke.int.test.ts
```
Optional: `OMNIROUTE_SMOKE_TARGETS="codex,opencode,qwen"` restricts the sweep;
`OMNIROUTE_SMOKE_TIMEOUT_MS` overrides the 120s per-target timeout.
---
## See also ## See also
- [Claude Code configuration](./CLAUDE-CODE-CONFIGURATION.md) — the deeper Claude Code guide - [Claude Code configuration](./CLAUDE-CODE-CONFIGURATION.md) — the deeper Claude Code guide

View File

@@ -10,6 +10,15 @@ Complete guide for using the Codex CLI pointed at OmniRoute as an OpenAI-compati
--- ---
> **TOML is the only effective format.** Modern Codex reads `~/.codex/config.toml`
> exclusively (verified against codex-cli 0.147.0: `codex --help` documents
> `-c/--config` overrides "loaded from `~/.codex/config.toml`"). The old
> `~/.codex/config.yaml` belonged to the legacy npm CLI and is silently ignored.
> The dashboard generator (`/api/cli-tools/apply`, tool `codex`) writes TOML with a
> conservative merge — existing keys and other provider blocks are preserved, the
> API key stays in `OMNIROUTE_API_KEY` (never in the file), and a leftover legacy
> `config.yaml` is reported as a migration note without being touched.
## Ready-to-paste config.toml ## Ready-to-paste config.toml
Replace `<YOUR_HOST>` and `<YOUR_KEY>` with your values: Replace `<YOUR_HOST>` and `<YOUR_KEY>` with your values:

View File

@@ -271,8 +271,16 @@ omniroute configure codex
# non-interactive # non-interactive
omniroute configure codex --provider glm --model glm/glm-5.2 --name glm52 omniroute configure codex --provider glm --model glm/glm-5.2 --name glm52
# keep a frequently used model at the top of the interactive picker
omniroute configure codex --provider glm --model glm/glm-5.2 --favorite --yes
``` ```
The picker keeps only model IDs (never URLs or credentials) in the local
`model-preferences.json` file, scoped by context and CLI target. Favorites are
shown before recent selections; use `--unfavorite` to remove a selected model
from that context/target list.
The written profile references the inference key by env var The written profile references the inference key by env var
(`OMNIROUTE_API_KEY`) — the secret is never written to disk. For the one-time (`OMNIROUTE_API_KEY`) — the secret is never written to disk. For the one-time
base Codex setup (the `[model_providers.omniroute]` block), see base Codex setup (the `[model_providers.omniroute]` block), see
@@ -360,14 +368,20 @@ omniroute contexts remove stg --yes
> revoke the token on the server with `omniroute tokens revoke <id>` to actually > revoke the token on the server with `omniroute tokens revoke <id>` to actually
> kill access. > kill access.
**Export / import** contexts (e.g. to move them between machines — secrets included, **Export / import** contexts (e.g. to move them between machines). New contexts persist
so handle the file carefully): only a keychain reference; credentials are not copied into the export when the OS
keychain is available:
```bash ```bash
omniroute contexts export --out contexts.json # default: stdout omniroute contexts export --out contexts.json # default: stdout
omniroute contexts import contexts.json # overwrite; --merge to keep existing omniroute contexts import contexts.json # overwrite; --merge to keep existing
omniroute contexts migrate --yes # move legacy plaintext tokens to keychain
``` ```
On headless systems without a usable OS keychain, the CLI falls back to
`config.json` with mode `0600` and prints a one-time warning. Treat exports from
that fallback (and any legacy config before migration) as secret material.
--- ---
## Quick end-to-end check ## Quick end-to-end check
@@ -409,8 +423,12 @@ omniroute contexts remove 192-168-0-15 --yes # drop the local context (even if
- `omniroute connect` reuses the login brute-force lockout + audit logging. - `omniroute connect` reuses the login brute-force lockout + audit logging.
- Prefer HTTPS or a Tailnet for the transport; a bare host defaults to `http://` - 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. for LAN/Tailscale convenience — pass a full `https://…` URL for TLS.
- The local context file is `~/.omniroute/config.json` (`chmod 600`); tokens are - The preferred local context file is `~/.omniroute/config.json` (`chmod 600`)
never printed in logs (masked to a prefix). containing only a `credentialRef`; the token itself is stored in the OS
keychain (`keytar`) and is never printed in logs. Headless installs without a
working native keychain use the same `0600` file as an explicit fallback and
emit a warning once. Use `omniroute contexts migrate --yes` after installing a
keychain backend.
--- ---

View File

@@ -83,6 +83,17 @@ actually meant; `OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true` does the same for
the server. See the server. See
[Docker Guide → Configuring host CLI tools](../guides/DOCKER_GUIDE.md#configuring-host-cli-tools-when-omniroute-runs-in-docker). [Docker Guide → Configuring host CLI tools](../guides/DOCKER_GUIDE.md#configuring-host-cli-tools-when-omniroute-runs-in-docker).
The dashboard's **apply endpoint** (`POST /api/cli-tools/apply`) enforces the
same guard: in a container, a write whose target is not bind-mounted from the
host answers **`422`** with `containerEphemeralTarget: true`, the safe error
text and a `hostSetupCommand` (e.g. `omniroute setup-opencode`) to run on the
host instead — nothing is written. `dryRun: true` keeps working in container
mode and returns the generated content + target path without touching disk, so
you can preview from the dashboard and apply on the host. This behavior is
intentional and regression-guarded by
`tests/unit/api/cli-tools/apply-container-guard.test.ts` — never "fix" a 422
by removing the guard.
--- ---
## Source of Truth ## Source of Truth
@@ -102,6 +113,26 @@ Each entry has these fields (defined in `src/shared/schemas/cliCatalog.ts`):
Entries with `baseUrlSupport: "none"` are **not shown** in the dashboard pages — they are registered in the MITM backlog for plan 11 (see `_tasks/features-v3.8.6/refactorpages/_orchestration/_plan11-mitm-backlog.md`). Entries with `baseUrlSupport: "none"` are **not shown** in the dashboard pages — they are registered in the MITM backlog for plan 11 (see `_tasks/features-v3.8.6/refactorpages/_orchestration/_plan11-mitm-backlog.md`).
### Capability tiers (cataloged × detectable × configurable × launchable)
Not every cataloged tool is detectable, configurable or launchable. Each tier has one
declaring source, and a drift test keeps them aligned:
| Tier | Meaning | Declared in |
| ---------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------- |
| **Cataloged** | Appears in the dashboard catalog (name, vendor, docs, config type) | `src/shared/constants/cliTools.ts` (`CLI_TOOLS`) |
| **Detectable** | Binary/config detection, health checks, config paths | `src/shared/services/cliRuntime.ts` (`CLI_TOOLS` runtime catalog) |
| **Configurable** | Supported by `omniroute configure <cli>` (setup recipe exists) | `bin/cli/cli-manifest.mjs` (`configure: true`) |
| **Launchable** | Supported by `omniroute run <target>` (env/args injection defined) | `bin/cli/cli-manifest.mjs` (`run: true`) |
`bin/cli/cli-manifest.mjs` is the canonical executable manifest for the CLI command
surfaces: `run`, `configure` and the shell-completion generators all derive their
target lists, alias resolution (for example `kilocode`/`kilo-code`/`kilo_cli``kilo`)
and `--model` flag wiring from it. The drift guard
`tests/unit/cli/cli-manifest-drift.test.ts` asserts that the manifest, the runtime
catalog, the UI catalog and every consumer surface stay in sync — a target added to
one surface without the others fails the suite instead of drifting silently.
--- ---
## 1. CLI Code's Catalog (25 tools) ## 1. CLI Code's Catalog (25 tools)
@@ -384,14 +415,26 @@ Use the unified Anthropic gateway root for Claude Code. Do not append `/v1` here
#### OpenAI Codex #### OpenAI Codex
Modern Codex (v0.137+) reads `~/.codex/config.toml` only — the old
`config.yaml` belongs to the legacy npm CLI and is silently ignored. The API
key stays in the `OMNIROUTE_API_KEY` environment variable (`env_key`), never
inside the file:
```bash ```bash
mkdir -p ~/.codex && cat > ~/.codex/config.yaml << EOF mkdir -p ~/.codex && cat > ~/.codex/config.toml << EOF
model: auto model_provider = "omniroute"
apiKey: sk-your-omniroute-key
apiBaseUrl: http://localhost:20128/v1 [model_providers.omniroute]
name = "OmniRoute"
base_url = "http://localhost:20128/v1"
env_key = "OMNIROUTE_API_KEY"
requires_openai_auth = false
EOF EOF
export OMNIROUTE_API_KEY="sk-your-omniroute-key"
``` ```
Full reference (profiles, `wire_api`, context windows): [CODEX-CLI-CONFIGURATION.md](../guides/CODEX-CLI-CONFIGURATION.md).
**Test:** `codex "what is 2+2?"` **Test:** `codex "what is 2+2?"`
--- ---
@@ -613,10 +656,19 @@ omniroute providers list --json
omniroute providers test <id|name> # Test one configured connection omniroute providers test <id|name> # Test one configured connection
omniroute providers test-all # Test every active connection omniroute providers test-all # Test every active connection
omniroute providers validate # Local-only structural validation omniroute providers validate # Local-only structural validation
omniroute providers add <provider> --credential-env PROVIDER_KEY
omniroute providers import ./providers.json --dry-run --json
omniroute providers auth <provider> # Existing OAuth flow
omniroute providers edit <id|name> --default-model <model>
omniroute providers remove <id|name> --yes
``` ```
> `providers available` reads the OmniRoute catalog; `providers list/test/test-all/validate` `providers add/import/auth/edit/remove` are API-first and therefore work against
> read the local SQLite database directly and do not require the server to be running. the active local or remote context. Credential input should use
`--credential-stdin` or `--credential-env`; `--dry-run --json` reports only
redacted presence/shape. `providers available` reads the OmniRoute catalog;
`providers list/test/test-all/validate` retain their local SQLite behavior and
do not require the server to be running.
### Recovery & Reset ### Recovery & Reset

View File

@@ -474,6 +474,7 @@ detection above).
| `OMNIROUTE_ISSUE_AGENT_ENABLED` | `false` | `src/app/api/issue-agent/runs/route.ts` | Enables the offline/local Issue Agent recorded-triage endpoint. Leave disabled unless explicitly running local recorded-triage workflows. | | `OMNIROUTE_ISSUE_AGENT_ENABLED` | `false` | `src/app/api/issue-agent/runs/route.ts` | Enables the offline/local Issue Agent recorded-triage endpoint. Leave disabled unless explicitly running local recorded-triage workflows. |
| `OMNIROUTE_ISSUE_AGENT_TIMEOUT_MS` | _(unset)_ | `src/lib/issueAgent/execution.ts` | Timeout (ms) for a single Issue Agent recorded-triage run. Clamped to an internal maximum; falls back to the built-in default when unset or invalid. | | `OMNIROUTE_ISSUE_AGENT_TIMEOUT_MS` | _(unset)_ | `src/lib/issueAgent/execution.ts` | Timeout (ms) for a single Issue Agent recorded-triage run. Clamped to an internal maximum; falls back to the built-in default when unset or invalid. |
| `OMNIROUTE_CONTEXT` | _(active context)_ | `bin/cli/program.mjs`, `bin/cli/api.mjs` | CLI remote-mode context/profile for `omniroute` commands; overrides the active context in the local contexts store. Equivalent to `--context <name>`. | | `OMNIROUTE_CONTEXT` | _(active context)_ | `bin/cli/program.mjs`, `bin/cli/api.mjs` | CLI remote-mode context/profile for `omniroute` commands; overrides the active context in the local contexts store. Equivalent to `--context <name>`. |
| `OMNIROUTE_CONTEXT_KEYCHAIN_DISABLED` | `0` | `bin/cli/contexts.mjs` | Disable the optional `keytar` OS-keychain backend for CLI context credentials. When enabled, credentials remain in `config.json` mode `0600` and the CLI emits a one-time fallback warning; intended for deliberate headless/container operation. |
| `OMNIROUTE_MCP_ENFORCE_SCOPES` | `true` | `open-sse/mcp-server/server.ts` | Enforce scope-based access control on MCP tool calls. | | `OMNIROUTE_MCP_ENFORCE_SCOPES` | `true` | `open-sse/mcp-server/server.ts` | Enforce scope-based access control on MCP tool calls. |
| `OMNIROUTE_MCP_SCOPES` | _(all)_ | `open-sse/mcp-server/server.ts` | Comma-separated scopes: `admin`, `combos`, `health`, `models`, `routing`, `budget`, `metrics`, `pricing`, `memory`, `skills`. | | `OMNIROUTE_MCP_SCOPES` | _(all)_ | `open-sse/mcp-server/server.ts` | Comma-separated scopes: `admin`, `combos`, `health`, `models`, `routing`, `budget`, `metrics`, `pricing`, `memory`, `skills`. |
| `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | `false` | `open-sse/mcp-server/descriptionCompressor.ts` | Compress MCP tool descriptions before serializing the manifest. Enable values: `1`, `true`, `on`. | | `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | `false` | `open-sse/mcp-server/descriptionCompressor.ts` | Compress MCP tool descriptions before serializing the manifest. Enable values: `1`, `true`, `on`. |

View File

@@ -117,15 +117,24 @@ async function applyOmniglyph(
let outBody: Record<string, unknown>; let outBody: Record<string, unknown>;
try { try {
const encoded = new TextEncoder().encode(JSON.stringify(body)); const encoded = new TextEncoder().encode(JSON.stringify(body));
const result = // Branch explicitly so TS narrows each transformer's return type:
wireFormat === "claude" // the Anthropic wrapper reports `applied`, the OpenAI ones `info.compressed`.
? await transformAnthropicMessages({ body: encoded, model }) let applied: boolean;
: wireFormat === "openai" let transformed: { body: Uint8Array; info: { compressed: boolean; reason?: string } };
if (wireFormat === "claude") {
const result = await transformAnthropicMessages({ body: encoded, model });
transformed = result;
applied = result.applied;
} else {
const result =
wireFormat === "openai"
? await transformOpenAIChatCompletions(encoded) ? await transformOpenAIChatCompletions(encoded)
: await transformOpenAIResponses(encoded); : await transformOpenAIResponses(encoded);
const applied = wireFormat === "claude" ? result.applied : result.info.compressed; transformed = result;
if (!applied) return skip(body, result.info?.reason ?? "not_profitable"); applied = result.info.compressed;
outBody = JSON.parse(new TextDecoder().decode(result.body)) as Record<string, unknown>; }
if (!applied) return skip(body, transformed.info?.reason ?? "not_profitable");
outBody = JSON.parse(new TextDecoder().decode(transformed.body)) as Record<string, unknown>;
} catch { } catch {
// Fail-open: qualquer erro no encode/transform/decode (ex.: corpo não serializável, // Fail-open: qualquer erro no encode/transform/decode (ex.: corpo não serializável,
// render PNG estourando, JSON decodificado malformado) vira skip, nunca propaga. // render PNG estourando, JSON decodificado malformado) vira skip, nunca propaga.

View File

@@ -12,6 +12,62 @@ import {
} from "@/lib/usage/tokenAccounting"; } from "@/lib/usage/tokenAccounting";
import { FORMATS } from "../translator/formats.ts"; import { FORMATS } from "../translator/formats.ts";
/** Nested `*_tokens_details` containers ({ cached_tokens, reasoning_tokens, … }). */
interface UsageTokenDetail {
cached_tokens?: number;
reasoning_tokens?: number;
thinking_tokens?: number;
[field: string]: unknown;
}
/**
* Loosely-shaped usage object accepted from any provider wire format.
* Declared fields cover the numeric counters this module reads/writes;
* everything else passes through untouched via the index signature.
*/
export interface UsageLike {
estimated?: boolean;
input_tokens?: number;
output_tokens?: number;
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
cached_tokens?: number;
no_cache_tokens?: number;
reasoning_tokens?: number;
cost_in_usd_ticks?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
prompt_cache_hit_tokens?: number;
prompt_cache_miss_tokens?: number;
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
cachedContentTokenCount?: number;
thoughtsTokenCount?: number;
context_budget_input_tokens?: number;
context_budget_prompt_tokens?: number;
context_budget_total_tokens?: number;
prompt_tokens_details?: UsageTokenDetail;
input_tokens_details?: UsageTokenDetail;
completion_tokens_details?: UsageTokenDetail;
output_tokens_details?: UsageTokenDetail;
[field: string]: unknown;
}
/** SSE/JSON chunk shapes this module inspects for embedded usage containers. */
interface UsagePayloadLike {
type?: string;
done?: boolean;
prompt_eval_count?: number;
eval_count?: number;
usage?: UsageLike;
usageMetadata?: UsageLike;
message?: { usage?: UsageLike; [field: string]: unknown };
response?: { usage?: UsageLike; usageMetadata?: UsageLike; [field: string]: unknown };
[field: string]: unknown;
}
// ANSI color codes // ANSI color codes
export const COLORS = { export const COLORS = {
reset: "\x1b[0m", reset: "\x1b[0m",
@@ -127,7 +183,7 @@ function getTimeString() {
* @param {object} usage - Usage object (supported format) * @param {object} usage - Usage object (supported format)
* @returns {object} Usage with context_budget_* fields added (metering fields unchanged) * @returns {object} Usage with context_budget_* fields added (metering fields unchanged)
*/ */
export function addBufferToUsage(usage) { export function addBufferToUsage(usage: UsageLike | null | undefined) {
if (!usage || typeof usage !== "object") return usage; if (!usage || typeof usage !== "object") return usage;
// Heuristic estimates (web/cookie providers with no upstream metering) should // Heuristic estimates (web/cookie providers with no upstream metering) should
@@ -164,7 +220,7 @@ export function addBufferToUsage(usage) {
return result; return result;
} }
export function filterUsageForFormat(usage, targetFormat) { export function filterUsageForFormat(usage: UsageLike | null | undefined, targetFormat: string) {
if (!usage || typeof usage !== "object") return usage; if (!usage || typeof usage !== "object") return usage;
// Cross-map between Claude-style and OpenAI-style field names before filtering. // Cross-map between Claude-style and OpenAI-style field names before filtering.
@@ -211,8 +267,8 @@ export function filterUsageForFormat(usage, targetFormat) {
} }
// Helper to pick only defined fields from usage // Helper to pick only defined fields from usage
const pickFields = (fields) => { const pickFields = (fields: string[]) => {
const filtered = {}; const filtered: Record<string, unknown> = {};
for (const field of fields) { for (const field of fields) {
if (convertedUsage[field] !== undefined) { if (convertedUsage[field] !== undefined) {
filtered[field] = convertedUsage[field]; filtered[field] = convertedUsage[field];
@@ -222,7 +278,7 @@ export function filterUsageForFormat(usage, targetFormat) {
}; };
// Define allowed fields for each format // Define allowed fields for each format
const formatFields = { const formatFields: Record<string, string[]> = {
[FORMATS.CLAUDE]: [ [FORMATS.CLAUDE]: [
"input_tokens", "input_tokens",
"output_tokens", "output_tokens",
@@ -312,7 +368,7 @@ const REMOTE_CONTEXT_REFERENCE_KEYS = new Set([
"videoUrl", "videoUrl",
]); ]);
function hasValue(value): boolean { function hasValue(value: unknown): boolean {
if (value === null || value === undefined || value === false) return false; if (value === null || value === undefined || value === false) return false;
if (typeof value === "string") return value.trim().length > 0; if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.length > 0; if (Array.isArray(value)) return value.length > 0;
@@ -320,7 +376,7 @@ function hasValue(value): boolean {
return true; return true;
} }
function hasRemoteContextReference(value, depth = 0): boolean { function hasRemoteContextReference(value: unknown, depth = 0): boolean {
if (!value || typeof value !== "object" || depth > 8) return false; if (!value || typeof value !== "object" || depth > 8) return false;
if (Array.isArray(value)) { if (Array.isArray(value)) {
@@ -338,7 +394,7 @@ function hasRemoteContextReference(value, depth = 0): boolean {
return false; return false;
} }
function getSerializedBodyBytes(body): number | null { function getSerializedBodyBytes(body: unknown): number | null {
if (!body || typeof body !== "object" || hasRemoteContextReference(body)) return null; if (!body || typeof body !== "object" || hasRemoteContextReference(body)) return null;
try { try {
const serialized = JSON.stringify(body); const serialized = JSON.stringify(body);
@@ -349,7 +405,7 @@ function getSerializedBodyBytes(body): number | null {
} }
} }
function tokenNumber(value): number { function tokenNumber(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0; return typeof value === "number" && Number.isFinite(value) ? value : 0;
} }
@@ -357,7 +413,7 @@ function tokenNumber(value): number {
* Return true when a provider-reported input count is plausible for this request. * Return true when a provider-reported input count is plausible for this request.
* `null`/unserializable bodies and server-side context references fail open. * `null`/unserializable bodies and server-side context references fail open.
*/ */
export function isInputTokenCountPlausible(inputTokens, body): boolean { export function isInputTokenCountPlausible(inputTokens: unknown, body: unknown): boolean {
if (typeof inputTokens !== "number" || !Number.isFinite(inputTokens) || inputTokens < 0) { if (typeof inputTokens !== "number" || !Number.isFinite(inputTokens) || inputTokens < 0) {
return false; return false;
} }
@@ -368,7 +424,7 @@ export function isInputTokenCountPlausible(inputTokens, body): boolean {
return inputTokens <= maximum; return inputTokens <= maximum;
} }
function resolveUsageFormat(usage, targetFormat) { function resolveUsageFormat(usage: UsageLike | null | undefined, targetFormat: string | null) {
if (targetFormat === FORMATS.CLAUDE) return FORMATS.CLAUDE; if (targetFormat === FORMATS.CLAUDE) return FORMATS.CLAUDE;
if (targetFormat === FORMATS.GEMINI || targetFormat === FORMATS.ANTIGRAVITY) { if (targetFormat === FORMATS.GEMINI || targetFormat === FORMATS.ANTIGRAVITY) {
return FORMATS.GEMINI; return FORMATS.GEMINI;
@@ -391,7 +447,7 @@ function resolveUsageFormat(usage, targetFormat) {
return FORMATS.OPENAI; return FORMATS.OPENAI;
} }
function getReportedInputTokens(usage, format): number { function getReportedInputTokens(usage: UsageLike, format: string): number {
if (format === FORMATS.CLAUDE) { if (format === FORMATS.CLAUDE) {
return ( return (
tokenNumber(usage.input_tokens) + tokenNumber(usage.input_tokens) +
@@ -408,7 +464,7 @@ function getReportedInputTokens(usage, format): number {
return tokenNumber(usage.prompt_tokens ?? usage.input_tokens); return tokenNumber(usage.prompt_tokens ?? usage.input_tokens);
} }
function clearCachedTokenDetail(value) { function clearCachedTokenDetail<T extends UsageTokenDetail | null | undefined>(value: T): T {
if (!value || typeof value !== "object" || Array.isArray(value)) return value; if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const result = { ...value }; const result = { ...value };
if (result.cached_tokens !== undefined) result.cached_tokens = 0; if (result.cached_tokens !== undefined) result.cached_tokens = 0;
@@ -419,7 +475,11 @@ function clearCachedTokenDetail(value) {
* Replace only physically implausible provider input/cache usage with the local * Replace only physically implausible provider input/cache usage with the local
* request estimate. Valid usage is returned by reference and remains untouched. * request estimate. Valid usage is returned by reference and remains untouched.
*/ */
export function sanitizeProviderUsageForRequest(usage, body, targetFormat = null) { export function sanitizeProviderUsageForRequest(
usage: UsageLike | null | undefined,
body: unknown,
targetFormat: string | null = null
) {
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return usage; if (!usage || typeof usage !== "object" || Array.isArray(usage)) return usage;
const format = resolveUsageFormat(usage, targetFormat); const format = resolveUsageFormat(usage, targetFormat);
@@ -475,12 +535,20 @@ export function sanitizeProviderUsageForRequest(usage, body, targetFormat = null
* Sanitize the usage container used by native provider responses/SSE events. * Sanitize the usage container used by native provider responses/SSE events.
* Returns true only when the payload was changed and must be re-serialized. * Returns true only when the payload was changed and must be re-serialized.
*/ */
export function sanitizeUsagePayloadForRequest(payload, body, targetFormat = null): boolean { export function sanitizeUsagePayloadForRequest(
payload: UsagePayloadLike | null | undefined,
body: unknown,
targetFormat: string | null = null
): boolean {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
const replaceUsage = (owner, key, format) => { const replaceUsage = (
owner: Record<string, unknown> | null | undefined,
key: string,
format: string | null
) => {
if (!owner || typeof owner !== "object" || !owner[key]) return false; if (!owner || typeof owner !== "object" || !owner[key]) return false;
const sanitized = sanitizeProviderUsageForRequest(owner[key], body, format); const sanitized = sanitizeProviderUsageForRequest(owner[key] as UsageLike, body, format);
if (sanitized === owner[key]) return false; if (sanitized === owner[key]) return false;
owner[key] = sanitized; owner[key] = sanitized;
return true; return true;
@@ -511,11 +579,11 @@ export function sanitizeUsagePayloadForRequest(payload, body, targetFormat = nul
/** /**
* Normalize usage object - ensure all values are valid numbers * Normalize usage object - ensure all values are valid numbers
*/ */
export function normalizeUsage(usage) { export function normalizeUsage(usage: UsageLike | null | undefined) {
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null; if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null;
const normalized: Record<string, number> = {}; const normalized: Record<string, number> = {};
const assignNumber = (key, value) => { const assignNumber = (key: string, value: unknown) => {
if (value === undefined || value === null) return; if (value === undefined || value === null) return;
const numeric = Number(value); const numeric = Number(value);
if (Number.isFinite(numeric)) normalized[key] = numeric; if (Number.isFinite(numeric)) normalized[key] = numeric;
@@ -551,7 +619,7 @@ export function normalizeUsage(usage) {
* Valid = has at least one token field with value > 0 * Valid = has at least one token field with value > 0
* Invalid = empty object {}, null, undefined, no token fields, or all zeros * Invalid = empty object {}, null, undefined, no token fields, or all zeros
*/ */
export function hasValidUsage(usage) { export function hasValidUsage(usage: UsageLike | null | undefined) {
if (!usage || typeof usage !== "object") return false; if (!usage || typeof usage !== "object") return false;
// Check for known token fields with value > 0 // Check for known token fields with value > 0
@@ -577,7 +645,7 @@ export function hasValidUsage(usage) {
/** /**
* Extract usage from supported formats (Claude, OpenAI, Gemini, Responses API) * Extract usage from supported formats (Claude, OpenAI, Gemini, Responses API)
*/ */
export function extractUsage(chunk) { export function extractUsage(chunk: UsagePayloadLike | null | undefined) {
if (!chunk || typeof chunk !== "object") return null; if (!chunk || typeof chunk !== "object") return null;
// Claude/Antigravity streaming: message_start event carries INPUT tokens // Claude/Antigravity streaming: message_start event carries INPUT tokens
@@ -715,7 +783,7 @@ const CHARS_PER_TOKEN_SCHEMA = 6; // ~6 chars/token for JSON schemas (more verbo
* @param {string} text - Text to estimate tokens for * @param {string} text - Text to estimate tokens for
* @returns {number} Estimated token count * @returns {number} Estimated token count
*/ */
function estimateTokenCount(text) { function estimateTokenCount(text: unknown) {
if (!text || typeof text !== "string") return 0; if (!text || typeof text !== "string") return 0;
// Count CJK ideographs separately — each is roughly 1 token // Count CJK ideographs separately — each is roughly 1 token
@@ -743,22 +811,23 @@ function estimateTokenCount(text) {
* for more accurate estimation since JSON schemas are more verbose but * for more accurate estimation since JSON schemas are more verbose but
* compress into fewer tokens than plain text. * compress into fewer tokens than plain text.
*/ */
export function estimateInputTokens(body) { export function estimateInputTokens(body: unknown) {
if (!body || typeof body !== "object") return 0; if (!body || typeof body !== "object") return 0;
const record = body as Record<string, unknown>;
try { try {
let toolTokens = 0; let toolTokens = 0;
let messageTokens = 0; let messageTokens = 0;
// Separate tool definitions from the rest of the body // Separate tool definitions from the rest of the body
if (body.tools && Array.isArray(body.tools)) { if (record.tools && Array.isArray(record.tools)) {
const toolStr = JSON.stringify(body.tools); const toolStr = JSON.stringify(record.tools);
toolTokens = Math.ceil(toolStr.length / CHARS_PER_TOKEN_SCHEMA); toolTokens = Math.ceil(toolStr.length / CHARS_PER_TOKEN_SCHEMA);
// Estimate messages without tools // Estimate messages without tools
const { tools, ...bodyWithoutTools } = body; const { tools, ...bodyWithoutTools } = record;
messageTokens = estimateTokenCount(JSON.stringify(bodyWithoutTools)); messageTokens = estimateTokenCount(JSON.stringify(bodyWithoutTools));
} else { } else {
messageTokens = estimateTokenCount(JSON.stringify(body)); messageTokens = estimateTokenCount(JSON.stringify(record));
} }
return messageTokens + toolTokens; return messageTokens + toolTokens;
@@ -772,7 +841,7 @@ export function estimateInputTokens(body) {
* Estimate output tokens from content length. * Estimate output tokens from content length.
* Uses improved heuristic when possible, falls back to length-based estimation. * Uses improved heuristic when possible, falls back to length-based estimation.
*/ */
export function estimateOutputTokens(contentLength) { export function estimateOutputTokens(contentLength: number | null | undefined) {
if (!contentLength || contentLength <= 0) return 0; if (!contentLength || contentLength <= 0) return 0;
// When we only have a character count, use 4 chars/token with sub-word correction // When we only have a character count, use 4 chars/token with sub-word correction
return Math.max(1, Math.ceil(contentLength / 3.5)); return Math.max(1, Math.ceil(contentLength / 3.5));
@@ -784,7 +853,7 @@ export function estimateOutputTokens(contentLength) {
* @param {number} outputTokens - Output/completion tokens * @param {number} outputTokens - Output/completion tokens
* @param {string} targetFormat - Target format from FORMATS * @param {string} targetFormat - Target format from FORMATS
*/ */
export function formatUsage(inputTokens, outputTokens, targetFormat) { export function formatUsage(inputTokens: number, outputTokens: number, targetFormat: string) {
// Claude format uses input_tokens/output_tokens // Claude format uses input_tokens/output_tokens
if (targetFormat === FORMATS.CLAUDE) { if (targetFormat === FORMATS.CLAUDE) {
return addBufferToUsage({ return addBufferToUsage({
@@ -809,7 +878,11 @@ export function formatUsage(inputTokens, outputTokens, targetFormat) {
* @param {number} contentLength - Content length for output token estimation * @param {number} contentLength - Content length for output token estimation
* @param {string} targetFormat - Target format from FORMATS constant * @param {string} targetFormat - Target format from FORMATS constant
*/ */
export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI) { export function estimateUsage(
body: unknown,
contentLength: number | null | undefined,
targetFormat: string = FORMATS.OPENAI
) {
return formatUsage(estimateInputTokens(body), estimateOutputTokens(contentLength), targetFormat); return formatUsage(estimateInputTokens(body), estimateOutputTokens(contentLength), targetFormat);
} }
@@ -817,8 +890,8 @@ export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI
* Log usage with cache info (green color) * Log usage with cache info (green color)
*/ */
export function logUsage( export function logUsage(
provider, provider: string | null | undefined,
usage, usage: UsageLike | null | undefined,
model: string | null = null, model: string | null = null,
connectionId: string | null = null, connectionId: string | null = null,
apiKeyInfo = null apiKeyInfo = null

View File

@@ -3,9 +3,9 @@ import { z } from "zod";
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import os from "node:os";
import { generateConfig } from "@/lib/cli-helper/config-generator"; import { generateConfig } from "@/lib/cli-helper/config-generator";
import { guardCliConfigWrite } from "@/lib/api/cliConfigWriteGuard"; import { guardCliConfigWrite } from "@/lib/api/cliConfigWriteGuard";
import { getCliPrimaryConfigPath, normalizeCliToolId } from "@/shared/services/cliRuntime";
const applySchema = z.object({ const applySchema = z.object({
toolId: z.string().min(1), toolId: z.string().min(1),
@@ -15,21 +15,13 @@ const applySchema = z.object({
dryRun: z.boolean().optional(), dryRun: z.boolean().optional(),
}); });
const TOOL_CONFIG_PATHS: Record<string, string> = {
claude: path.join(os.homedir(), ".claude", "settings.json"),
codex: path.join(os.homedir(), ".codex", "config.yaml"),
cline: path.join(os.homedir(), ".cline", "data", "globalState.json"),
kilocode: path.join(os.homedir(), ".config", "kilocode", "settings.json"),
continue: path.join(os.homedir(), ".continue", "config.yaml"),
};
/** The host-side command that does the same job when OmniRoute is containerised. */ /** The host-side command that does the same job when OmniRoute is containerised. */
const HOST_SETUP_COMMANDS: Record<string, string> = { const HOST_SETUP_COMMANDS: Record<string, string> = {
claude: "omniroute setup-claude", claude: "omniroute setup-claude",
codex: "omniroute setup-codex", codex: "omniroute setup-codex",
opencode: "omniroute setup-opencode", opencode: "omniroute setup-opencode",
cline: "omniroute setup-cline", cline: "omniroute setup-cline",
kilocode: "omniroute setup-kilo", kilo: "omniroute setup-kilo",
continue: "omniroute setup-continue", continue: "omniroute setup-continue",
}; };
@@ -56,8 +48,9 @@ export async function POST(request: Request) {
); );
} }
const { toolId, baseUrl, apiKey, model, dryRun } = parsed.data; const { toolId, baseUrl, apiKey, model, dryRun } = parsed.data;
const canonicalToolId = normalizeCliToolId(toolId);
const result = await generateConfig(toolId, { const result = await generateConfig(canonicalToolId, {
baseUrl: baseUrl || "http://localhost:20128/v1", baseUrl: baseUrl || "http://localhost:20128/v1",
apiKey, apiKey,
model, model,
@@ -72,10 +65,11 @@ export async function POST(request: Request) {
dryRun: true, dryRun: true,
configPath: result.configPath, configPath: result.configPath,
content: result.content, content: result.content,
...(result.migration ? { migration: result.migration } : {}),
}); });
} }
const configPath = toolId === "opencode" ? result.configPath : TOOL_CONFIG_PATHS[toolId]; const configPath = result.configPath || getCliPrimaryConfigPath(canonicalToolId);
if (!configPath) { if (!configPath) {
return NextResponse.json({ error: `Unknown tool: ${toolId}` }, { status: 400 }); return NextResponse.json({ error: `Unknown tool: ${toolId}` }, { status: 400 });
} }
@@ -83,8 +77,8 @@ export async function POST(request: Request) {
// A container write into an unmounted path looks successful and then // A container write into an unmounted path looks successful and then
// disappears with the container — refuse it and point at the host CLI. // disappears with the container — refuse it and point at the host CLI.
const refusal = guardCliConfigWrite(configPath, { const refusal = guardCliConfigWrite(configPath, {
toolLabel: toolId, toolLabel: canonicalToolId,
hostCommand: HOST_SETUP_COMMANDS[toolId], hostCommand: HOST_SETUP_COMMANDS[canonicalToolId],
}); });
if (refusal) return refusal; if (refusal) return refusal;
@@ -100,6 +94,7 @@ export async function POST(request: Request) {
configPath, configPath,
backupPath, backupPath,
content: result.content, content: result.content,
...(result.migration ? { migration: result.migration } : {}),
}); });
} catch (error) { } catch (error) {
console.log("Error applying config:", error); console.log("Error applying config:", error);

View File

@@ -4,7 +4,12 @@
* Re-exports the registry and manager for convenient imports. * Re-exports the registry and manager for convenient imports.
*/ */
export { detectInstalledAgents, getAgentById, getAvailableAgents } from "./registry"; export {
detectInstalledAgents,
getAgentById,
getAvailableAgents,
hasRegisteredAgent,
} from "./registry";
export type { CliAgentInfo } from "./registry"; export type { CliAgentInfo } from "./registry";
export { AcpManager, acpManager } from "./manager"; export { AcpManager, acpManager } from "./manager";

View File

@@ -11,6 +11,7 @@
import { spawn, ChildProcess } from "child_process"; import { spawn, ChildProcess } from "child_process";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { hasRegisteredAgent } from "./registry";
export interface AcpSession { export interface AcpSession {
/** Unique session ID */ /** Unique session ID */
@@ -47,11 +48,18 @@ export class AcpManager extends EventEmitter {
args: string[] = [], args: string[] = [],
env: Record<string, string> = {} env: Record<string, string> = {}
): AcpSession { ): AcpSession {
const ALLOWED_AGENTS = ["claude", "codex", "gemini", "qwen"]; const normalizedAgentId = String(agentId || "")
if (!ALLOWED_AGENTS.includes(agentId)) { .trim()
.toLowerCase();
if (!hasRegisteredAgent(normalizedAgentId)) {
throw new Error(`Unknown agent: ${agentId}`); throw new Error(`Unknown agent: ${agentId}`);
} }
// Keep session ids and telemetry stable when a caller uses a registry
// alias/custom spelling. The registry remains the source of truth for
// which ACP-capable IDs may be spawned.
agentId = normalizedAgentId;
const sessionId = `acp-${agentId}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; const sessionId = `acp-${agentId}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
const child = spawn(binary, args, { const child = spawn(binary, args, {

View File

@@ -69,6 +69,15 @@ const AGENT_DEFINITIONS: Omit<CliAgentInfo, "version" | "installed">[] = [
spawnArgs: ["--print", "--output-format", "json"], spawnArgs: ["--print", "--output-format", "json"],
protocol: "stdio", protocol: "stdio",
}, },
{
id: "gemini",
name: "Google Gemini CLI",
binary: "gemini",
versionCommand: "gemini --version",
providerAlias: "gemini",
spawnArgs: [],
protocol: "stdio",
},
{ {
id: "goose", id: "goose",
name: "Goose CLI", name: "Goose CLI",
@@ -385,6 +394,24 @@ export function getAgentById(id: string): CliAgentInfo | undefined {
return agents.find((a) => a.id === id); return agents.find((a) => a.id === id);
} }
/**
* Check registration without probing every executable on PATH.
*
* Process lifecycle callers need an allowlist decision, not a fresh health
* scan. Keeping this lookup pure avoids making `spawn()` wait on one timeout
* per uninstalled agent while preserving detectInstalledAgents() for UI/status
* consumers.
*/
export function hasRegisteredAgent(id: string): boolean {
const normalized = String(id || "")
.trim()
.toLowerCase();
return (
AGENT_DEFINITIONS.some((agent) => agent.id === normalized) ||
_customAgentDefs.some((agent) => agent.id === normalized)
);
}
/** /**
* Get agents that are installed and available for ACP. * Get agents that are installed and available for ACP.
*/ */

View File

@@ -1,34 +1,93 @@
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import fs from "node:fs";
import { parse, stringify } from "smol-toml";
let yaml: typeof import("js-yaml") | null = null; /**
async function loadYaml() { * Codex CLI config generator — TOML.
if (!yaml) { *
yaml = await import("js-yaml"); * Modern Codex (Rust CLI, v0.137+) reads `~/.codex/config.toml` exclusively;
} * the YAML `~/.codex/config.yaml` this generator used to emit belongs to the
return yaml; * legacy npm codex-cli and is silently ignored by current binaries. The shape
* below matches the documented OmniRoute block
* (docs/guides/CODEX-CLI-CONFIGURATION.md → "Ready-to-paste config.toml").
*
* Two deliberate safety properties:
* - The API key is NEVER written into the file. Codex reads it from the env
* var named by `env_key` (`OMNIROUTE_API_KEY`), so the generated content is
* credential-free and safe to show in dry-run.
* - An existing `config.toml` is merged conservatively: every unrelated key
* the operator already has is preserved; only `model`, `model_provider` and
* `[model_providers.omniroute]` are set. An existing file that fails TOML
* parsing aborts generation instead of clobbering the operator's config.
*/
export const CODEX_MODEL_PROVIDER_ID = "omniroute";
export function getCodexHome(): string {
return path.join(os.homedir(), ".codex");
} }
const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.yaml"); /** Path of the legacy YAML config, when one is left over from old generators. */
export function findLegacyCodexYaml(codexHome: string = getCodexHome()): string | null {
const legacyPath = path.join(codexHome, "config.yaml");
try {
return fs.existsSync(legacyPath) ? legacyPath : null;
} catch {
return null;
}
}
export async function generateCodexConfig(options: { export async function generateCodexConfig(options: {
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;
model?: string; model?: string;
/** Override for tests; production callers use ~/.codex/config.toml. */
configPath?: string;
}): Promise<string> { }): Promise<string> {
const y = await loadYaml();
let base = options.baseUrl; let base = options.baseUrl;
let end = base.length; let end = base.length;
while (end > 0 && base[end - 1] === "/") end--; while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base; base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3); if (base.endsWith("/v1")) base = base.slice(0, -3);
const config = { const configPath = options.configPath ?? path.join(getCodexHome(), "config.toml");
openai: {
api_key: options.apiKey, let existing: Record<string, unknown> = {};
base_url: `${base}/v1`, if (fs.existsSync(configPath)) {
const raw = fs.readFileSync(configPath, "utf-8");
try {
existing = parse(raw) as Record<string, unknown>;
} catch {
throw new Error(
`Existing ${configPath} is not valid TOML; refusing to overwrite it. ` +
"Fix or move the file, then retry."
);
}
}
const existingProviders =
existing.model_providers && typeof existing.model_providers === "object"
? (existing.model_providers as Record<string, unknown>)
: {};
const merged: Record<string, unknown> = {
...existing,
...(options.model ? { model: options.model } : {}),
model_provider: CODEX_MODEL_PROVIDER_ID,
model_providers: {
...existingProviders,
[CODEX_MODEL_PROVIDER_ID]: {
name: "OmniRoute",
base_url: `${base}/v1`,
env_key: "OMNIROUTE_API_KEY",
requires_openai_auth: false,
},
}, },
}; };
return y.dump(config, { lineWidth: -1 }); const header =
"# Generated by OmniRoute. The API key is read from the OMNIROUTE_API_KEY\n" +
"# environment variable (env_key) and is never stored in this file.\n";
return header + stringify(merged) + "\n";
} }

View File

@@ -4,13 +4,14 @@ import os from "node:os";
import { getHermesConfigPath } from "./hermesHome.ts"; import { getHermesConfigPath } from "./hermesHome.ts";
import { generateClaudeConfig } from "./claude"; import { generateClaudeConfig } from "./claude";
import { generateClineConfig } from "./cline"; import { generateClineConfig } from "./cline";
import { generateCodexConfig } from "./codex"; import { generateCodexConfig, findLegacyCodexYaml } from "./codex";
import { generateContinueConfig } from "./continue"; import { generateContinueConfig } from "./continue";
import { generateHermesConfig } from "./hermes"; import { generateHermesConfig } from "./hermes";
import { generateHermesAgentConfig, type HermesAgentConfigPayload } from "./hermes-agent"; import { generateHermesAgentConfig, type HermesAgentConfigPayload } from "./hermes-agent";
import { generateKilocodeConfig } from "./kilocode"; import { generateKilocodeConfig } from "./kilocode";
import { generateOpencodeConfig } from "./opencode"; import { generateOpencodeConfig } from "./opencode";
import { resolveOpencodeConfigPath } from "../../../shared/services/opencodeConfigPath"; import { resolveOpencodeConfigPath } from "../../../shared/services/opencodeConfigPath";
import { normalizeCliToolId } from "../../../shared/services/cliRuntime";
export interface GenerateOptions { export interface GenerateOptions {
baseUrl: string; baseUrl: string;
@@ -23,6 +24,8 @@ export interface GenerateResult {
configPath: string; configPath: string;
content?: string; content?: string;
error?: string; error?: string;
/** Human-readable migration note (e.g. a legacy config file that is now ignored). */
migration?: string;
} }
export function validateBaseUrl(url: string): boolean { export function validateBaseUrl(url: string): boolean {
@@ -42,9 +45,12 @@ function expandHome(p: string): string {
// Static paths that do not depend on runtime env vars can stay eagerly computed. // Static paths that do not depend on runtime env vars can stay eagerly computed.
const STATIC_TOOL_CONFIG_PATHS: Record<string, string> = { const STATIC_TOOL_CONFIG_PATHS: Record<string, string> = {
claude: path.join(os.homedir(), ".claude", "settings.json"), claude: path.join(os.homedir(), ".claude", "settings.json"),
codex: path.join(os.homedir(), ".codex", "config.yaml"), // Modern Codex (v0.137+) reads TOML only; config.yaml is the legacy npm CLI.
codex: path.join(os.homedir(), ".codex", "config.toml"),
cline: path.join(os.homedir(), ".cline", "data", "globalState.json"), cline: path.join(os.homedir(), ".cline", "data", "globalState.json"),
kilocode: path.join(os.homedir(), ".config", "kilocode", "settings.json"), // `kilo` is the canonical id; the file name remains `kilocode` because the
// VS Code extension owns that settings namespace.
kilo: path.join(os.homedir(), ".config", "kilocode", "settings.json"),
continue: path.join(os.homedir(), ".continue", "config.yaml"), continue: path.join(os.homedir(), ".continue", "config.yaml"),
}; };
@@ -55,6 +61,7 @@ const STATIC_TOOL_CONFIG_PATHS: Record<string, string> = {
* honoured (#3628). All other tools use the eagerly-computed static map. * honoured (#3628). All other tools use the eagerly-computed static map.
*/ */
function getToolConfigPath(toolId: string): string { function getToolConfigPath(toolId: string): string {
toolId = normalizeCliToolId(toolId);
if (toolId === "hermes" || toolId === "hermes-agent") { if (toolId === "hermes" || toolId === "hermes-agent") {
return getHermesConfigPath(); return getHermesConfigPath();
} }
@@ -71,7 +78,7 @@ const GENERATORS: Record<string, ConfigGenerator> = {
codex: generateCodexConfig, codex: generateCodexConfig,
opencode: generateOpencodeConfig, opencode: generateOpencodeConfig,
cline: generateClineConfig, cline: generateClineConfig,
kilocode: generateKilocodeConfig, kilo: generateKilocodeConfig,
continue: generateContinueConfig, continue: generateContinueConfig,
hermes: generateHermesConfig, hermes: generateHermesConfig,
"hermes-agent": generateHermesAgentConfig as any, // rich multi-role version "hermes-agent": generateHermesAgentConfig as any, // rich multi-role version
@@ -94,16 +101,31 @@ export async function generateConfig(
} }
try { try {
const generate = GENERATORS[toolId]; const canonicalToolId = normalizeCliToolId(toolId);
const generate = GENERATORS[canonicalToolId];
if (!generate) { if (!generate) {
return { success: false, configPath: "", error: `Unknown tool: ${toolId}` }; return { success: false, configPath: "", error: `Unknown tool: ${toolId}` };
} }
const configPath = getToolConfigPath(toolId); const configPath = getToolConfigPath(canonicalToolId);
const content = const content =
toolId === "opencode" canonicalToolId === "opencode"
? await generateOpencodeConfig({ ...options, configPath }) ? await generateOpencodeConfig({ ...options, configPath })
: await generate(options); : canonicalToolId === "codex"
return { success: true, configPath, content }; ? await generateCodexConfig({ ...options, configPath })
: await generate(options);
let migration: string | undefined;
if (canonicalToolId === "codex") {
const legacyYaml = findLegacyCodexYaml();
if (legacyYaml) {
migration =
`Legacy ${legacyYaml} found — modern Codex (v0.137+) ignores YAML and reads ` +
`only config.toml. The YAML file was left untouched; remove it manually once ` +
`you confirm nothing else uses it.`;
}
}
return { success: true, configPath, content, ...(migration ? { migration } : {}) };
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
return { success: false, configPath: "", error: `Generation failed: ${msg}` }; return { success: false, configPath: "", error: `Generation failed: ${msg}` };
@@ -111,15 +133,10 @@ export async function generateConfig(
} }
export async function generateAllConfigs(options: GenerateOptions): Promise<GenerateResult[]> { export async function generateAllConfigs(options: GenerateOptions): Promise<GenerateResult[]> {
const toolIds = [ // Keep the batch view derived from the actual generator registry. Hermes
"claude", // Agent has a richer payload and is intentionally exposed by its dedicated
"codex", // endpoint, not by this simple `{baseUrl, apiKey, model}` batch API.
"opencode", const toolIds = Object.keys(GENERATORS).filter((id) => id !== "hermes-agent");
"cline",
"kilocode",
"continue",
"hermes",
] as const;
const results = await Promise.allSettled(toolIds.map((id) => generateConfig(id, options))); const results = await Promise.allSettled(toolIds.map((id) => generateConfig(id, options)));
return results.map((r) => return results.map((r) =>

View File

@@ -1,11 +1,16 @@
import os from "node:os"; import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { getCurrentHermesAgentRoles } from "./config-generator/hermes-agent"; import { getCurrentHermesAgentRoles } from "./config-generator/hermes-agent";
import { getHermesConfigPath } from "./config-generator/hermesHome";
import { getCliTool, listCliTools } from "../../shared/constants/cliTools";
import { import {
CLI_TOOL_IDS,
getLookupEnv, getLookupEnv,
getCliPrimaryConfigPath,
getCliToolCommandCandidates,
locateCommand, locateCommand,
normalizeCliToolId,
shouldUseShellForCommand, shouldUseShellForCommand,
} from "../../shared/services/cliRuntime"; } from "../../shared/services/cliRuntime";
import { resolveOpencodeConfigPath } from "../../shared/services/opencodeConfigPath"; import { resolveOpencodeConfigPath } from "../../shared/services/opencodeConfigPath";
@@ -42,30 +47,29 @@ export interface DetectedTool {
>; >;
} }
const TOOLS = [ type ToolDescriptor = { id: string; name: string; configPath: string };
{ id: "claude", name: "Claude Code", configPath: "~/.claude/settings.json" },
{ id: "codex", name: "Codex CLI", configPath: "~/.codex/config.yaml" },
{ id: "opencode", name: "OpenCode", configPath: resolveOpencodeConfigPath },
{ id: "cline", name: "Cline", configPath: "~/.cline/data/globalState.json" },
{ id: "kilocode", name: "Kilo Code", configPath: "~/.config/kilocode/settings.json" },
{ id: "continue", name: "Continue", configPath: "~/.continue/config.yaml" },
{ id: "hermes", name: "Hermes", configPath: "~/.hermes/config.yaml" },
{ id: "hermes-agent", name: "Hermes Agent", configPath: "~/.hermes/config.yaml" },
{ id: "openclaw", name: "OpenClaw", configPath: "~/.openclaw/openclaw.json" },
] as const;
const BINARY_NAMES: Record<string, string> = { // Keep the long-standing CLI status labels stable while the UI catalog uses
claude: "claude", // marketing names (for example, "Open Claw").
codex: "codex", const DETECTOR_NAME_OVERRIDES: Readonly<Record<string, string>> = {
opencode: "opencode", claude: "Claude Code",
cline: "cline", codex: "Codex CLI",
kilocode: "kilocode", openclaw: "OpenClaw",
continue: "continue",
hermes: "hermes",
"hermes-agent": "hermes",
openclaw: "openclaw",
}; };
/**
* The detector is a read-only view over the shared runtime/UI catalogs.
* Runtime-only entries (for example qoder) are retained, while guide-only UI
* entries still appear with an empty config path and `installed: false`.
*/
const TOOLS: ToolDescriptor[] = Array.from(
new Set([...listCliTools().map((tool) => tool.id), ...CLI_TOOL_IDS])
).map((id) => ({
id,
name: DETECTOR_NAME_OVERRIDES[id] || getCliTool(id)?.name || id,
configPath: "",
}));
function expandHome(p: string): string { function expandHome(p: string): string {
const home = os.homedir(); const home = os.homedir();
return p.replace(/^~\//, home + "/"); return p.replace(/^~\//, home + "/");
@@ -111,27 +115,35 @@ async function detectBinaryWindows(
} }
async function detectBinary(name: string): Promise<{ installed: boolean; version?: string }> { async function detectBinary(name: string): Promise<{ installed: boolean; version?: string }> {
const binary = BINARY_NAMES[name] || name; const binaries = getCliToolCommandCandidates(name);
if (binaries.length === 0) return { installed: false };
const env = getLookupEnv(); const env = getLookupEnv();
if (process.platform === "win32") { for (const binary of binaries) {
return detectBinaryWindows(binary, env); if (process.platform === "win32") {
const result = await detectBinaryWindows(binary, env);
if (result.installed) return result;
continue;
}
try {
const { stdout } = await execFileImpl(binary, ["--version"], { timeout: 5000, env });
const version = stdout.trim().replace(/^v/, "");
return { installed: true, version };
} catch {
try {
// Try `which` as fallback (routed through execFileImpl so it stays mockable)
const { stdout } = await execFileImpl("which", [binary], { timeout: 5000, env });
if (stdout.trim()) {
return { installed: true };
}
} catch {
// Try the next declared command candidate.
}
}
} }
try { return { installed: false };
const { stdout } = await execFileImpl(binary, ["--version"], { timeout: 5000, env });
const version = stdout.trim().replace(/^v/, "");
return { installed: true, version };
} catch {
try {
// Try `which` as fallback (routed through execFileImpl so it stays mockable)
const { stdout } = await execFileImpl("which", [binary], { timeout: 5000, env });
if (stdout.trim()) {
return { installed: true };
}
} catch {}
return { installed: false };
}
} }
async function readConfigFile(configPath: string): Promise<string | null> { async function readConfigFile(configPath: string): Promise<string | null> {
@@ -146,17 +158,21 @@ async function readConfigFile(configPath: string): Promise<string | null> {
} }
export async function detectTool(id: string): Promise<DetectedTool | null> { export async function detectTool(id: string): Promise<DetectedTool | null> {
const tool = TOOLS.find((t) => t.id === id); const canonicalId = normalizeCliToolId(id);
const tool = TOOLS.find((t) => t.id === canonicalId);
if (!tool) return null; if (!tool) return null;
const { installed, version } = await detectBinary(tool.id); const { installed, version } = await detectBinary(tool.id);
const configPath = const configPath =
typeof tool.configPath === "function" ? tool.configPath() : expandHome(tool.configPath); tool.id === "hermes" || tool.id === "hermes-agent"
? getHermesConfigPath()
: getCliPrimaryConfigPath(tool.id) ||
(tool.id === "opencode" ? resolveOpencodeConfigPath() : "");
const configContents = await readConfigFile(configPath); const configContents = await readConfigFile(configPath);
const configured = !!configContents && isConfigured(configContents, "http://localhost:20128"); const configured = !!configContents && isConfigured(configContents, "http://localhost:20128");
const result: DetectedTool = { const result: DetectedTool = {
id: tool.id, id: canonicalId,
name: tool.name, name: tool.name,
installed, installed,
version, version,

View File

@@ -28,7 +28,7 @@ const PASSTHROUGH_PROVIDERS = new Set(
); );
// Wrap isValidModel with passthrough providers // Wrap isValidModel with passthrough providers
export function isValidModel(aliasOrId, modelId) { export function isValidModel(aliasOrId: string, modelId: string) {
if (isOpenAICompatibleProvider(aliasOrId)) return true; if (isOpenAICompatibleProvider(aliasOrId)) return true;
if (isAnthropicCompatibleProvider(aliasOrId)) return true; if (isAnthropicCompatibleProvider(aliasOrId)) return true;
if (PASSTHROUGH_PROVIDERS.has(aliasOrId)) return true; if (PASSTHROUGH_PROVIDERS.has(aliasOrId)) return true;

View File

@@ -198,6 +198,34 @@ const CLI_TOOLS: Record<string, any> = {
env: ".qwen/.env", env: ".qwen/.env",
}, },
}, },
aider: {
defaultCommand: "aider",
envBinKey: "CLI_AIDER_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 12000,
paths: {
config: ".aider.conf.yml",
},
},
goose: {
defaultCommand: "goose",
envBinKey: "CLI_GOOSE_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 12000,
paths: {
config: ".config/goose/config.yaml",
},
},
gemini: {
defaultCommand: "gemini",
envBinKey: "CLI_GEMINI_BIN",
requiresBinary: true,
// gemini-cli cold start (bundle + extension discovery) can exceed 4s.
healthcheckTimeoutMs: 15000,
paths: {
settings: ".gemini/settings.json",
},
},
// ── Plan 14 — new "custom" configType tools ─────────────────────────────── // ── Plan 14 — new "custom" configType tools ───────────────────────────────
forge: { forge: {
defaultCommand: "forge", defaultCommand: "forge",
@@ -286,6 +314,33 @@ const CLI_TOOLS: Record<string, any> = {
}, },
}; };
/**
* Compatibility aliases accepted by CLI/API callers.
*
* The runtime catalog keeps one canonical id per executable. Older surfaces
* exposed a binary name (notably `kilocode`) or launcher aliases instead of
* that id, so normalize them at the boundary rather than duplicating entries.
*/
export const CLI_TOOL_ALIASES: Readonly<Record<string, string>> = {
kilocode: "kilo",
"kilo-code": "kilo",
kilo_cli: "kilo",
cc: "claude",
"claude-code": "claude",
"openai-codex": "codex",
openai: "codex",
cn: "continue",
qodercli: "qoder",
};
/** Resolve a user-facing or legacy id to the canonical runtime id. */
export const normalizeCliToolId = (toolId: string): string => {
const normalized = String(toolId || "")
.trim()
.toLowerCase();
return CLI_TOOL_ALIASES[normalized] || normalized;
};
const isWindows = () => process.platform === "win32"; const isWindows = () => process.platform === "win32";
/** /**
@@ -568,6 +623,7 @@ const getExtraPaths = () =>
* Works on all platforms — Windows checks .cmd wrappers, Linux/macOS checks bare names. * Works on all platforms — Windows checks .cmd wrappers, Linux/macOS checks bare names.
*/ */
export const getKnownToolPaths = (toolId: string): string[] => { export const getKnownToolPaths = (toolId: string): string[] => {
toolId = normalizeCliToolId(toolId);
const home = os.homedir(); const home = os.homedir();
const paths: string[] = []; const paths: string[] = [];
@@ -730,7 +786,7 @@ export const getLookupEnv = () => {
}; };
const resolveToolCommands = (toolId: string): string[] => { const resolveToolCommands = (toolId: string): string[] => {
const tool = CLI_TOOLS[toolId]; const tool = CLI_TOOLS[normalizeCliToolId(toolId)];
if (!tool) return []; if (!tool) return [];
const envCommand = String(process.env[tool.envBinKey] || "").trim(); const envCommand = String(process.env[tool.envBinKey] || "").trim();
if (envCommand) return [envCommand]; if (envCommand) return [envCommand];
@@ -740,6 +796,16 @@ const resolveToolCommands = (toolId: string): string[] => {
return tool.defaultCommand ? [tool.defaultCommand] : []; return tool.defaultCommand ? [tool.defaultCommand] : [];
}; };
/**
* Return command candidates without probing the filesystem.
*
* Lightweight consumers (config status and CLI inventory) use this to build
* a version probe while getCliRuntimeStatus() remains the authoritative
* health/runnability check.
*/
export const getCliToolCommandCandidates = (toolId: string): string[] =>
resolveToolCommands(toolId);
const checkExplicitPath = async (commandPath: string) => { const checkExplicitPath = async (commandPath: string) => {
// Reject paths that look like injection attempts // Reject paths that look like injection attempts
if (!isSafePath(commandPath)) { if (!isSafePath(commandPath)) {
@@ -781,13 +847,13 @@ export const locateCommand = async (command: string, env: Record<string, string
// and a .cmd wrapper. We must prefer the Windows executable extension. // and a .cmd wrapper. We must prefer the Windows executable extension.
const lines = located.stdout const lines = located.stdout
.split(/\r?\n/) .split(/\r?\n/)
.map((l) => l.trim()) .map((l: string) => l.trim())
.filter(Boolean); .filter(Boolean);
if (lines.length === 0) { if (lines.length === 0) {
return { installed: false, commandPath: null, reason: "not_found" }; return { installed: false, commandPath: null, reason: "not_found" };
} }
const winExt = /\.(cmd|exe|bat|com)$/i; const winExt = /\.(cmd|exe|bat|com)$/i;
const preferred = lines.find((l) => winExt.test(l)) || lines[0]; const preferred = lines.find((l: string) => winExt.test(l)) || lines[0];
return { installed: true, commandPath: normalizeMsys2Path(preferred), reason: null }; return { installed: true, commandPath: normalizeMsys2Path(preferred), reason: null };
} }
return { installed: false, commandPath: null, reason: "not_found" }; return { installed: false, commandPath: null, reason: "not_found" };
@@ -1025,6 +1091,7 @@ export const resolveOpencodeConfigPath = (
export const getOpenCodeConfigPath = () => resolveOpencodeConfigPath(); export const getOpenCodeConfigPath = () => resolveOpencodeConfigPath();
export const getCliConfigPaths = (toolId: string) => { export const getCliConfigPaths = (toolId: string) => {
toolId = normalizeCliToolId(toolId);
const tool = CLI_TOOLS[toolId]; const tool = CLI_TOOLS[toolId];
if (!tool) return null; if (!tool) return null;
@@ -1071,6 +1138,7 @@ export const getCliPrimaryConfigPath = (toolId: string) => {
}; };
export const getCliRuntimeStatus = async (toolId: string) => { export const getCliRuntimeStatus = async (toolId: string) => {
toolId = normalizeCliToolId(toolId);
const tool = CLI_TOOLS[toolId]; const tool = CLI_TOOLS[toolId];
const runtimeMode = getRuntimeMode(); const runtimeMode = getRuntimeMode();
if (!tool) { if (!tool) {

View File

@@ -0,0 +1,178 @@
/**
* Opt-in REAL smoke harness for upstream CLIs launched through `omniroute run`.
*
* Deterministic regression for the launch plans lives in
* `tests/unit/cli/run-command.test.ts` (dry-run plans) and
* `tests/unit/cli/run-execution.test.ts` (child-process isolation). This file
* exercises the REAL binaries against a REAL OmniRoute server and therefore:
*
* - NEVER runs automatically: every sub-test skips unless RUN_CLI_SMOKE=1;
* - NEVER ships or prints credentials: the API key is passed by env-var NAME
* (`--api-key-env`), values are never logged, and assertions only inspect
* exit codes and redacted output classes;
* - classifies failures as binary-missing / server-unreachable / auth /
* upstream instead of a bare boolean.
*
* Operator usage (all knobs are env vars — no secrets on the command line):
*
* RUN_CLI_SMOKE=1 \
* OMNIROUTE_SMOKE_BASE_URL="http://localhost:20128" \
* OMNIROUTE_SMOKE_MODEL="<provider/model>" \
* OMNIROUTE_SMOKE_API_KEY_ENV="OMNIROUTE_API_KEY" \
* node --import tsx/esm --test tests/integration/upstream-cli-smoke.int.test.ts
*
* Optional: OMNIROUTE_SMOKE_TARGETS="codex,opencode,qwen" restricts the sweep;
* OMNIROUTE_SMOKE_TIMEOUT_MS overrides the per-target timeout (default 120s).
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
const ENABLED = process.env.RUN_CLI_SMOKE === "1";
const BASE_URL = (process.env.OMNIROUTE_SMOKE_BASE_URL || "http://localhost:20128").replace(
/\/+$/,
""
);
const MODEL = process.env.OMNIROUTE_SMOKE_MODEL || "";
const API_KEY_ENV = process.env.OMNIROUTE_SMOKE_API_KEY_ENV || "OMNIROUTE_API_KEY";
const TIMEOUT_MS = Number(process.env.OMNIROUTE_SMOKE_TIMEOUT_MS || 120_000);
const CLI_ENTRY = fileURLToPath(new URL("../../bin/omniroute.mjs", import.meta.url));
/** One-shot, non-interactive invocation per target. Prompts are inert. */
const SMOKE_TARGETS: Record<string, { args: string[] }> = {
codex: { args: ["exec", "--skip-git-repo-check", "reply with the single word OK"] },
aider: { args: ["--message", "reply with the single word OK", "--no-git", "--yes-always"] },
goose: { args: ["run", "-t", "reply with the single word OK"] },
opencode: { args: ["run", "reply with the single word OK"] },
qwen: { args: ["-p", "reply with the single word OK"] },
gemini: { args: ["--skip-trust", "-p", "reply with the single word OK"] },
};
function selectedTargets(): string[] {
const filter = String(process.env.OMNIROUTE_SMOKE_TARGETS || "")
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const all = Object.keys(SMOKE_TARGETS);
return filter.length ? all.filter((t) => filter.includes(t)) : all;
}
function binaryAvailable(target: string): boolean {
try {
execFileSync("sh", ["-c", 'command -v -- "$1"', "sh", target], {
stdio: ["ignore", "pipe", "ignore"],
timeout: 5000,
});
return true;
} catch {
return false;
}
}
async function serverReachable(): Promise<boolean> {
try {
const res = await fetch(`${BASE_URL}/api/monitoring/health`, {
signal: AbortSignal.timeout(5000),
});
return res.ok;
} catch {
return false;
}
}
/** Redact anything that looks like a secret before recording output. */
function redact(text: string): string {
return text
.replace(/(sk|pk|rk)[-_][A-Za-z0-9_-]{8,}/g, "[redacted-key]")
.replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [redacted]")
.slice(0, 2000);
}
type SmokeResult = {
exitCode: number | null;
stdout: string;
stderr: string;
classification: "pass" | "auth" | "upstream" | "config" | "unknown";
};
function classify(exitCode: number | null, output: string): SmokeResult["classification"] {
if (exitCode === 0) return "pass";
if (/401|403|unauthorized|invalid[_ ]api[_ ]key/i.test(output)) return "auth";
if (/5\d\d|upstream|overloaded|rate.?limit|429/i.test(output)) return "upstream";
if (/not found|unknown model|unsupported|invalid (option|argument)/i.test(output)) {
return "config";
}
return "unknown";
}
function runSmoke(target: string): Promise<SmokeResult> {
const spec = SMOKE_TARGETS[target];
const args = [
CLI_ENTRY,
"run",
target,
"--base-url",
BASE_URL,
"--api-key-env",
API_KEY_ENV,
...(MODEL ? ["--model", MODEL] : []),
"--",
...spec.args,
];
return new Promise((resolve) => {
const child = spawn(process.execPath, args, {
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (c) => (stdout += String(c)));
child.stderr.on("data", (c) => (stderr += String(c)));
const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
child.on("close", (code) => {
clearTimeout(timer);
const combined = redact(stdout + "\n" + stderr);
resolve({
exitCode: code,
stdout: redact(stdout),
stderr: redact(stderr),
classification: classify(code, combined),
});
});
});
}
test("upstream CLI smoke sweep (opt-in via RUN_CLI_SMOKE=1)", { timeout: 0 }, async (t) => {
if (!ENABLED) {
t.skip("RUN_CLI_SMOKE!=1 — real smoke is operator opt-in, never automatic");
return;
}
assert.ok(MODEL, "OMNIROUTE_SMOKE_MODEL must name the provider/model to exercise");
assert.ok(
process.env[API_KEY_ENV] !== undefined,
`credential env var '${API_KEY_ENV}' must exist (value is never printed)`
);
assert.ok(await serverReachable(), `OmniRoute is not reachable at ${BASE_URL}`);
for (const target of selectedTargets()) {
await t.test(`smoke: ${target}`, async (st) => {
if (!binaryAvailable(target)) {
st.skip(`binary '${target}' not installed on this machine`);
return;
}
const result = await runSmoke(target);
st.diagnostic(`${target}: exit=${result.exitCode} class=${result.classification}`);
assert.equal(
result.classification,
"pass",
`${target} smoke failed (exit=${result.exitCode}, class=${result.classification}).\n` +
`stderr (redacted): ${result.stderr.slice(0, 500)}`
);
});
}
});

View File

@@ -0,0 +1,156 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert";
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
/**
* Container-guard homologation for POST /api/cli-tools/apply.
*
* Both runtime modes are exercised by SCOPED `OMNIROUTE_CONTAINER` overrides
* (set per test, restored in finally). The override is the documented test
* seam of `isRunningInContainer()`; it is never forced globally — forcing it
* off for the whole suite would hide a regression in the guard itself.
*/
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-apply-guard-data-"));
const TEST_XDG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-apply-guard-xdg-"));
const originalDataDir = process.env.DATA_DIR;
const originalXdg = process.env.XDG_CONFIG_HOME;
// Fresh DB without a configured password → management auth is open, so these
// tests exercise the guard, not the auth stack (covered elsewhere).
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.XDG_CONFIG_HOME = TEST_XDG_DIR;
const core = await import("../../../../src/lib/db/core.ts");
const { POST } = await import("../../../../src/app/api/cli-tools/apply/route.ts");
const OPENCODE_CONFIG = path.join(TEST_XDG_DIR, "opencode", "opencode.json");
// The OpenCode generator refuses to write without the live /v1/models catalog
// (context windows are catalog-sourced by design), so serve a minimal catalog
// from an in-test loopback server instead of mocking generator internals.
let catalogServer: http.Server;
let catalogBaseUrl = "";
function startCatalogServer(): Promise<string> {
return new Promise((resolve) => {
catalogServer = http.createServer((req, res) => {
if (String(req.url).startsWith("/v1/models")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
data: [{ id: "glm/glm-5.2", object: "model", context_length: 128000 }],
})
);
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
catalogServer.listen(0, "127.0.0.1", () => {
const address = catalogServer.address();
const port = typeof address === "object" && address ? address.port : 0;
resolve(`http://127.0.0.1:${port}`);
});
});
}
function applyRequest(body: Record<string, unknown>): Request {
return new Request("http://localhost:3000/api/cli-tools/apply", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
async function withContainerMode<T>(mode: "1" | "0", run: () => Promise<T>): Promise<T> {
const original = process.env.OMNIROUTE_CONTAINER;
const originalAllow = process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE;
process.env.OMNIROUTE_CONTAINER = mode;
delete process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE;
try {
return await run();
} finally {
if (original === undefined) delete process.env.OMNIROUTE_CONTAINER;
else process.env.OMNIROUTE_CONTAINER = original;
if (originalAllow !== undefined) {
process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = originalAllow;
}
}
}
describe("POST /api/cli-tools/apply — container guard", () => {
before(async () => {
catalogBaseUrl = await startCatalogServer();
});
after(() => {
catalogServer?.close();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_XDG_DIR, { recursive: true, force: true });
if (originalDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = originalDataDir;
if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = originalXdg;
});
it("refuses an OpenCode write in container mode with a safe 422", async () => {
const res = await withContainerMode("1", () =>
POST(
applyRequest({
toolId: "opencode",
baseUrl: catalogBaseUrl,
apiKey: "sk-test-guard",
})
)
);
assert.strictEqual(res.status, 422);
const body = await res.json();
assert.ok(body.containerEphemeralTarget, "422 must be keyed as containerEphemeralTarget");
assert.strictEqual(body.hostSetupCommand, "omniroute setup-opencode");
assert.ok(typeof body.error === "string" && body.error.length > 0);
assert.ok(!body.error.includes("at /"), "error must not leak a stack trace");
assert.ok(!body.error.includes("sk-test-guard"), "error must not leak the API key");
assert.strictEqual(fs.existsSync(OPENCODE_CONFIG), false, "nothing may be written");
});
it("still serves dry-run previews in container mode without writing", async () => {
const res = await withContainerMode("1", () =>
POST(
applyRequest({
toolId: "opencode",
baseUrl: catalogBaseUrl,
apiKey: "sk-test-guard",
dryRun: true,
})
)
);
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.dryRun, true);
assert.ok(String(body.content).includes(catalogBaseUrl));
assert.strictEqual(fs.existsSync(OPENCODE_CONFIG), false, "dry-run must not write");
});
it("writes the valid OpenCode config on a host", async () => {
const res = await withContainerMode("0", () =>
POST(
applyRequest({
toolId: "opencode",
baseUrl: catalogBaseUrl,
apiKey: "sk-test-guard",
})
)
);
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.success, true);
assert.strictEqual(body.configPath, OPENCODE_CONFIG);
assert.ok(fs.existsSync(OPENCODE_CONFIG), "host write must land");
const written = fs.readFileSync(OPENCODE_CONFIG, "utf-8");
assert.ok(written.includes(catalogBaseUrl));
});
});

View File

@@ -93,3 +93,26 @@ test("completion scripts incluem combos/providers/models no cache dinamicamente"
"should reference cache" "should reference cache"
); );
}); });
test("completion scripts expõem os alvos de execução e configuração", async () => {
const { runCompletionCommand } = await import("../../bin/cli/commands/completion.mjs");
const expected = ["connect", "contexts", "configure", "launch", "launch-codex", "run", "repair"];
for (const shell of ["bash", "zsh", "fish"] as const) {
const chunks: string[] = [];
const originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = ((chunk: unknown) => {
if (typeof chunk === "string") chunks.push(chunk);
return true;
}) as typeof process.stdout.write;
try {
assert.equal(await runCompletionCommand(shell), 0);
} finally {
process.stdout.write = originalWrite;
}
const output = chunks.join("");
for (const command of expected) {
assert.ok(output.includes(command), `${shell} completion should include ${command}`);
}
}
});

View File

@@ -1,6 +1,6 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@@ -70,11 +70,59 @@ test("resolveActiveContext aceita override pontual", async () => {
assert.equal(ctx.baseUrl, "http://staging:20128"); assert.equal(ctx.baseUrl, "http://staging:20128");
}); });
test("saveContextsSecure guarda tokens no keychain e resolve pela referência", async () => {
const {
loadContexts,
saveContextsSecure,
resolveActiveContext,
setContextKeychainBackendForTests,
} = await import("../../bin/cli/contexts.mjs");
const entries = new Map<string, string>();
const fakeKeychain = {
async getPassword(_service: string, account: string) {
return entries.get(account) || null;
},
async setPassword(_service: string, account: string, value: string) {
entries.set(account, value);
},
async deletePassword(_service: string, account: string) {
entries.delete(account);
return true;
},
};
await setContextKeychainBackendForTests(fakeKeychain);
const cfg = loadContexts();
cfg.contexts.secure = {
baseUrl: "https://secure.example.com",
accessToken: "oma_test_secret",
scope: "write",
};
await saveContextsSecure(cfg);
const persisted = JSON.parse(readFileSync(join(tmpDir, "config.json"), "utf8"));
assert.equal(persisted.contexts.secure.accessToken, undefined);
assert.match(persisted.contexts.secure.credentialRef, /^omniroute-cli:context:/);
assert.equal(resolveActiveContext("secure").accessToken, "oma_test_secret");
assert.ok(entries.size >= 1);
await setContextKeychainBackendForTests(null);
});
test("contexts.mjs (commands) pode ser importado sem erro", async () => { test("contexts.mjs (commands) pode ser importado sem erro", async () => {
const mod = await import("../../bin/cli/commands/contexts.mjs"); const mod = await import("../../bin/cli/commands/contexts.mjs");
assert.equal(typeof mod.registerContexts, "function"); assert.equal(typeof mod.registerContexts, "function");
}); });
test("context export redaction covers canonical and legacy profile schemas", async () => {
const { redactContextSecrets } = await import("../../bin/cli/commands/contexts.mjs");
const redacted = redactContextSecrets({
contexts: { remote: { accessToken: "oma-secret", apiKey: "sk-secret" } },
profiles: { legacy: { accessToken: "legacy-secret", apiKey: "legacy-key" } },
});
assert.deepEqual(redacted.contexts.remote, { apiKey: null });
assert.deepEqual(redacted.profiles.legacy, { apiKey: null });
});
test("confirm() declines cleanly on non-interactive stdin (no hung await)", async () => { test("confirm() declines cleanly on non-interactive stdin (no hung await)", async () => {
// Regression: `contexts remove` without --yes used to prompt even when stdin // Regression: `contexts remove` without --yes used to prompt even when stdin
// could not answer (pipe/CI/EOF), leaving the readline question pending and // could not answer (pipe/CI/EOF), leaving the readline question pending and

View File

@@ -0,0 +1,143 @@
import { describe, it, after } from "node:test";
import assert from "node:assert";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { parse } from "smol-toml";
import {
generateCodexConfig,
findLegacyCodexYaml,
} from "../../../src/lib/cli-helper/config-generator/codex.ts";
import { generateConfig } from "../../../src/lib/cli-helper/config-generator/index.ts";
interface ParsedCodexToml {
model?: string;
model_provider?: string;
tool_output_token_limit?: number;
model_providers: Record<
string,
{ name?: string; base_url?: string; env_key?: string; requires_openai_auth?: boolean }
>;
}
const tmpDirs: string[] = [];
function tempCodexHome(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-gen-"));
tmpDirs.push(dir);
return dir;
}
after(() => {
for (const dir of tmpDirs) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// best-effort cleanup
}
}
});
describe("config-generator codex (TOML)", () => {
it("generates modern TOML with env_key auth and never embeds the API key", async () => {
const home = tempCodexHome();
const content = await generateCodexConfig({
baseUrl: "http://localhost:20128/",
apiKey: "sk_live_secret_value",
model: "glm/glm-5.2",
configPath: path.join(home, "config.toml"),
});
assert.ok(!content.includes("sk_live_secret_value"), "API key must not be written");
const parsed = parse(content) as unknown as ParsedCodexToml;
assert.strictEqual(parsed.model, "glm/glm-5.2");
assert.strictEqual(parsed.model_provider, "omniroute");
assert.strictEqual(parsed.model_providers.omniroute.base_url, "http://localhost:20128/v1");
assert.strictEqual(parsed.model_providers.omniroute.env_key, "OMNIROUTE_API_KEY");
assert.strictEqual(parsed.model_providers.omniroute.requires_openai_auth, false);
});
it("normalizes a baseUrl that already ends in /v1", async () => {
const home = tempCodexHome();
const content = await generateCodexConfig({
baseUrl: "https://relay.example.test/v1",
apiKey: "sk-test",
configPath: path.join(home, "config.toml"),
});
const parsed = parse(content) as unknown as ParsedCodexToml;
assert.strictEqual(parsed.model_providers.omniroute.base_url, "https://relay.example.test/v1");
assert.ok(!("model" in parsed), "model key is omitted when no model is chosen");
});
it("merges conservatively with an existing config.toml, preserving operator keys", async () => {
const home = tempCodexHome();
const configPath = path.join(home, "config.toml");
fs.writeFileSync(
configPath,
[
'model = "old/model"',
"tool_output_token_limit = 32768",
"",
"[model_providers.other]",
'name = "Other"',
'base_url = "https://other.example/v1"',
].join("\n"),
"utf-8"
);
const content = await generateCodexConfig({
baseUrl: "http://localhost:20128",
apiKey: "sk-test",
model: "glm/glm-5.2",
configPath,
});
const parsed = parse(content) as unknown as ParsedCodexToml;
assert.strictEqual(parsed.tool_output_token_limit, 32768, "unrelated key preserved");
assert.strictEqual(parsed.model_providers.other.name, "Other", "other provider preserved");
assert.strictEqual(parsed.model, "glm/glm-5.2", "model updated");
assert.strictEqual(parsed.model_provider, "omniroute");
assert.ok(parsed.model_providers.omniroute, "omniroute provider added");
});
it("refuses to overwrite an existing config.toml that is not valid TOML", async () => {
const home = tempCodexHome();
const configPath = path.join(home, "config.toml");
fs.writeFileSync(configPath, "this is { not [ valid toml =", "utf-8");
await assert.rejects(
() =>
generateCodexConfig({
baseUrl: "http://localhost:20128",
apiKey: "sk-test",
configPath,
}),
/not valid TOML/
);
assert.strictEqual(
fs.readFileSync(configPath, "utf-8"),
"this is { not [ valid toml =",
"invalid file left untouched"
);
});
it("detects a leftover legacy config.yaml for migration messaging", () => {
const home = tempCodexHome();
assert.strictEqual(findLegacyCodexYaml(home), null, "absent yaml → no migration");
fs.writeFileSync(path.join(home, "config.yaml"), "openai:\n base_url: x\n", "utf-8");
assert.strictEqual(findLegacyCodexYaml(home), path.join(home, "config.yaml"));
});
it("generateConfig(codex) targets ~/.codex/config.toml (not the legacy yaml)", async () => {
const result = await generateConfig("codex", {
baseUrl: "http://localhost:20128",
apiKey: "sk-test",
});
// Success depends on the operator's real ~/.codex/config.toml being valid;
// the path contract is what must hold either way.
assert.ok(result.configPath.endsWith(path.join(".codex", "config.toml")));
if (result.success) {
assert.ok(String(result.content).includes("[model_providers.omniroute]"));
assert.ok(!String(result.content).includes("sk-test"));
}
});
});

View File

@@ -110,6 +110,16 @@ describe("config-generator", () => {
assert.ok("configPath" in result); assert.ok("configPath" in result);
}); });
it("accepts the legacy kilocode id while generating the canonical kilo config", async () => {
const result = await generator.generateConfig("kilocode", {
baseUrl: "http://localhost:20128",
apiKey: "sk-test",
});
assert.strictEqual(result.success, true);
assert.ok(result.configPath.includes(".config/kilocode/settings.json"));
assert.ok(String(result.content).includes("http://localhost:20128/v1"));
});
it("returns success for valid hermes config", async () => { it("returns success for valid hermes config", async () => {
const result = await generator.generateConfig("hermes", { const result = await generator.generateConfig("hermes", {
baseUrl: "http://localhost:20128", baseUrl: "http://localhost:20128",

View File

@@ -61,10 +61,18 @@ describe("tool-detector", () => {
assert.strictEqual(result!.version, "0.3.1"); assert.strictEqual(result!.version, "0.3.1");
assert.ok( assert.ok(
result!.configPath.includes(".openclaw/openclaw.json"), result!.configPath.includes(".openclaw/openclaw.json"),
`expected configPath to include '.openclaw/openclaw.json', got: ${result!.configPath}`, `expected configPath to include '.openclaw/openclaw.json', got: ${result!.configPath}`
); );
assert.strictEqual(typeof result!.configured, "boolean"); assert.strictEqual(typeof result!.configured, "boolean");
}); });
it("normalizes the legacy kilocode id to the canonical kilo target", async () => {
const result = await toolDetector.detectTool("kilocode");
assert.ok(result !== null);
assert.strictEqual(result!.id, "kilo");
assert.strictEqual(result!.name, "Kilo Code");
assert.ok(result!.configPath.includes(".local/share/kilo/auth.json"));
});
}); });
describe("detectAllTools", () => { describe("detectAllTools", () => {
@@ -85,11 +93,14 @@ describe("tool-detector", () => {
it("includes openclaw in the detected tools list", async () => { it("includes openclaw in the detected tools list", async () => {
const tools = await toolDetector.detectAllTools(); const tools = await toolDetector.detectAllTools();
const openclaw = tools.find((t) => t.id === "openclaw"); const openclaw = tools.find((t) => t.id === "openclaw");
assert.ok(openclaw !== undefined, "detectAllTools() must include an entry with id='openclaw'"); assert.ok(
openclaw !== undefined,
"detectAllTools() must include an entry with id='openclaw'"
);
assert.strictEqual(openclaw!.name, "OpenClaw"); assert.strictEqual(openclaw!.name, "OpenClaw");
assert.ok( assert.ok(
openclaw!.configPath.includes(".openclaw/openclaw.json"), openclaw!.configPath.includes(".openclaw/openclaw.json"),
`expected configPath to include '.openclaw/openclaw.json', got: ${openclaw!.configPath}`, `expected configPath to include '.openclaw/openclaw.json', got: ${openclaw!.configPath}`
); );
}); });
}); });

View File

@@ -258,7 +258,7 @@ test("createProgram wires the remote-mode commands into the real CLI program", a
} }
const contexts = program.commands.find((c: any) => c.name() === "contexts"); const contexts = program.commands.find((c: any) => c.name() === "contexts");
const subs = contexts.commands.map((c: any) => c.name()); const subs = contexts.commands.map((c: any) => c.name());
for (const sub of ["list", "use", "current"]) { for (const sub of ["list", "use", "current", "migrate"]) {
assert.ok(subs.includes(sub), `expected 'contexts ${sub}' subcommand, got: ${subs.join(", ")}`); assert.ok(subs.includes(sub), `expected 'contexts ${sub}' subcommand, got: ${subs.join(", ")}`);
} }
}); });

View File

@@ -9,7 +9,7 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
const { getCliRuntimeStatus, getKnownToolPaths, CLI_TOOL_IDS } = const { getCliRuntimeStatus, getKnownToolPaths, normalizeCliToolId, CLI_TOOL_IDS } =
await import("../../src/shared/services/cliRuntime.ts"); await import("../../src/shared/services/cliRuntime.ts");
// ─── Helpers ────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────
@@ -81,6 +81,16 @@ describe("CLI_TOOL_IDS", () => {
}); });
}); });
describe("CLI tool id compatibility aliases", () => {
it("normalizes legacy binary names without creating duplicate ids", () => {
assert.equal(normalizeCliToolId("kilocode"), "kilo");
assert.equal(normalizeCliToolId("kilo-code"), "kilo");
assert.equal(normalizeCliToolId("openai-codex"), "codex");
assert.equal(normalizeCliToolId("cc"), "claude");
assert.equal(normalizeCliToolId("unknown-tool"), "unknown-tool");
});
});
// ─── Size Threshold (30 bytes) ──────────────────────────────── // ─── Size Threshold (30 bytes) ────────────────────────────────
describe("Size threshold — checkKnownPath", () => { describe("Size threshold — checkKnownPath", () => {

View File

@@ -10,6 +10,7 @@ const {
normalizeCliCompatProviderId, normalizeCliCompatProviderId,
} = await import("../../src/shared/constants/cliCompatProviders.ts"); } = await import("../../src/shared/constants/cliCompatProviders.ts");
const { CLI_TOOL_IDS } = await import("../../src/shared/services/cliRuntime.ts"); const { CLI_TOOL_IDS } = await import("../../src/shared/services/cliRuntime.ts");
const { hasRegisteredAgent } = await import("../../src/lib/acp/registry.ts");
const { applyFingerprint, isCliCompatEnabled, setCliCompatProviders } = const { applyFingerprint, isCliCompatEnabled, setCliCompatProviders } =
await import("../../open-sse/config/cliFingerprints.ts"); await import("../../open-sse/config/cliFingerprints.ts");
@@ -31,6 +32,11 @@ test("Hermes quick-config is registered as a guide-based CLI tool", () => {
assert.ok(CLI_TOOL_IDS.includes("hermes")); assert.ok(CLI_TOOL_IDS.includes("hermes"));
}); });
test("ACP registry accepts the Gemini CLI target used by the manager", () => {
assert.equal(hasRegisteredAgent("gemini"), true);
assert.equal(hasRegisteredAgent("definitely-not-an-agent"), false);
});
test("CLI fingerprint toggles only expose implemented fingerprints and functional legacy aliases", () => { test("CLI fingerprint toggles only expose implemented fingerprints and functional legacy aliases", () => {
const implemented = new Set<string>(IMPLEMENTED_CLI_FINGERPRINT_PROVIDER_IDS); const implemented = new Set<string>(IMPLEMENTED_CLI_FINGERPRINT_PROVIDER_IDS);

View File

@@ -0,0 +1,127 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
CLI_TARGET_MANIFEST,
listManifestTargets,
manifestModelArgs,
manifestRequiresModel,
resolveManifestTarget,
} from "../../../bin/cli/cli-manifest.mjs";
import { listRunTargets, resolveRunTarget } from "../../../bin/cli/commands/run.mjs";
import { listConfigureTargets, SETUP_MODULES } from "../../../bin/cli/commands/configure.mjs";
import { runCompletionCommand } from "../../../bin/cli/commands/completion.mjs";
import {
CLI_TOOL_IDS,
CLI_TOOL_ALIASES,
normalizeCliToolId,
getCliConfigPaths,
} from "../../../src/shared/services/cliRuntime";
import { getCliTool } from "../../../src/shared/constants/cliTools";
/**
* Drift guard for the executable manifest (`bin/cli/cli-manifest.mjs`).
*
* The manifest is the single declaration of which targets `omniroute run` /
* `omniroute configure` / shell completion expose. These assertions fail as
* soon as any consumer surface — or the server-side runtime catalog — starts
* disagreeing with it silently.
*/
const manifestIds = Object.keys(CLI_TARGET_MANIFEST);
test("every manifest target is a canonical id in the runtime catalog", () => {
for (const [id, entry] of Object.entries(CLI_TARGET_MANIFEST)) {
assert.equal(normalizeCliToolId(id), id, `${id} must be canonical (not an alias)`);
assert.ok(CLI_TOOL_IDS.includes(id), `${id} must exist in cliRuntime CLI_TOOLS`);
assert.ok(getCliConfigPaths(id), `${id} must resolve config paths in the runtime`);
// Configure targets surface in the dashboard picker flows, so they must be
// cataloged for the UI. Run-only targets (e.g. gemini) may stay CLI-only.
if (entry.configure) {
assert.ok(getCliTool(id), `${id} must exist in the UI catalog (cliTools.ts)`);
}
}
});
test("manifest aliases never conflict with runtime aliases", () => {
for (const [id, entry] of Object.entries(CLI_TARGET_MANIFEST)) {
for (const alias of entry.aliases) {
const runtimeTarget = CLI_TOOL_ALIASES[alias];
if (runtimeTarget !== undefined) {
assert.equal(
runtimeTarget,
id,
`alias '${alias}' maps to '${id}' in the manifest but '${runtimeTarget}' in cliRuntime`
);
}
}
}
});
test("kilocode variants stay a single canonical target in both worlds", () => {
for (const legacy of ["kilocode", "kilo-code", "kilo_cli"]) {
assert.equal(resolveManifestTarget(legacy, "configure"), "kilo");
assert.equal(normalizeCliToolId(legacy), "kilo");
}
});
test("run command derives targets and aliases from the manifest", () => {
assert.deepEqual(listRunTargets(), listManifestTargets("run"));
for (const [id, entry] of Object.entries(CLI_TARGET_MANIFEST)) {
const expected = entry.run ? id : undefined;
assert.equal(resolveRunTarget(id), expected, `resolveRunTarget(${id})`);
for (const alias of entry.aliases) {
assert.equal(resolveRunTarget(alias), expected, `resolveRunTarget(${alias})`);
}
}
assert.equal(resolveRunTarget("definitely-not-a-cli"), undefined);
});
test("configure command derives targets from the manifest and has a recipe per target", () => {
assert.deepEqual(listConfigureTargets(), listManifestTargets("configure"));
for (const id of listManifestTargets("configure")) {
const hasRecipe = id === "codex" || Boolean(SETUP_MODULES[id]);
assert.ok(hasRecipe, `configure target '${id}' has no setup recipe`);
}
for (const id of Object.keys(SETUP_MODULES)) {
assert.ok(
listManifestTargets("configure").includes(id),
`setup recipe '${id}' is not a manifest configure target`
);
}
});
test("completion scripts embed the manifest-derived target lists", async () => {
const runWords = listManifestTargets("run").join(" ");
const configureWords = listManifestTargets("configure").join(" ");
for (const shell of ["bash", "zsh", "fish"] as const) {
const chunks: string[] = [];
const originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = ((chunk: unknown) => {
if (typeof chunk === "string") chunks.push(chunk);
return true;
}) as typeof process.stdout.write;
try {
assert.equal(await runCompletionCommand(shell), 0);
} finally {
process.stdout.write = originalWrite;
}
const output = chunks.join("");
assert.ok(output.includes(runWords), `${shell} completion must list run targets`);
assert.ok(output.includes(configureWords), `${shell} completion must list configure targets`);
}
});
test("model-flag wiring stays declared in the manifest", () => {
assert.deepEqual(manifestModelArgs("aider", "glm/glm-5.2"), ["--model", "openai/glm/glm-5.2"]);
assert.deepEqual(manifestModelArgs("opencode", "glm/glm-5.2"), [
"--model",
"omniroute/glm/glm-5.2",
]);
assert.deepEqual(manifestModelArgs("qwen", "glm/glm-5.2"), ["--model", "glm/glm-5.2"]);
assert.deepEqual(manifestModelArgs("claude", "glm/glm-5.2"), []);
assert.deepEqual(manifestModelArgs("codex", "glm/glm-5.2"), []);
assert.equal(manifestRequiresModel("qwen"), true);
assert.equal(manifestRequiresModel("aider"), false);
});

View File

@@ -0,0 +1,76 @@
import test from "node:test";
import assert from "node:assert/strict";
const {
listConfigureTargets,
profileNameFromModel,
resolveConfigureTargetOptions,
rankPreferredModels,
getModelPreferenceState,
} = await import("../../../bin/cli/commands/configure.mjs");
test("configure picker exposes setup-backed CLI targets (manifest declaration order)", () => {
assert.deepEqual(listConfigureTargets(), [
"claude",
"codex",
"aider",
"goose",
"opencode",
"qwen",
"cline",
"continue",
"kilo",
]);
});
test("configure picker derives stable profile names from provider/model ids", () => {
assert.equal(profileNameFromModel("glm/glm-5.2"), "glm52");
assert.equal(profileNameFromModel("claude-sonnet-4.6"), "claudesonnet46");
});
test("configure picker materializes explicit remote/base-url targets", () => {
assert.deepEqual(
resolveConfigureTargetOptions({
baseUrl: "https://relay.example.test/v1",
apiKey: "sk_test",
port: "2999",
}),
{
baseUrl: "https://relay.example.test/v1",
remote: "https://relay.example.test/v1",
apiKey: "sk_test",
port: "2999",
}
);
});
test("configure picker ranks favorites and recent model ids without leaking context data", () => {
const ranked = rankPreferredModels("codex", ["glm/slow", "glm/fast", "qwen/recent"], {
targets: { codex: { favorites: ["glm/fast"], recent: ["qwen/recent"] } },
});
assert.deepEqual(ranked, ["glm/fast", "qwen/recent", "glm/slow"]);
assert.deepEqual(
getModelPreferenceState("codex", {
targets: { codex: { favorites: ["glm/fast"], recent: ["qwen/recent"] } },
}),
{ favorites: ["glm/fast"], recent: ["qwen/recent"] }
);
});
test("configure picker keeps preferences isolated per remote context", () => {
const preferences = {
targets: {},
contexts: {
local: { codex: { favorites: ["local/model"], recent: [] } },
remote: { codex: { favorites: ["remote/model"], recent: [] } },
},
};
assert.deepEqual(
rankPreferredModels("codex", ["local/model", "remote/model"], preferences, "remote"),
["remote/model", "local/model"]
);
assert.deepEqual(getModelPreferenceState("codex", preferences, "local"), {
favorites: ["local/model"],
recent: [],
});
});

View File

@@ -0,0 +1,136 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildProviderPayload,
findConnectionFromResponse,
redactProviderResponse,
resolveProviderCredential,
runProviderAddCommand,
} from "../../../bin/cli/commands/provider-crud.mjs";
test("provider payload separates management auth from provider credential", () => {
const payload = buildProviderPayload(
"glm",
{
name: "work",
defaultModel: "glm/glm-5.2",
priority: "2",
providerSpecificData: '{"region":"global"}',
apiKey: "management-token-that-must-not-be-used",
},
"provider-secret"
);
assert.deepEqual(payload, {
provider: "glm",
name: "work",
apiKey: "provider-secret",
defaultModel: "glm/glm-5.2",
priority: 2,
providerSpecificData: { region: "global" },
});
});
test("provider selector resolves id, prefix, name, and provider", () => {
const body = {
connections: [
{ id: "abc-123", name: "Work GLM", provider: "glm" },
{ id: "def-456", name: "OpenAI", provider: "openai" },
],
};
assert.equal(findConnectionFromResponse(body, "abc-123")?.name, "Work GLM");
assert.equal(findConnectionFromResponse(body, "def")?.name, "OpenAI");
assert.equal(findConnectionFromResponse(body, "work glm")?.id, "abc-123");
assert.equal(findConnectionFromResponse(body, "openai")?.id, "def-456");
assert.equal(findConnectionFromResponse(body, "missing"), null);
});
test("provider credential can be resolved from a validated environment name", async () => {
const previous = process.env.TEST_PROVIDER_SECRET;
process.env.TEST_PROVIDER_SECRET = "secret-from-env";
try {
assert.equal(
await resolveProviderCredential({ credentialEnv: "TEST_PROVIDER_SECRET" }, { prompt: false }),
"secret-from-env"
);
await assert.rejects(
resolveProviderCredential({ credentialEnv: "bad-name;rm" }, { prompt: false }),
/valid env name/
);
} finally {
if (previous === undefined) delete process.env.TEST_PROVIDER_SECRET;
else process.env.TEST_PROVIDER_SECRET = previous;
}
});
test("dry-run credential resolution never prompts or requires a secret", async () => {
assert.equal(await resolveProviderCredential({}, { prompt: false }), undefined);
assert.deepEqual(buildProviderPayload("glm", { name: "work" }, undefined), {
provider: "glm",
name: "work",
});
});
test("negated --no-credential is treated as a control flag, not the literal string", async () => {
assert.equal(
await resolveProviderCredential({ credential: false }, { prompt: false }),
undefined
);
assert.deepEqual(buildProviderPayload("ollama", { name: "local" }, undefined), {
provider: "ollama",
name: "local",
});
});
test("provider JSON output redacts raw credentials recursively", () => {
const redacted = redactProviderResponse({
connection: {
id: "conn-1",
apiKey: "provider-secret",
providerSpecificData: { client_secret: "oauth-secret" },
credentialRef: "omniroute-cli:context:remote",
},
token: "management-secret",
});
assert.deepEqual(redacted, {
connection: {
id: "conn-1",
apiKey: { present: true, length: 15 },
providerSpecificData: { client_secret: { present: true, length: 12 } },
credentialRef: "omniroute-cli:context:remote",
},
token: { present: true, length: 17 },
});
});
test("provider OAuth dry-run never starts a browser or mutates the server", async () => {
assert.equal(
await runProviderAddCommand("openai", { oauth: true, dryRun: true, silent: true }),
0
);
});
test("provider add dry-run redacts provider-specific secrets", async () => {
const output: string[] = [];
const originalLog = console.log;
console.log = (...args: unknown[]) => output.push(args.join(" "));
try {
assert.equal(
await runProviderAddCommand("glm", {
dryRun: true,
yes: true,
json: true,
providerSpecificData: JSON.stringify({ client_secret: "oauth-secret" }),
}),
0
);
} finally {
console.log = originalLog;
}
const serialized = output.join("\n");
assert.ok(!serialized.includes("oauth-secret"));
assert.match(serialized, /client_secret/);
});

View File

@@ -13,8 +13,10 @@ test("resolveRunTarget resolves aliases", () => {
assert.equal(resolveRunTarget("CLAUDE-CODE"), "claude"); assert.equal(resolveRunTarget("CLAUDE-CODE"), "claude");
assert.equal(resolveRunTarget("cc"), "claude"); assert.equal(resolveRunTarget("cc"), "claude");
assert.equal(resolveRunTarget("codex"), "codex"); assert.equal(resolveRunTarget("codex"), "codex");
assert.equal(resolveRunTarget("codex-cli"), "codex");
assert.equal(resolveRunTarget("openai-codex"), "codex"); assert.equal(resolveRunTarget("openai-codex"), "codex");
assert.equal(resolveRunTarget("openai"), "codex"); assert.equal(resolveRunTarget("openai"), "codex");
assert.equal(resolveRunTarget("anthropic"), "claude");
assert.equal(resolveRunTarget("unknown"), undefined); assert.equal(resolveRunTarget("unknown"), undefined);
}); });
@@ -60,6 +62,78 @@ test("buildRunPlan for codex injects model into provider args", async () => {
assert.equal(plan.authSource, "option"); assert.equal(plan.authSource, "option");
}); });
test("buildRunPlan for Aider uses its OpenAI-compatible root endpoint", async () => {
const plan = await buildRunPlan(
"aider",
{ remote: "https://relay.example.test/v1", apiKey: "sk_test_x", model: "glm/glm-5.2" },
["--message", "reply OK"]
);
assert.equal(plan.target, "aider");
assert.equal(plan.baseUrl, "https://relay.example.test");
assert.deepEqual(plan.args.slice(0, 2), ["--model", "openai/glm/glm-5.2"]);
assert.equal(plan.envDiff.changedOrAdded.includes("OPENAI_API_BASE"), true);
assert.equal(plan.envDiff.changedOrAdded.includes("OPENAI_API_KEY"), true);
});
test("buildRunPlan for Goose injects provider and model without writing config", async () => {
const plan = await buildRunPlan(
"goose-cli",
{ baseUrl: "http://localhost:20128", apiKey: "sk_test_x", model: "glm/glm-5.2" },
["session"]
);
assert.equal(plan.target, "goose");
assert.deepEqual(plan.args, ["session"]);
assert.equal(plan.envDiff.changedOrAdded.includes("GOOSE_PROVIDER"), true);
assert.equal(plan.envDiff.changedOrAdded.includes("GOOSE_MODEL"), true);
assert.equal(plan.envDiff.changedOrAdded.includes("OPENAI_HOST"), true);
});
test("buildRunPlan for OpenCode uses an ephemeral compatible config", async () => {
const plan = await buildRunPlan(
"open-code",
{ baseUrl: "https://relay.example.test", apiKey: "sk_test_x", model: "glm/glm-5.2" },
["run", "reply OK"]
);
assert.equal(plan.target, "opencode");
assert.deepEqual(plan.args.slice(0, 2), ["--model", "omniroute/glm/glm-5.2"]);
assert.equal(plan.envDiff.changedOrAdded.includes("OPENCODE_CONFIG_CONTENT"), true);
assert.equal(plan.envDiff.changedOrAdded.includes("OMNIROUTE_API_KEY"), true);
assert.equal(plan.configOverlay, "OPENCODE_CONFIG_CONTENT (process environment only)");
assert.equal(JSON.stringify(plan).includes("sk_test_x"), false);
});
test("buildRunPlan for Qwen requires a deterministic model and injects only env names", async () => {
const plan = await buildRunPlan(
"qwen-code",
{ baseUrl: "https://relay.example.test", apiKey: "sk_test_x", model: "glm/glm-5.2" },
["-p", "reply OK"]
);
assert.equal(plan.target, "qwen");
assert.deepEqual(plan.args.slice(0, 2), ["--model", "glm/glm-5.2"]);
assert.equal(plan.envDiff.changedOrAdded.includes("OMNIROUTE_API_KEY"), true);
assert.equal(plan.configOverlay, "temporary QWEN_HOME (removed after exit)");
await assert.rejects(
() => buildRunPlan("qwen", { baseUrl: "https://relay.example.test", apiKey: "sk_test_x" }),
/requires --model/
);
});
test("buildRunPlan for Gemini points the CLI at the /v1beta surface via env", async () => {
const plan = await buildRunPlan(
"gemini-cli",
{ baseUrl: "https://relay.example.test", apiKey: "sk_test_x", model: "glm/glm-5.2" },
["-p", "reply OK"]
);
assert.equal(plan.target, "gemini");
assert.equal(plan.baseUrl, "https://relay.example.test");
assert.deepEqual(plan.args.slice(0, 2), ["--model", "glm/glm-5.2"]);
assert.equal(plan.envDiff.changedOrAdded.includes("GOOGLE_GEMINI_BASE_URL"), true);
assert.equal(plan.envDiff.changedOrAdded.includes("GEMINI_API_KEY"), true);
assert.equal(plan.envDiff.changedOrAdded.includes("GEMINI_DEFAULT_AUTH_TYPE"), true);
assert.equal(plan.configOverlay, "temporary GEMINI_CLI_HOME (removed after exit)");
assert.equal(JSON.stringify(plan).includes("sk_test_x"), false);
});
test("runCliTarget returns usage error code for unsupported targets", async () => { test("runCliTarget returns usage error code for unsupported targets", async () => {
const seen = []; const seen = [];
const originalWrite = process.stderr.write; const originalWrite = process.stderr.write;
@@ -99,3 +173,17 @@ test("dry-run --json does not print resolved auth token", async () => {
const raw = chunks.join(""); const raw = chunks.join("");
assert.equal(raw.includes("sk_live_very_private_token"), false); assert.equal(raw.includes("sk_live_very_private_token"), false);
}); });
test("--api-key-env resolves credentials without exposing their value in the plan", async () => {
const previous = process.env.OMNIROUTE_RUN_TEST_TOKEN;
process.env.OMNIROUTE_RUN_TEST_TOKEN = "sk_env_private";
try {
const plan = await buildRunPlan("codex-cli", { apiKeyEnv: "OMNIROUTE_RUN_TEST_TOKEN" });
assert.equal(plan.authSource, "env");
assert.equal(plan.envDiff.changedOrAdded.includes("OMNIROUTE_API_KEY"), true);
assert.equal(JSON.stringify(plan).includes("sk_env_private"), false);
} finally {
if (previous === undefined) delete process.env.OMNIROUTE_RUN_TEST_TOKEN;
else process.env.OMNIROUTE_RUN_TEST_TOKEN = previous;
}
});

View File

@@ -0,0 +1,170 @@
import test from "node:test";
import assert from "node:assert/strict";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { runCliTarget } from "../../../bin/cli/commands/run.mjs";
const originalFetch = globalThis.fetch;
const originalPath = process.env.PATH;
async function makeFakeCli(name: string, body: string) {
const dir = await mkdtemp(path.join(os.tmpdir(), "omniroute-run-cli-"));
const file = path.join(dir, name);
await writeFile(file, `#!/usr/bin/env node\n${body}\n`, { mode: 0o755 });
await chmod(file, 0o755);
return { dir, file };
}
async function withReachableOmniRoute<T>(run: () => Promise<T>): Promise<T> {
globalThis.fetch = async () => new Response("{}", { status: 200 });
try {
return await run();
} finally {
globalThis.fetch = originalFetch;
}
}
test("run executes a generic target with isolated env and propagates its exit code", async (t) => {
if (process.platform === "win32") {
t.skip("POSIX fake executable; Windows shim behavior is covered by launch tests");
return;
}
const capture = await mkdtemp(path.join(os.tmpdir(), "omniroute-run-capture-"));
const capturePath = path.join(capture, "aider.json");
const fake = await makeFakeCli(
"aider",
`const fs = await import("node:fs");
fs.writeFileSync(process.env.CAPTURE_PATH, JSON.stringify({
argv: process.argv.slice(2),
base: process.env.OPENAI_API_BASE,
key: process.env.OPENAI_API_KEY,
}));
process.exit(7);`
);
process.env.PATH = `${fake.dir}${path.delimiter}${originalPath || ""}`;
process.env.CAPTURE_PATH = capturePath;
try {
const code = await withReachableOmniRoute(() =>
runCliTarget(
"aider",
{ remote: "https://relay.example.test", apiKey: "sk_private", model: "glm/glm-5.2" },
["--message", "reply OK"]
)
);
assert.equal(code, 7);
const result = JSON.parse(await readFile(capturePath, "utf8"));
assert.deepEqual(result.argv.slice(0, 2), ["--model", "openai/glm/glm-5.2"]);
assert.deepEqual(result.argv.slice(2), ["--message", "reply OK"]);
assert.equal(result.base, "https://relay.example.test");
assert.equal(result.key, "sk_private");
} finally {
if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath;
delete process.env.CAPTURE_PATH;
await rm(fake.dir, { recursive: true, force: true });
await rm(capture, { recursive: true, force: true });
}
});
test("run gives Gemini an isolated GEMINI_CLI_HOME forcing api-key auth and removes it", async (t) => {
if (process.platform === "win32") {
t.skip("POSIX fake executable; Windows shim behavior is covered by launch tests");
return;
}
const capture = await mkdtemp(path.join(os.tmpdir(), "omniroute-run-gemini-capture-"));
const capturePath = path.join(capture, "gemini.json");
const fake = await makeFakeCli(
"gemini",
`const fs = await import("node:fs");
const path = await import("node:path");
const home = process.env.GEMINI_CLI_HOME;
const settings = JSON.parse(fs.readFileSync(path.join(home, ".gemini", "settings.json"), "utf8"));
fs.writeFileSync(process.env.CAPTURE_PATH, JSON.stringify({
home,
argv: process.argv.slice(2),
baseUrl: process.env.GOOGLE_GEMINI_BASE_URL,
key: process.env.GEMINI_API_KEY,
defaultAuth: process.env.GEMINI_DEFAULT_AUTH_TYPE,
selectedType: settings.security?.auth?.selectedType,
}));`
);
process.env.PATH = `${fake.dir}${path.delimiter}${originalPath || ""}`;
process.env.CAPTURE_PATH = capturePath;
try {
const code = await withReachableOmniRoute(() =>
runCliTarget(
"gemini",
{ remote: "https://relay.example.test", apiKey: "sk_private", model: "glm/glm-5.2" },
["-p", "reply OK"]
)
);
assert.equal(code, 0);
const result = JSON.parse(await readFile(capturePath, "utf8"));
assert.deepEqual(result.argv, ["--model", "glm/glm-5.2", "-p", "reply OK"]);
assert.equal(result.baseUrl, "https://relay.example.test");
assert.equal(result.key, "sk_private");
assert.equal(result.defaultAuth, "gemini-api-key");
assert.equal(result.selectedType, "gemini-api-key");
assert.equal(existsSync(result.home), false);
} finally {
if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath;
delete process.env.CAPTURE_PATH;
await rm(fake.dir, { recursive: true, force: true });
await rm(capture, { recursive: true, force: true });
}
});
test("run gives Qwen an isolated temporary home and removes it after exit", async (t) => {
if (process.platform === "win32") {
t.skip("POSIX fake executable; Windows shim behavior is covered by launch tests");
return;
}
const capture = await mkdtemp(path.join(os.tmpdir(), "omniroute-run-qwen-capture-"));
const capturePath = path.join(capture, "qwen.json");
const fake = await makeFakeCli(
"qwen",
`const fs = await import("node:fs");
const path = await import("node:path");
const home = process.env.QWEN_HOME;
const settings = JSON.parse(fs.readFileSync(path.join(home, "settings.json"), "utf8"));
fs.writeFileSync(process.env.CAPTURE_PATH, JSON.stringify({
home,
argv: process.argv.slice(2),
model: settings.model?.name,
baseUrl: settings.model?.baseUrl,
}));`
);
process.env.PATH = `${fake.dir}${path.delimiter}${originalPath || ""}`;
process.env.CAPTURE_PATH = capturePath;
try {
const code = await withReachableOmniRoute(() =>
runCliTarget(
"qwen",
{ remote: "https://relay.example.test", apiKey: "sk_private", model: "glm/glm-5.2" },
["-p", "reply OK"]
)
);
assert.equal(code, 0);
const result = JSON.parse(await readFile(capturePath, "utf8"));
assert.deepEqual(result.argv, ["--model", "glm/glm-5.2", "-p", "reply OK"]);
assert.equal(result.model, "glm/glm-5.2");
assert.equal(result.baseUrl, "https://relay.example.test/v1");
assert.equal(existsSync(result.home), false);
} finally {
if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath;
delete process.env.CAPTURE_PATH;
await rm(fake.dir, { recursive: true, force: true });
await rm(capture, { recursive: true, force: true });
}
});