Files
OmniRoute/bin/cli/contexts.mjs
Xiangzhe 0a74bfbdea 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
2026-08-18 08:25:16 -03:00

278 lines
9.6 KiB
JavaScript

import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
import { join, dirname } from "node:path";
import { resolveDataDir } from "./data-dir.mjs";
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() {
return join(resolveDataDir(), "config.json");
}
function defaultConfig() {
return {
version: CONFIG_VERSION,
currentContext: "default",
contexts: {
default: { baseUrl: `http://localhost:${process.env.PORT || "20128"}`, apiKey: null },
},
};
}
export function loadContexts() {
return readConfigFile();
}
/**
* Synchronous compatibility writer. New credential-bearing code should use
* `saveContextsSecure()` so tokens are moved to the OS keychain when possible.
*/
export function saveContexts(cfg) {
const path = configPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(cfg, null, 2));
try {
chmodSync(path, 0o600);
} 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.
*
* Canonical schema is `{ currentContext, contexts }` (written by
* `omniroute contexts ...`). For backward compatibility we also read the legacy
* `{ activeProfile, profiles }` shape and a bare top-level `baseUrl` — older
* configs and `api.mjs::getBaseUrl` used those before remote-mode unified the
* store. `overrideName` (from `--context`/`OMNIROUTE_CONTEXT`) wins when set.
*
* A context may carry `{ baseUrl, accessToken?, apiKey?, scope?, description? }`.
* `accessToken` is the scoped CLI access token (preferred); `apiKey` is the
* legacy inference key kept for back-compat.
*/
export function resolveActiveContext(overrideName) {
const cfg = loadContexts();
const contexts = cfg.contexts || cfg.profiles || {};
const name = overrideName || cfg.currentContext || cfg.activeProfile || "default";
const found = contexts[name] || contexts.default;
if (found) return applyCachedCredential(found);
if (cfg.baseUrl) return { baseUrl: cfg.baseUrl };
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);
}