From ac291d5020923514aad63500f5ee41ebe5b4e957 Mon Sep 17 00:00:00 2001 From: Nur Ad-Duja Date: Mon, 27 Jul 2026 13:41:15 +0700 Subject: [PATCH 1/3] feat!: add mixed model routing Replace persistent provider switching with process-scoped wrappers so native and Kenari models can share one CLI session without storing Kenari credentials in tool configuration. BREAKING CHANGE: Remove kenari use and kenari key. Use configure, login, logout, and the claude or codex wrapper commands. --- README.md | 197 ++++---- package.json | 4 +- src/adapters/claude.js | 107 +--- src/adapters/codex.js | 129 +---- src/catalog.js | 119 +++++ src/cli.js | 766 ++++++++++++++++++++--------- src/config.js | 101 ++++ src/gateway.js | 44 +- src/migrate.js | 241 +++++++++ src/paths.js | 4 + src/router.js | 304 ++++++++++++ src/runtime/claude.js | 48 ++ src/runtime/codex.js | 93 ++++ src/store.js | 34 +- src/supervisor.js | 74 +++ test/claude.test.js | 119 +---- test/cli.test.js | 309 +++++++----- test/codex.test.js | 162 +----- test/contract.test.js | 181 ++++--- test/gateway.test.js | 10 +- test/store.test.js | 4 +- test/v2-config-catalog.test.js | 131 +++++ test/v2-migrate.test.js | 187 +++++++ test/v2-router.test.js | 157 ++++++ test/v2-runtime-supervisor.test.js | 163 ++++++ 25 files changed, 2676 insertions(+), 1012 deletions(-) create mode 100644 src/catalog.js create mode 100644 src/config.js create mode 100644 src/migrate.js create mode 100644 src/router.js create mode 100644 src/runtime/claude.js create mode 100644 src/runtime/codex.js create mode 100644 src/supervisor.js create mode 100644 test/v2-config-catalog.test.js create mode 100644 test/v2-migrate.test.js create mode 100644 test/v2-router.test.js create mode 100644 test/v2-runtime-supervisor.test.js diff --git a/README.md b/README.md index 531318b..948ae1b 100644 --- a/README.md +++ b/README.md @@ -1,145 +1,126 @@ # @kenarihq/cli -The official kenari CLI. It switches Claude Code and Codex to the [kenari](https://kenari.id) -gateway and back. When a tool is switched, its requests route through kenari and bill your -kenari wallet. Restoring puts the tool back on its original provider. - -## Install and run - -Run it once without installing: - -``` -npx @kenarihq/cli -``` - -Or install it globally and use the `kenari` command: +Route native and Kenari models inside one Claude Code or Codex CLI session. +Plain `claude` and `codex` stay native. Kenari routing only applies when launched +through the wrapper: +```bash +kenari claude [args...] +kenari codex [args...] ``` -npm i -g @kenarihq/cli -kenari -``` - -Node 18 or newer is required. -## Quick start +Each launch starts a private router on `127.0.0.1` with a random port, starts the +original CLI with process-scoped overrides, forwards terminal I/O and signals, +then stops the router. No provider setting or credential is written into Claude +Code or Codex configuration. -Sign in and store an API key: +## Install -``` +```bash +npm install -g @kenarihq/cli kenari login +kenari configure ``` -This opens your browser to approve the CLI, then stores the key for you, no copy-pasting a key -by hand. On a machine with no browser, or where the loopback callback is blocked (a remote SSH -box), use `kenari login --paste` instead: it prints a URL to open elsewhere and asks you to -paste back a short code. Add `--no-browser` to either mode to print the URL instead of opening -it automatically. +Node.js 18 or newer is required. -Point Claude Code at kenari: - -``` -kenari use claude -``` - -If no key is stored yet, this asks for one (`kn-...`, from https://kenari.id/keys) directly; run -`kenari login` first if you would rather authenticate that way. It then lets you pick a kenari -model for each Claude slot (opus, sonnet, haiku). - -Check what each tool points at: - -``` -kenari status -``` - -Put Claude Code back on its original provider: +## Commands -``` -kenari use claude default +```text +kenari configure [claude|codex] +kenari reset [claude|codex] +kenari claude [args...] +kenari codex [args...] +kenari status [--check] [--json] +kenari models [--json] +kenari login [--no-browser] [--paste] [--stdin] +kenari logout +kenari help ``` -## Commands +`kenari use` and `kenari key` were removed. Routing is process-scoped and +credentials are managed by `login` and `logout`. -| Command | What it does | -| --- | --- | -| `kenari` | Interactive switcher. Lists installed tools and lets you pick a target. | -| `kenari login` | Sign in through the browser (PKCE) and store an API key. | -| `kenari login --no-browser` | Print the approval URL instead of opening it. | -| `kenari login --paste` | Paste-code mode, for a blocked loopback or a remote box. | -| `kenari use ` | Switch `` to kenari. Prompts for models unless you pass flags. | -| `kenari use default` | Restore `` to the provider it used before the switch. | -| `kenari status` | Show whether each tool points at kenari or its default. | -| `kenari models` | List kenari models with input and output prices. | -| `kenari key set` | Store a kenari API key (prompted, or `--stdin` to pipe it in). | -| `kenari key show` | Print the stored key, masked. | -| `kenari key delete` | Remove the stored key. | +## Routing configuration -Tools are `claude` (Claude Code) and `codex` (Codex). +Claude Code supports these roles: -Model flags for `kenari use`: +- `main` +- `opus` +- `sonnet` +- `haiku` +- `fable` +- `subagents` -- Claude has three slots: `--opus --sonnet --haiku `. -- Codex has one slot: `--model `. +Codex supports these roles: -Pass any of these to skip the interactive prompt. A model that is not in the kenari catalog is -rejected. Run `kenari models` to see valid ids. +- `main` +- `review` +- `subagents` -## How login works +Every role can stay `native` or select a fixed `kenari/`. Codex main +also supports `picker`. Codex review and subagents also support `inherit`. +Native is always the default. -`kenari login` opens `https://kenari.id/cli-auth` in your browser with a PKCE challenge and -binds a one-shot local server on a random loopback port. Approving in the browser redirects -back to that local server with a single-use code; the CLI exchanges it for an API key over -`https://kenari.id/api/cli-auth/token` and stores the key. The verifier never leaves your -machine, and the flow times out after 5 minutes if nothing comes back. `kenari login --paste` -skips the local server entirely: it shows a short code in the browser that you type back into -the terminal, for machines where a browser cannot reach a loopback port on your machine (a -remote SSH box) or where you would rather not open one automatically. +Automation must set every role: -## How switching works +```bash +kenari configure claude \ + --main native \ + --opus native \ + --sonnet kenari/glm-5-2 \ + --haiku native \ + --fable native \ + --subagents native \ + --yes -A switch writes the env block in `~/.claude/settings.json` and the provider entry in -`~/.codex/config.toml`, and records the previous values first. Restore puts those previous -values back and never touches keys it does not own. If you hand-edit an owned key while a tool -is switched, restore leaves that key alone and tells you which ones it skipped. Config file -locations follow `CLAUDE_CONFIG_DIR` and `CODEX_HOME` when those are set. +kenari configure codex \ + --main picker \ + --review inherit \ + --subagents kenari/gpt-5.4 \ + --yes +``` -## Key storage +Unprefixed model IDs always route to the native provider. `kenari/` model IDs +always route to Kenari. Routing never falls back between providers. -The API key is stored in plaintext at `~/.kenari/credentials.json`. On POSIX systems the file -is written with mode 0600 (owner read and write only). On Windows the file is protected only by -your user profile ACLs. Treat the key like a password: anyone who can read the file can spend -your wallet. Remove it any time with `kenari key delete`. +## Files -## Minimum tested versions +Kenari owns these files: -The versions below are the tool releases this CLI has been verified against. +```text +~/.kenari/config.json +~/.kenari/credentials.json +~/.kenari/model-cache.json +~/.kenari/state.json +``` -| Tool | Minimum tested version | Status | -| --- | --- | --- | -| Claude Code | 2.1.206 | verified end to end (switch, tool loop through kenari, restore) | -| Codex | 0.144.1 | verified end to end (switch, text and tool loop through kenari, restore) | +The directory uses mode `0700` and files use mode `0600` on POSIX systems. +Writes are atomic. The Kenari credential is stored only in +`credentials.json`, never in Claude Code settings, Codex configuration, +generated model catalogs, command arguments, or logs. -**Codex wire.** Codex removed the `chat/completions` wire protocol and now requires the -OpenAI `responses` API. The kenari gateway serves `/v1/responses`, so the Codex adapter -writes `wire_api = "responses"`. A switched Codex runs against kenari for both plain -prompts and tool loops. +`KENARI_HOME`, `CLAUDE_CONFIG_DIR`, and `CODEX_HOME` override their matching +directories. `KENARI_BASE_URL` overrides the Kenari gateway. HTTP gateways are +rejected unless `KENARI_ALLOW_HTTP=1` is set for local development. -## Troubleshooting +## Catalog and offline behavior -**"is a symlink. Refusing to replace it with a regular file."** The CLI will not overwrite a -config file that is a symlink, because replacing it would break the link. Point the CLI at the -real file, or edit the config by hand. +`kenari configure` fetches and validates the model catalog. The cache is fresh +for 24 hours. -**"another kenari process is running."** Only one `kenari` command can modify config at a time. -Wait for the other run to finish, then retry. If no other run exists, a stale lock in -`~/.kenari/lock` is cleared automatically after a few seconds. +- Native-only configuration works without login or catalog access. +- A stale valid cache can be used when refresh fails. +- A required Kenari route without a valid cache or credential fails closed. +- A removed or unknown Kenari model fails closed. +- `kenari status` is offline. `kenari status --check` runs bounded network + checks. -**"kenari rejected the API key (HTTP 401)."** The stored key is missing, wrong, or revoked. Run -`kenari login` to get a fresh one, or grab one at https://kenari.id/keys and run `kenari key -set`. +## Reset and logout -**"login timed out, run `kenari login` again."** Nobody approved the browser page within 5 -minutes. Run it again and approve promptly, or use `kenari login --paste` if the browser cannot -reach your machine's loopback port. +`kenari reset` removes routing choices without changing native tool +configuration or login state. `kenari logout` removes only the Kenari +credential. Native routes keep working after logout. ## License diff --git a/package.json b/package.json index 2ad5f6d..d4a9ad9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@kenarihq/cli", - "version": "0.2.0", - "description": "Official kenari CLI. Switch Claude Code and Codex to the kenari gateway.", + "version": "0.3.0", + "description": "Official kenari CLI. Route Claude Code and Codex models per process.", "license": "MIT", "type": "module", "bin": { "kenari": "bin/kenari.js" }, diff --git a/src/adapters/claude.js b/src/adapters/claude.js index 26682da..837401c 100644 --- a/src/adapters/claude.js +++ b/src/adapters/claude.js @@ -1,114 +1,13 @@ import fs from 'node:fs'; -import { claudeConfigDir, claudeSettingsPath, gatewayBase } from '../paths.js'; -import { readJson, writeJson, getToolState, setToolState, clearToolState } from '../store.js'; - -const SLOT_ENV = { - opus: 'ANTHROPIC_DEFAULT_OPUS_MODEL', - sonnet: 'ANTHROPIC_DEFAULT_SONNET_MODEL', - haiku: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', -}; - -function ownedValues(mapping, key) { - return { - ANTHROPIC_BASE_URL: gatewayBase(), - ANTHROPIC_AUTH_TOKEN: key, - // Pin the main loop too: without this, a session on an Anthropic-native - // model (for example claude-fable-5) sends that id through the gateway - // untouched, and if the gateway catalogs it, it bills at premium price. - ANTHROPIC_MODEL: mapping.sonnet, - [SLOT_ENV.opus]: mapping.opus, - [SLOT_ENV.sonnet]: mapping.sonnet, - [SLOT_ENV.haiku]: mapping.haiku, - }; -} +import { claudeConfigDir, claudeSettingsPath } from '../paths.js'; export default { id: 'claude', name: 'Claude Code', - slots: [ - { id: 'opus', label: 'Opus slot (strongest)', defaultModel: 'glm-5-2' }, - { id: 'sonnet', label: 'Sonnet slot (daily driver)', defaultModel: 'kimi-k2-7-code' }, - { id: 'haiku', label: 'Haiku slot (fast/background)', defaultModel: 'deepseek-v4-flash' }, - ], - detect() { - return { installed: fs.existsSync(claudeConfigDir()), configPath: claudeSettingsPath() }; - }, - - status() { - const settings = readJson(claudeSettingsPath()) || {}; - const env = settings.env || {}; - const onKenari = env.ANTHROPIC_BASE_URL === gatewayBase() - && typeof env.ANTHROPIC_AUTH_TOKEN === 'string' - && env.ANTHROPIC_AUTH_TOKEN.startsWith('kn-'); - const notes = []; - const state = getToolState('claude'); - if (state && !onKenari) notes.push('switch state exists but settings.json no longer points at kenari (hand-edited?)'); - if (!state && onKenari) notes.push('points at kenari but no switch state (configured outside this CLI)'); return { - provider: onKenari ? 'kenari' : 'default', - mapping: onKenari ? { - opus: env[SLOT_ENV.opus], sonnet: env[SLOT_ENV.sonnet], haiku: env[SLOT_ENV.haiku], - } : null, - notes, + installed: fs.existsSync(claudeConfigDir()), + configPath: claudeSettingsPath(), }; }, - - apply(mapping, key) { - const file = claudeSettingsPath(); - const existing = readJson(file); - const settings = existing || {}; - const fileCreated = existing === null; - const containerCreated = !('env' in settings); - const env = settings.env || {}; - const values = ownedValues(mapping, key); - const prior = getToolState('claude'); - const keys = {}; - for (const [k, v] of Object.entries(values)) { - const before = prior?.keys?.[k] !== undefined - ? prior.keys[k].before - : (k in env ? env[k] : null); - keys[k] = { before, applied: v }; - } - setToolState('claude', { - fileCreated: prior?.fileCreated ?? fileCreated, - containerCreated: prior?.containerCreated ?? containerCreated, - keys, - }); - settings.env = { ...env, ...values }; - writeJson(file, settings); - }, - - restore() { - const state = getToolState('claude'); - if (!state) return { restored: false, conflicts: [] }; - const file = claudeSettingsPath(); - const settings = readJson(file) || {}; - const env = settings.env || {}; - const conflicts = []; - const remaining = {}; - for (const [k, rec] of Object.entries(state.keys)) { - const cur = k in env ? env[k] : null; - if (cur === rec.applied) { - if (rec.before === null) delete env[k]; - else env[k] = rec.before; - } else if (cur === rec.before) { - // Already back at its baseline (user resolved a prior conflict by hand): - // nothing to revert, and not a conflict, so a repeat restore can finish. - } else { - conflicts.push(`${k}: changed by hand, left as-is`); - remaining[k] = rec; - } - } - settings.env = env; - if (state.containerCreated && Object.keys(env).length === 0) delete settings.env; - if (state.fileCreated && Object.keys(settings).length === 0) { - try { fs.rmSync(file); } catch {} - } else { - writeJson(file, settings); - } - if (conflicts.length === 0) clearToolState('claude'); - else setToolState('claude', { ...state, keys: remaining }); - return { restored: true, conflicts }; - }, }; diff --git a/src/adapters/codex.js b/src/adapters/codex.js index d552b76..951c40e 100644 --- a/src/adapters/codex.js +++ b/src/adapters/codex.js @@ -1,134 +1,13 @@ import fs from 'node:fs'; -import { codexHome, codexConfigPath, gatewayBase } from '../paths.js'; -import { writeFileAtomic, getToolState, setToolState, clearToolState, KenariError } from '../store.js'; -import * as toml from '../toml.js'; - -const TABLE = 'model_providers.kenari'; -const TABLE_KEY = `table:${TABLE}`; - -function readConfig() { - try { return fs.readFileSync(codexConfigPath(), 'utf8'); } - catch (e) { if (e.code === 'ENOENT') return null; throw e; } -} - -function tableBody(key) { - return [ - 'name = "Kenari"', - `base_url = "${gatewayBase()}/v1"`, - 'wire_api = "responses"', - `http_headers = { "Authorization" = "Bearer ${key}" }`, - ]; -} +import { codexConfigPath, codexHome } from '../paths.js'; export default { id: 'codex', - name: 'Codex', - slots: [{ id: 'model', label: 'Model', defaultModel: 'glm-5-2' }], - + name: 'Codex CLI', detect() { - return { installed: fs.existsSync(codexHome()), configPath: codexConfigPath() }; - }, - - status() { - const content = readConfig() || ''; - const onKenari = toml.getTopLevel(content, 'model_provider') === 'kenari' - && toml.getTableText(content, TABLE) !== null; - const notes = []; - const state = getToolState('codex'); - if (state && !onKenari) notes.push('switch state exists but config.toml no longer points at kenari (hand-edited?)'); - if (!state && onKenari) notes.push('points at kenari but no switch state (configured outside this CLI)'); - notes.push('shell overrides (codex --profile / -c) are not visible here'); return { - provider: onKenari ? 'kenari' : 'default', - mapping: onKenari ? { model: toml.getTopLevel(content, 'model') } : null, - notes, + installed: fs.existsSync(codexHome()), + configPath: codexConfigPath(), }; }, - - apply(mapping, key) { - const existing = readConfig(); - let content = existing ?? ''; - const fileCreated = existing === null; - // A key present in an unsupported value shape (single-quoted literal, etc.) - // is invisible to setTopLevel, which would then INSERT a duplicate and break - // Codex's TOML parse. Refuse rather than corrupt the file. - for (const k of ['model', 'model_provider']) { - if (toml.hasTopLevel(content, k) && toml.getTopLevel(content, k) === null) { - throw new KenariError(`${k} in config.toml uses a value format this CLI cannot edit safely; edit it by hand or remove the line, then retry`); - } - } - const prior = getToolState('codex'); - const currentVals = { - model: toml.getTopLevel(content, 'model'), - model_provider: toml.getTopLevel(content, 'model_provider'), - [TABLE_KEY]: toml.getTableText(content, TABLE), - }; - const newTable = [`[${TABLE}]`, ...tableBody(key)].join('\n'); - const applied = { model: mapping.model, model_provider: 'kenari', [TABLE_KEY]: newTable }; - const keys = {}; - for (const k of Object.keys(applied)) { - const before = prior?.keys?.[k] !== undefined ? prior.keys[k].before : currentVals[k]; - keys[k] = { before, applied: applied[k] }; - } - setToolState('codex', { - fileCreated: prior?.fileCreated ?? fileCreated, - containerCreated: false, - keys, - }); - content = toml.setTopLevel(content, 'model', mapping.model); - content = toml.setTopLevel(content, 'model_provider', 'kenari'); - content = toml.setTable(content, TABLE, tableBody(key)); - writeFileAtomic(codexConfigPath(), content); - }, - - restore() { - const state = getToolState('codex'); - if (!state) return { restored: false, conflicts: [] }; - let content = readConfig() ?? ''; - const conflicts = []; - const remaining = {}; - - for (const k of ['model', 'model_provider']) { - const rec = state.keys[k]; - if (!rec) continue; - const cur = toml.getTopLevel(content, k); - if (cur === rec.applied) { - content = rec.before === null - ? toml.deleteTopLevel(content, k) - : toml.setTopLevel(content, k, rec.before); - } else if (cur === rec.before) { - // Already back at its baseline (user resolved a prior conflict by hand): - // nothing to revert, and not a conflict, so a repeat restore can finish. - } else { - conflicts.push(`${k}: changed by hand, left as-is`); - remaining[k] = rec; - } - } - const rec = state.keys[TABLE_KEY]; - if (rec) { - const cur = toml.getTableText(content, TABLE); - if (cur === rec.applied) { - if (rec.before === null) content = toml.deleteTable(content, TABLE); - else { - const lines = rec.before.split('\n'); - content = toml.setTable(content, TABLE, lines.slice(1)); - } - } else if (cur === rec.before) { - // Table already back at baseline (user resolved a prior conflict by hand): - // no revert, no conflict, so a repeat restore can finish. - } else { - conflicts.push(`[${TABLE}]: changed by hand, left as-is`); - remaining[TABLE_KEY] = rec; - } - } - - if (state.fileCreated && content.trim() === '') { - try { fs.rmSync(codexConfigPath()); } catch {} - } else { - writeFileAtomic(codexConfigPath(), content); - } - if (conflicts.length === 0) clearToolState('codex'); - else setToolState('codex', { ...state, keys: remaining }); - return { restored: true, conflicts }; - }, }; diff --git a/src/catalog.js b/src/catalog.js new file mode 100644 index 0000000..3e7c4ef --- /dev/null +++ b/src/catalog.js @@ -0,0 +1,119 @@ +import { modelCachePath } from './paths.js'; +import { fetchCatalog, validateGatewayUrl } from './gateway.js'; +import { KenariError, readJson, writeFileAtomic, writePrivateJson } from './store.js'; +import { codexKenariModels } from './runtime/codex.js'; + +export const CACHE_VERSION = 1; +export const DEFAULT_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +function numberOrNull(value) { + return Number.isFinite(value) && value >= 0 ? value : null; +} + +export function validateCatalogResponse(body, gateway) { + if (!body || typeof body !== 'object' || !Array.isArray(body.data)) { + throw new KenariError('Kenari catalog has an unsupported schema'); + } + const seen = new Set(); + const models = body.data.map((model) => { + if (!model || typeof model.id !== 'string' || !/^[^/\s]+$/.test(model.id) || seen.has(model.id)) { + throw new KenariError('Kenari catalog contains an invalid or duplicate model ID'); + } + seen.add(model.id); + const pricing = model.pricing || {}; + const knownUnit = !pricing.unit || pricing.unit === 'micro_idr_per_1m_tokens'; + const free = pricing.free === true; + return { + id: model.id, + input_price: free ? 0 : knownUnit ? numberOrNull(pricing.input) : null, + output_price: free ? 0 : knownUnit ? numberOrNull(pricing.output) : null, + context_limit: numberOrNull(model.context_length), + output_limit: numberOrNull(model.output_limit ?? model.max_output_tokens), + reasoning_efforts: Array.isArray(model.reasoning_efforts) + ? model.reasoning_efforts.filter((v) => typeof v === 'string') + : [], + compatibility: model.compatibility && typeof model.compatibility === 'object' + ? model.compatibility + : {}, + }; + }); + return { + version: CACHE_VERSION, + fetched_at: new Date().toISOString(), + gateway, + models, + }; +} + +export function validateCatalogCache(value) { + if (!value || value.version !== CACHE_VERSION || typeof value.gateway !== 'string' + || !Number.isFinite(Date.parse(value.fetched_at)) || !Array.isArray(value.models)) { + throw new KenariError('model-cache.json has an unsupported schema'); + } + const ids = new Set(); + for (const model of value.models) { + if (!model || typeof model.id !== 'string' || !/^[^/\s]+$/.test(model.id) || ids.has(model.id)) { + throw new KenariError('model-cache.json contains an invalid or duplicate model'); + } + ids.add(model.id); + } + return value; +} + +export function loadCatalogCache() { + const value = readJson(modelCachePath()); + return value === null ? null : validateCatalogCache(value); +} + +export function saveCatalogCache(cache) { + const value = validateCatalogCache(cache); + writePrivateJson(modelCachePath(), value); + return value; +} + +export function catalogIsFresh(cache, now = Date.now(), maxAgeMs = DEFAULT_CACHE_MAX_AGE_MS) { + return !!cache && now - Date.parse(cache.fetched_at) <= maxAgeMs; +} + +export async function refreshCatalogCache(key, options = {}) { + const gateway = options.base || process.env.KENARI_BASE_URL || 'https://kenari.id'; + const body = await fetchCatalog(key, options); + const cache = validateCatalogResponse(body, gateway.replace(/\/+$/, '')); + saveCatalogCache(cache); + return cache; +} + +export async function loadCatalogForLaunch(options = {}) { + const loaded = loadCatalogCache(); + const currentGateway = validateGatewayUrl( + options.base || process.env.KENARI_BASE_URL || 'https://kenari.id', + ); + const cache = loaded?.gateway === currentGateway ? loaded : null; + if (catalogIsFresh(cache, options.now, options.maxAgeMs)) { + return { cache, warning: null, refreshed: false }; + } + if (options.refresh !== false && options.key) { + try { + const refreshed = await refreshCatalogCache(options.key, options); + return { cache: refreshed, warning: null, refreshed: true }; + } catch (error) { + if (cache) { + return { cache, warning: `catalog refresh failed, using cached catalog: ${error.message}`, refreshed: false }; + } + if (options.requireKenari) throw error; + return { cache: null, warning: null, refreshed: false }; + } + } + if (options.requireKenari && !cache) { + throw new KenariError('Kenari model catalog unavailable. Run: kenari configure'); + } + return { cache, warning: null, refreshed: false }; +} + +export function writeMergedCodexCatalog(nativeModels, cache, file) { + const native = Array.isArray(nativeModels) ? nativeModels : []; + const kenari = codexKenariModels(cache, native); + const merged = [...native, ...kenari]; + writeFileAtomic(file, JSON.stringify({ models: merged }, null, 2) + '\n'); + return merged; +} diff --git a/src/cli.js b/src/cli.js index 34d6d38..a123d2a 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,27 +1,57 @@ +import fs from 'node:fs'; import os from 'node:os'; +import path from 'node:path'; +import { randomBytes } from 'node:crypto'; import { spawn } from 'node:child_process'; import registry from './adapters/registry.js'; -import { KenariError, getKey, setKey, deleteKey, maskKey, withLock } from './store.js'; -import { fetchModels, formatRp, AuthError } from './gateway.js'; -import { askHidden, ask, pickNumber } from './prompt.js'; -import { gatewayBase } from './paths.js'; +import { + KenariError, + deleteKey, + getKey, + loadState, + maskKey, + removeFile, + setKey, + withLock, +} from './store.js'; +import { + ROLE_DEFINITIONS, + getToolConfig, + hasKenariRoutes, + loadConfig, + saveConfig, +} from './config.js'; +import { + loadCatalogCache, + loadCatalogForLaunch, + writeMergedCodexCatalog, +} from './catalog.js'; +import { fetchCatalog, formatRp } from './gateway.js'; +import { ask, askHidden, askYesNo, pickNumber } from './prompt.js'; +import { gatewayBase, modelCachePath, runtimeDir } from './paths.js'; import { genPkce, genState, buildLoopbackUrl, buildPasteUrl, startCallbackServer } from './oauth.js'; +import { resolveBinary, runWrappedTool } from './supervisor.js'; +import { buildClaudeLaunch } from './runtime/claude.js'; +import { buildCodexLaunch, loadCodexNativeModels } from './runtime/codex.js'; +import { startRouter } from './router.js'; +import { detectOrphanedV1Signatures, detectV1State, migrateV1 } from './migrate.js'; const isTTY = () => Boolean(process.stdin.isTTY && process.stdout.isTTY); +const TOOLS = Object.keys(ROLE_DEFINITIONS); function parseFlags(argv) { - const flags = {}; const rest = []; + const flags = {}; + const rest = []; for (let i = 0; i < argv.length; i += 1) { - if (argv[i].startsWith('--')) { - // A flag value never starts with `--`: that is the next flag, not this - // one's value (otherwise two adjacent boolean flags, e.g. `--paste - // --no-browser`, would swallow each other). + const arg = argv[i]; + if (arg.startsWith('--')) { const next = argv[i + 1]; const hasValue = next !== undefined && !next.startsWith('--'); - flags[argv[i].slice(2)] = hasValue ? next : true; + flags[arg.slice(2)] = hasValue ? next : true; if (hasValue) i += 1; + } else { + rest.push(arg); } - else rest.push(argv[i]); } return { flags, rest }; } @@ -33,168 +63,461 @@ async function readStdinLine() { } function findAdapter(id) { - return registry.find((a) => a.id === id) || null; + return registry.find((adapter) => adapter.id === id) || null; } -function printStatus() { - for (const a of registry) { - const det = a.detect(); - if (!det.installed) { console.log(`${a.id.padEnd(8)} not found`); continue; } - const st = a.status(); - if (st.provider === 'kenari') { - const map = Object.entries(st.mapping || {}).map(([k, v]) => `${k}=${v}`).join(' '); - console.log(`${a.id.padEnd(8)} kenari ${map}`); - } else { - console.log(`${a.id.padEnd(8)} default`); +function assertTool(tool) { + if (!TOOLS.includes(tool)) { + throw new KenariError(`unknown tool "${tool}". Known tools: ${TOOLS.join(', ')}`); + } +} + +function defaultRoles(tool) { + const roles = {}; + for (const role of Object.keys(ROLE_DEFINITIONS[tool])) { + roles[role] = { mode: tool === 'codex' && role !== 'main' ? 'inherit' : 'native' }; + } + return roles; +} + +function parseRoleValue(tool, role, value) { + if (typeof value !== 'string' || value === '') { + throw new KenariError(`--${role} needs native, inherit, picker, or kenari/`); + } + if (value.startsWith('kenari/')) return { mode: 'fixed', model: value }; + if (!ROLE_DEFINITIONS[tool][role].includes(value)) { + throw new KenariError(`unsupported ${tool}.${role} value "${value}"`); + } + return { mode: value }; +} + +function fixedIds(roles) { + return Object.values(roles) + .filter((role) => role.mode === 'fixed') + .map((role) => role.model.slice('kenari/'.length)); +} + +function storedMigrationConflicts(tool) { + const state = loadState(); + if (state.version === 1) { + return (state.migration_conflicts || []).filter((item) => !tool || item.tool === tool); + } + return Object.entries(state.migration?.tools || {}) + .filter(([id, value]) => (!tool || id === tool) && value?.status === 'conflict') + .flatMap(([id, value]) => (value.keys || []).map((key) => ({ tool: id, key }))); +} + +async function prepareMigration(tool) { + if (detectV1State()) { + const result = await withLock(() => migrateV1()); + const conflicts = result.conflicts.filter((item) => !tool || item.tool === tool); + if (conflicts.length) { + throw new KenariError( + `migration conflict: ${conflicts.map((item) => `${item.tool}.${item.key}`).join(', ')}`, + ); + } + if (result.migrated.length) { + console.error(`migrated version 1 routing: ${result.migrated.join(', ')}`); } - for (const n of st.notes) console.log(` note: ${n}`); + } + const orphaned = detectOrphanedV1Signatures().filter((item) => !tool || item.tool === tool); + if (orphaned.length) { + throw new KenariError( + `unowned version 1 routing found: ${orphaned.map((item) => `${item.tool}.${item.key}`).join(', ')}`, + ); + } + const conflicts = storedMigrationConflicts(tool); + if (conflicts.length) { + throw new KenariError( + `migration conflict: ${conflicts.map((item) => `${item.tool}.${item.key}`).join(', ')}`, + ); } } -async function ensureKey() { +async function catalogForRoles(roles) { + const needsCatalog = fixedIds(roles).length > 0 + || Object.values(roles).some((role) => role.mode === 'picker'); + if (!needsCatalog) return null; const key = getKey(); - if (key) return key; - if (!isTTY()) throw new KenariError('no API key stored. Run: kenari login'); - const entered = await askHidden('kenari API key (kn-..., from https://kenari.id/keys): '); - setKey(entered); - return entered; -} - -async function buildMapping(adapter, key, flags) { - // Validate any flag-provided slot value up front. A bare `--opus` (no value) - // parses as boolean true; it must fail with a clear message naming the slot - // instead of slipping through as a bogus model id or an unvalidated offline - // value. - for (const slot of adapter.slots) { - if (flags[slot.id] !== undefined) { - const chosen = flags[slot.id]; - if (!(typeof chosen === 'string' && chosen.length > 0 && !chosen.startsWith('--'))) { - throw new KenariError(`--${slot.id} needs a model id (e.g. --${slot.id} glm-5-2)`); - } + if (!key) throw new KenariError('Kenari login required. Run: kenari login'); + const result = await loadCatalogForLaunch({ key, requireKenari: true }); + if (result.warning) console.log(`warning: ${result.warning}`); + const ids = new Set(result.cache.models.map((model) => model.id)); + for (const id of fixedIds(roles)) { + if (!ids.has(id)) { + throw new KenariError(`model "kenari/${id}" is not in the Kenari catalog. Run: kenari models`); } } - let catalog = null; - try { catalog = await fetchModels(key); } - catch (e) { - if (e instanceof AuthError) throw e; - const allProvided = adapter.slots.every((s) => flags[s.id]); - if (!allProvided) throw new KenariError(e.message + '. To apply without validation, pass every slot flag explicitly.'); - console.log('warning: cannot reach gateway, applying without validation'); - } - if (catalog && catalog.length === 0) { - throw new KenariError('the kenari catalog returned no models; try again later'); - } - const ids = catalog ? new Set(catalog.map((m) => m.id)) : null; - const mapping = {}; - for (const slot of adapter.slots) { - let chosen = flags[slot.id]; - if (chosen && ids && !ids.has(chosen)) { - throw new KenariError(`model "${chosen}" is not in the kenari catalog. Run: kenari models`); - } - if (!chosen) { - if (isTTY()) { - const items = catalog.map((m) => `${m.id.padEnd(24)} in ${formatRp(m.in)}/1M out ${formatRp(m.out)}/1M ctx ${m.context ?? '-'}`); - let defIdx = catalog.findIndex((m) => m.id === slot.defaultModel); - if (defIdx === -1) defIdx = 0; - const idx = await pickNumber(`${adapter.name}: ${slot.label}`, items, defIdx); - chosen = catalog[idx].id; - } else { - chosen = slot.defaultModel; - if (ids && !ids.has(chosen)) { - throw new KenariError(`default model "${chosen}" is not in the catalog; pass --${slot.id} explicitly`); - } - } - } - mapping[slot.id] = chosen; + return result.cache; +} + +async function pickFixedModel(tool, role, current) { + const key = getKey(); + if (!key) throw new KenariError('Kenari login required. Run: kenari login'); + const { cache, warning } = await loadCatalogForLaunch({ key, requireKenari: true }); + if (warning) console.log(`warning: ${warning}`); + if (!cache.models.length) throw new KenariError('the Kenari catalog returned no models'); + const items = cache.models.map((model) => ( + `kenari/${model.id} ${formatRp(model.input_price)} in ` + + `${formatRp(model.output_price)} out ${model.context_limit ?? '-'} context` + )); + const currentId = current?.mode === 'fixed' ? current.model : ''; + let defaultIndex = cache.models.findIndex((model) => `kenari/${model.id}` === currentId); + if (defaultIndex < 0) defaultIndex = 0; + const selected = await pickNumber(`${tool}: ${role}`, items, defaultIndex); + return { mode: 'fixed', model: `kenari/${cache.models[selected].id}` }; +} + +async function configureAdvanced(tool, current) { + const roles = {}; + for (const [role, modes] of Object.entries(ROLE_DEFINITIONS[tool])) { + const labels = modes.map((mode) => mode === 'fixed' ? 'fixed Kenari model' : mode); + const previous = current?.[role]?.mode; + let defaultIndex = modes.indexOf(previous); + if (defaultIndex < 0) defaultIndex = 0; + const index = await pickNumber(`${tool}: ${role}`, labels, defaultIndex); + const mode = modes[index]; + roles[role] = mode === 'fixed' + ? await pickFixedModel(tool, role, current?.[role]) + : { mode }; } - return mapping; + return roles; } -async function cmdUse(argv) { +async function configureClaude(current) { + const roleIds = Object.keys(ROLE_DEFINITIONS.claude); + const prior = roleIds.filter((role) => current?.[role]?.mode === 'fixed'); + const answer = await ask( + `Claude roles to use Kenari (${roleIds.join(', ')})` + + ` [Enter = ${prior.length ? prior.join(',') : 'none'}]: `, + ); + const selected = answer === '' + ? prior + : answer.toLowerCase() === 'none' + ? [] + : answer.split(',').map((value) => value.trim()).filter(Boolean); + const unknown = selected.filter((role) => !roleIds.includes(role)); + if (unknown.length) throw new KenariError(`unknown Claude role "${unknown[0]}"`); + const roles = defaultRoles('claude'); + for (const role of selected) roles[role] = await pickFixedModel('claude', role, current?.[role]); + return roles; +} + +async function configureCodex(current) { + const roles = defaultRoles('codex'); + const modes = ['native', 'picker', 'fixed']; + let defaultIndex = modes.indexOf(current?.main?.mode); + if (defaultIndex < 0) defaultIndex = 1; + const selected = modes[await pickNumber( + 'Codex: add Kenari models to the active model selection?', + ['native only', 'native and Kenari picker', 'fixed Kenari model'], + defaultIndex, + )]; + roles.main = selected === 'fixed' + ? await pickFixedModel('codex', 'main', current?.main) + : { mode: selected }; + if (await askYesNo('Configure review and subagent roles independently?', false)) { + return configureAdvanced('codex', { ...current, main: roles.main }); + } + return roles; +} + +function printRoutingSummary(tool, roles) { + console.log(tool === 'claude' ? 'Claude Code' : 'Codex'); + for (const [role, value] of Object.entries(roles)) { + const target = value.mode === 'fixed' ? value.model : value.mode; + console.log(` ${role.padEnd(12)} ${target}`); + } +} + +async function cmdConfigure(argv) { const { flags, rest } = parseFlags(argv); - const toolId = rest[0]; - if (!toolId) throw new KenariError('usage: kenari use [default]'); - const adapter = findAdapter(toolId); - if (!adapter) { - throw new KenariError(`unknown tool "${toolId}". Known tools: ${registry.map((a) => a.id).join(', ')}`); - } - const det = adapter.detect(); - if (!det.installed) { - throw new KenariError(`${adapter.id} not found (looked for ${det.configPath})`); - } - - if (rest[1] === 'default') { - const { restored, conflicts } = await withLock(() => adapter.restore()); - if (!restored) { console.log(`${adapter.id} is not switched (nothing to restore)`); return 0; } - if (conflicts.length === 0) { - console.log(`ok: ${adapter.id} restored to its previous default`); - return 0; + const requested = rest[0]; + if (requested) assertTool(requested); + const tools = requested + ? [requested] + : registry.filter((adapter) => { + try { + resolveBinary(adapter.id, { exclude: [process.argv[1]] }); + return true; + } catch { + return adapter.detect().installed; + } + }).map((adapter) => adapter.id); + if (!tools.length) throw new KenariError('no supported tools installed (looked for Claude Code and Codex)'); + + for (const tool of tools) await prepareMigration(tool); + let config = loadConfig() || { version: 2, tools: {} }; + for (const tool of tools) { + const current = getToolConfig(config, tool)?.roles || null; + let roles; + if (flags.yes) { + const roleIds = Object.keys(ROLE_DEFINITIONS[tool]); + const missing = roleIds.filter((role) => !(role in flags)); + if (missing.length) { + throw new KenariError(`--yes requires every ${tool} role; missing --${missing.join(', --')}`); + } + roles = {}; + for (const role of roleIds) roles[role] = parseRoleValue(tool, role, flags[role]); + } else { + if (!isTTY()) throw new KenariError('non-interactive configuration requires --yes and every role'); + const advanced = await askYesNo(`Use advanced ${tool} role configuration?`, false); + roles = advanced + ? await configureAdvanced(tool, current) + : tool === 'claude' + ? await configureClaude(current) + : await configureCodex(current); } - console.log(`${adapter.id}: restored with conflicts:`); - for (const c of conflicts) console.log(` ${c}`); - return 1; + await catalogForRoles(roles); + config = { + version: 2, + tools: { ...config.tools, [tool]: { roles } }, + }; + printRoutingSummary(tool, roles); } + await withLock(() => saveConfig(config)); + console.log('ok: routing configuration saved'); + return 0; +} - const key = await ensureKey(); - const mapping = await buildMapping(adapter, key, flags); - await withLock(() => adapter.apply(mapping, key)); - const mapStr = Object.entries(mapping).map(([k, v]) => `${k}=${v}`).join(' '); - console.log(`ok: ${adapter.id} -> kenari (${mapStr})`); - if (adapter.id === 'claude') { - console.log('in-app /model now moves between your mapped kenari models'); +async function cmdReset(argv) { + const { rest } = parseFlags(argv); + const requested = rest[0]; + if (requested) assertTool(requested); + if (requested) await prepareMigration(requested); + else if (detectV1State()) await prepareMigration(); + const config = loadConfig(); + if (!config) { + console.log('nothing to reset'); + return 0; } + const tools = requested ? [requested] : Object.keys(config.tools); + const next = { version: 2, tools: { ...config.tools } }; + for (const tool of tools) delete next.tools[tool]; + await withLock(() => saveConfig(next)); + if (!hasKenariRoutes(next)) removeFile(modelCachePath()); + console.log(`ok: reset ${tools.length ? tools.join(', ') : 'routing configuration'}`); return 0; } -async function cmdModels() { - const key = await ensureKey(); - const models = await fetchModels(key); - console.log(`${'id'.padEnd(24)} ${'in /1M'.padStart(12)} ${'out /1M'.padStart(12)} ${'ctx'.padStart(10)}`); - for (const m of models) { - console.log( - `${m.id.padEnd(24)} ${formatRp(m.in).padStart(12)} ${formatRp(m.out).padStart(12)} ${String(m.context ?? '-').padStart(10)}`, - ); +function cacheAge(cache) { + if (!cache) return null; + return Math.max(0, Date.now() - Date.parse(cache.fetched_at)); +} + +function offlineStatus() { + const config = loadConfig(); + const cache = loadCatalogCache(); + const tools = {}; + for (const tool of TOOLS) { + const configured = config?.tools?.[tool]; + tools[tool] = configured + ? Object.fromEntries(Object.entries(configured.roles).map(([role, value]) => [ + role, + value.mode === 'fixed' ? value.model : value.mode, + ])) + : null; } - return 0; + return { + version: 2, + credential: getKey() ? 'stored' : 'missing', + cache: cache ? { + age_ms: cacheAge(cache), + fetched_at: cache.fetched_at, + gateway: cache.gateway, + models: cache.models.length, + } : null, + migration_conflicts: [...storedMigrationConflicts(), ...detectOrphanedV1Signatures()], + tools, + }; } -async function cmdKey(argv) { +async function boundedReachable(url, timeoutMs = 3000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { method: 'HEAD', signal: controller.signal }); + return { ok: response.status < 500, status: response.status }; + } catch (error) { + return { ok: false, error: error.name === 'AbortError' ? 'timeout' : error.message }; + } finally { + clearTimeout(timer); + } +} + +async function runStatusChecks(status) { + const checks = {}; + let router; + try { + router = await startRouter({ + nativeBase: 'https://127.0.0.1:9', + kenariBase: 'https://127.0.0.1:9', + credential: null, + catalog: null, + }); + checks.router = { ok: true }; + } catch (error) { + checks.router = { ok: false, error: error.message }; + } finally { + await router?.close(); + } + checks.native_anthropic = await boundedReachable('https://api.anthropic.com'); + checks.native_openai = await boundedReachable('https://api.openai.com'); + const key = getKey(); + if (!key) { + checks.kenari = { ok: false, error: 'login required' }; + checks.catalog = { ok: false, error: 'login required' }; + } else { + try { + const body = await fetchCatalog(key); + checks.kenari = { ok: true }; + checks.catalog = { ok: Array.isArray(body.data), models: body.data?.length ?? 0 }; + } catch (error) { + checks.kenari = { ok: false, error: error.message }; + checks.catalog = { ok: false, error: error.message }; + } + } + return { ...status, checks }; +} + +async function cmdStatus(argv) { const { flags, rest } = parseFlags(argv); - const sub = rest[0] || 'show'; - if (sub === 'show') { - const key = getKey(); - if (!key) { console.log('no key stored. Run: kenari key set'); return 0; } - console.log(maskKey(key)); - return 0; + if (rest.length) throw new KenariError('usage: kenari status [--check] [--json]'); + let status = offlineStatus(); + if (flags.check) status = await runStatusChecks(status); + if (flags.json) { + console.log(JSON.stringify(status, null, 2)); + return Object.values(status.checks || {}).some((check) => !check.ok) ? 1 : 0; } - if (sub === 'delete') { - deleteKey(); - console.log('deleted the stored API key'); - return 0; + console.log(`credential ${status.credential}`); + console.log(`catalog ${status.cache ? `${status.cache.models} models, age ${Math.round(status.cache.age_ms / 1000)}s` : 'missing'}`); + for (const conflict of status.migration_conflicts) { + console.log(`conflict ${conflict.tool}.${conflict.key}`); } - if (sub === 'set') { - let key; - if (flags.stdin) key = await readStdinLine(); - else if (isTTY()) key = await askHidden('kenari API key (kn-...): '); - else throw new KenariError('provide the key via --stdin'); - setKey(key); - for (const a of registry) { - if (!a.detect().installed) continue; - const st = a.status(); - if (st.provider === 'kenari') { - const vals = Object.values(st.mapping || {}); - if (vals.length === 0 || vals.some((v) => v === null || v === undefined || v === '')) { - console.log(`warning: skipped re-applying to ${a.id} (its mapping is missing a model; run: kenari use ${a.id})`); - continue; - } - await withLock(() => a.apply(st.mapping, key)); - console.log(`re-applied to ${a.id}`); - } + for (const [tool, roles] of Object.entries(status.tools)) { + console.log(`${tool.padEnd(11)}${roles ? 'configured' : 'not configured'}`); + for (const [role, target] of Object.entries(roles || {})) { + console.log(` ${role.padEnd(12)} ${target}`); } - console.log(`stored the API key (${maskKey(key)})`); + } + for (const [name, check] of Object.entries(status.checks || {})) { + console.log(`check ${name.padEnd(17)} ${check.ok ? 'ok' : `failed: ${check.error || check.status}`}`); + } + return Object.values(status.checks || {}).some((check) => !check.ok) ? 1 : 0; +} + +async function cmdModels(argv) { + const { flags, rest } = parseFlags(argv); + if (rest.length) throw new KenariError('usage: kenari models [--json]'); + let cache = null; + const key = getKey(); + if (key) { + const result = await loadCatalogForLaunch({ + key, + requireKenari: true, + maxAgeMs: 0, + }); + cache = result.cache; + if (result.warning) console.log(`warning: ${result.warning}`); + } else { + cache = (await loadCatalogForLaunch({ refresh: false, requireKenari: false })).cache; + } + if (!cache) throw new KenariError('no model catalog cached. Run: kenari login, then kenari configure'); + const output = { + fetched_at: cache.fetched_at, + age_ms: cacheAge(cache), + models: cache.models.map((model) => ({ + id: `kenari/${model.id}`, + input_price: model.input_price, + output_price: model.output_price, + context_limit: model.context_limit, + output_limit: model.output_limit, + reasoning_efforts: model.reasoning_efforts, + })), + }; + if (flags.json) { + console.log(JSON.stringify(output, null, 2)); return 0; } - throw new KenariError(`unknown key command "${sub}". Use: set, show, delete`); + console.log(`${'id'.padEnd(30)} ${'in /1M'.padStart(12)} ${'out /1M'.padStart(12)} ${'context'.padStart(10)} ${'output'.padStart(10)}`); + for (const model of output.models) { + console.log( + `${model.id.padEnd(30)} ${formatRp(model.input_price).padStart(12)} ` + + `${formatRp(model.output_price).padStart(12)} ` + + `${String(model.context_limit ?? '-').padStart(10)} ` + + `${String(model.output_limit ?? '-').padStart(10)}`, + ); + } + console.log(`cache age: ${Math.round(output.age_ms / 1000)}s`); + return 0; +} + +function originalBinary(tool) { + return resolveBinary(tool, { exclude: [process.argv[1]] }); +} + +function assertSelectedModels(toolConfig, cache) { + const available = new Set((cache?.models || []).map((model) => model.id)); + for (const id of fixedIds(toolConfig.roles)) { + if (!available.has(id)) { + throw new KenariError(`selected model "kenari/${id}" is unavailable. Run: kenari configure`); + } + } +} + +async function ensureConfigured(tool) { + await prepareMigration(tool); + let config = loadConfig(); + if (!getToolConfig(config, tool)) { + if (!isTTY()) throw new KenariError(`missing ${tool} configuration, run: kenari configure ${tool}`); + await cmdConfigure([tool]); + config = loadConfig(); + } + return { config, toolConfig: getToolConfig(config, tool) }; +} + +async function runTool(tool, args) { + const { config, toolConfig } = await ensureConfigured(tool); + const requireKenari = hasKenariRoutes(config, tool); + const key = getKey(); + if (requireKenari && !key) throw new KenariError('Kenari login required. Run: kenari login'); + const { cache, warning } = await loadCatalogForLaunch({ + key, + requireKenari, + }); + if (warning) console.error(`warning: ${warning}`); + if (requireKenari) assertSelectedModels(toolConfig, cache); + + const binary = originalBinary(tool); + let catalogPath = null; + if (tool === 'codex' && cache) { + fs.mkdirSync(runtimeDir(), { recursive: true, mode: 0o700 }); + catalogPath = path.join(runtimeDir(), `models-${process.pid}-${randomBytes(6).toString('hex')}.json`); + writeMergedCodexCatalog(loadCodexNativeModels(binary), cache, catalogPath); + } + const kenariBase = tool === 'codex' ? `${gatewayBase()}/v1` : gatewayBase(); + const nativeBase = tool === 'codex' + ? (process.env.KENARI_CODEX_NATIVE_BASE_URL || 'https://api.openai.com/v1') + : (process.env.KENARI_CLAUDE_NATIVE_BASE_URL || 'https://api.anthropic.com'); + const runtimeBuilder = tool === 'codex' ? buildCodexLaunch : buildClaudeLaunch; + try { + return await runWrappedTool({ + binary, + args, + env: process.env, + routerOptions: { + nativeBase, + kenariBase, + credential: key, + catalog: cache, + capabilityToken: tool === 'claude' ? randomBytes(32).toString('base64url') : null, + }, + runtimeBuilder, + runtimeOptions: { toolConfig, catalogPath }, + }); + } finally { + if (catalogPath) removeFile(catalogPath); + } } const LOGIN_TIMEOUT_MS = 5 * 60 * 1000; @@ -207,7 +530,7 @@ function openBrowser(url) { if (process.platform === 'darwin') child = spawn('open', [url], { detached: true, stdio: 'ignore' }); else if (process.platform === 'win32') child = spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore', windowsHide: true }); else child = spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }); - child.on('error', () => {}); // no browser available: the URL is already printed + child.on('error', () => {}); child.unref(); } catch {} } @@ -219,59 +542,53 @@ function printLoginUrl(url, flags) { } async function exchangeCode(base, code, verifier) { - let res; + let response; try { - res = await fetch(base + '/api/cli-auth/token', { + response = await fetch(base + '/api/cli-auth/token', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code, verifier }), }); - } catch (e) { - throw new KenariError(`cannot reach ${base}: ${e.cause?.code || e.message}`); + } catch (error) { + throw new KenariError(`cannot reach ${base}: ${error.cause?.code || error.message}`); } - if (!res.ok) { - const msg = (await res.text()).trim(); - throw new KenariError(msg || `login failed (HTTP ${res.status})`); + if (!response.ok) { + const message = (await response.text()).trim(); + throw new KenariError(message || `login failed (HTTP ${response.status})`); } - return res.json(); + return response.json(); } async function finishLogin(base, code, verifier) { - let resp; try { - resp = await exchangeCode(base, code, verifier); - } catch (e) { - if (e instanceof KenariError) { - console.error(`error: ${e.message}`); - console.error('Retry with: kenari login'); - return 1; - } - throw e; + const response = await exchangeCode(base, code, verifier); + setKey(response.key); + console.log(`ok: signed in as ${response.prefix || maskKey(response.key)}`); + return 0; + } catch (error) { + if (!(error instanceof KenariError)) throw error; + console.error(`error: ${error.message}`); + console.error('Retry with: kenari login'); + return 1; } - setKey(resp.key); - console.log(`ok: signed in as ${resp.prefix || maskKey(resp.key)}`); - return 0; } async function loginLoopback(base, host, flags) { const { verifier, challenge } = genPkce(); const state = genState(); const { server, port, codePromise } = await startCallbackServer(state); - - const url = buildLoopbackUrl(base, { challenge, state, port, host }); - printLoginUrl(url, flags); - + printLoginUrl(buildLoopbackUrl(base, { challenge, state, port, host }), flags); let timer; - let sigintHandler; + let signalHandler; try { - const timeoutPromise = new Promise((resolve) => { + const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(LOGIN_TIMEOUT), LOGIN_TIMEOUT_MS); }); - const interruptPromise = new Promise((resolve) => { - sigintHandler = () => resolve(LOGIN_INTERRUPT); - process.once('SIGINT', sigintHandler); + const interrupt = new Promise((resolve) => { + signalHandler = () => resolve(LOGIN_INTERRUPT); + process.once('SIGINT', signalHandler); }); - const result = await Promise.race([codePromise, timeoutPromise, interruptPromise]); + const result = await Promise.race([codePromise, timeout, interrupt]); if (result === LOGIN_TIMEOUT) { console.error('login timed out, run `kenari login` again'); return 1; @@ -280,75 +597,84 @@ async function loginLoopback(base, host, flags) { console.error('login cancelled'); return 1; } - return await finishLogin(base, result, verifier); + return finishLogin(base, result, verifier); } finally { clearTimeout(timer); - if (sigintHandler) process.off('SIGINT', sigintHandler); + if (signalHandler) process.off('SIGINT', signalHandler); server.close(); } } async function loginPaste(base, host, flags) { const { verifier, challenge } = genPkce(); - const url = buildPasteUrl(base, { challenge, host }); - printLoginUrl(url, flags); - if (!isTTY()) throw new KenariError('paste mode needs an interactive terminal to enter the code'); + printLoginUrl(buildPasteUrl(base, { challenge, host }), flags); + if (!isTTY()) throw new KenariError('paste mode needs an interactive terminal'); const code = await ask('Paste the code shown in your browser: '); - if (!code) { console.error('error: no code entered'); return 1; } - return await finishLogin(base, code, verifier); + if (!code) throw new KenariError('no code entered'); + return finishLogin(base, code, verifier); } async function cmdLogin(argv) { - const { flags } = parseFlags(argv); - const base = (typeof flags.base === 'string' && flags.base) || gatewayBase(); + const { flags, rest } = parseFlags(argv); + if (rest.length) throw new KenariError('usage: kenari login [--no-browser] [--paste] [--stdin]'); + if (flags.stdin) { + const key = await readStdinLine(); + setKey(key); + console.log(`ok: stored ${maskKey(key)}`); + return 0; + } + const base = typeof flags.base === 'string' && flags.base ? flags.base : gatewayBase(); const host = os.hostname(); - if (flags.paste) return await loginPaste(base, host, flags); - return await loginLoopback(base, host, flags); + return flags.paste ? loginPaste(base, host, flags) : loginLoopback(base, host, flags); } -async function interactive() { - printStatus(); - const installed = registry.filter((a) => a.detect().installed); - if (installed.length === 0) { - console.log('no supported tools installed (looked for Claude Code, Codex).'); - return 0; - } - const items = [...installed.map((a) => a.name), 'quit']; - const pick = await pickNumber('switch which tool?', items, 0); - if (pick === installed.length) return 0; - const adapter = installed[pick]; - const target = await pickNumber('target?', ['kenari', 'default (restore)'], 0); - if (target === 1) return await cmdUse([adapter.id, 'default']); - return await cmdUse([adapter.id]); +async function cmdLogout() { + deleteKey(); + console.log('ok: logged out'); + return 0; } const USAGE = `kenari CLI usage: - kenari interactive switcher - kenari login [--no-browser] [--paste] sign in and store an API key - kenari use [default] [--opus M --sonnet M --haiku M | --model M] - kenari status show what each tool points at - kenari models list kenari models with prices - kenari key [set|show|delete] manage the stored API key - -tools: ${registry.map((a) => a.id).join(', ')} + kenari configure [claude|codex] [role flags] [--yes] + kenari reset [claude|codex] + kenari claude [args...] + kenari codex [args...] + kenari status [--check] [--json] + kenari models [--json] + kenari login [--no-browser] [--paste] [--stdin] + kenari logout + kenari help + +model values: native, picker, inherit, or kenari/ `; export async function main(argv) { try { - const [cmd, ...rest] = argv; - if (!cmd) return isTTY() ? await interactive() : (printStatus(), 0); - if (cmd === 'status') { printStatus(); return 0; } - if (cmd === 'login') return await cmdLogin(rest); - if (cmd === 'use') return await cmdUse(rest); - if (cmd === 'models') return await cmdModels(); - if (cmd === 'key') return await cmdKey(rest); - if (cmd === '--help' || cmd === '-h' || cmd === 'help') { console.log(USAGE); return 0; } - console.error(`unknown command: ${cmd}\n${USAGE}`); + const [command, ...rest] = argv; + if (!command) { + console.log(USAGE); + return 0; + } + if (command === 'configure') return await cmdConfigure(rest); + if (command === 'reset') return await cmdReset(rest); + if (command === 'claude' || command === 'codex') return await runTool(command, rest); + if (command === 'status') return await cmdStatus(rest); + if (command === 'models') return await cmdModels(rest); + if (command === 'login') return await cmdLogin(rest); + if (command === 'logout') return await cmdLogout(); + if (command === '--help' || command === '-h' || command === 'help') { + console.log(USAGE); + return 0; + } + console.error(`unknown command: ${command}\n${USAGE}`); return 1; - } catch (e) { - if (e instanceof KenariError) { console.error(`error: ${e.message}`); return 1; } - throw e; + } catch (error) { + if (error instanceof KenariError) { + console.error(`error: ${error.message}`); + return 1; + } + throw error; } } diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..9fc1fe4 --- /dev/null +++ b/src/config.js @@ -0,0 +1,101 @@ +import { configPath } from './paths.js'; +import { KenariError, readJson, writePrivateJson } from './store.js'; + +export const ROLE_DEFINITIONS = Object.freeze({ + claude: Object.freeze({ + main: ['native', 'fixed'], + opus: ['native', 'fixed'], + sonnet: ['native', 'fixed'], + haiku: ['native', 'fixed'], + fable: ['native', 'fixed'], + subagents: ['native', 'fixed'], + }), + codex: Object.freeze({ + main: ['native', 'fixed', 'picker'], + review: ['native', 'fixed', 'inherit'], + subagents: ['native', 'fixed', 'inherit'], + }), +}); + +function fail(message) { + throw new KenariError(`invalid config.json: ${message}`); +} + +function validateRole(tool, role, value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail(`${tool}.${role} must be an object`); + } + const keys = Object.keys(value); + if (keys.some((key) => !['mode', 'model'].includes(key))) { + fail(`${tool}.${role} contains an unknown field`); + } + if (!ROLE_DEFINITIONS[tool][role].includes(value.mode)) { + fail(`${tool}.${role} has unsupported mode "${value.mode}"`); + } + if (value.mode === 'fixed') { + if (typeof value.model !== 'string' || !/^kenari\/[^/\s]+$/.test(value.model)) { + fail(`${tool}.${role} fixed model must use kenari/`); + } + } else if ('model' in value) { + fail(`${tool}.${role} may specify model only in fixed mode`); + } + return value.mode === 'fixed' + ? { mode: 'fixed', model: value.model } + : { mode: value.mode }; +} + +export function validateConfig(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) fail('root must be an object'); + if (value.version !== 2) fail(`unsupported schema version "${value.version}"`); + if (!value.tools || typeof value.tools !== 'object' || Array.isArray(value.tools)) { + fail('tools must be an object'); + } + const unknownRoot = Object.keys(value).filter((key) => !['version', 'tools'].includes(key)); + if (unknownRoot.length) fail(`unknown top-level field "${unknownRoot[0]}"`); + const unknownTools = Object.keys(value.tools).filter((tool) => !(tool in ROLE_DEFINITIONS)); + if (unknownTools.length) fail(`unknown tool "${unknownTools[0]}"`); + + const tools = {}; + for (const [tool, definition] of Object.entries(ROLE_DEFINITIONS)) { + if (!(tool in value.tools)) continue; + const entry = value.tools[tool]; + if (!entry || typeof entry !== 'object' || Array.isArray(entry) + || Object.keys(entry).some((key) => key !== 'roles')) { + fail(`${tool} must contain only roles`); + } + if (!entry.roles || typeof entry.roles !== 'object' || Array.isArray(entry.roles)) { + fail(`${tool}.roles must be an object`); + } + const unknownRoles = Object.keys(entry.roles).filter((role) => !(role in definition)); + if (unknownRoles.length) fail(`unknown role "${tool}.${unknownRoles[0]}"`); + const missing = Object.keys(definition).filter((role) => !(role in entry.roles)); + if (missing.length) fail(`missing role "${tool}.${missing[0]}"`); + tools[tool] = { roles: {} }; + for (const role of Object.keys(definition)) { + tools[tool].roles[role] = validateRole(tool, role, entry.roles[role]); + } + } + return { version: 2, tools }; +} + +export function loadConfig() { + const value = readJson(configPath()); + return value === null ? null : validateConfig(value); +} + +export function saveConfig(value) { + const validated = validateConfig(value); + writePrivateJson(configPath(), validated); + return validated; +} + +export function getToolConfig(config, tool) { + if (!(tool in ROLE_DEFINITIONS)) throw new KenariError(`unsupported tool "${tool}"`); + return config?.tools?.[tool] || null; +} + +export function hasKenariRoutes(config, tool) { + const tools = tool ? [tool] : Object.keys(config?.tools || {}); + return tools.some((id) => Object.values(config.tools[id]?.roles || {}) + .some((role) => role.mode === 'fixed' || role.mode === 'picker')); +} diff --git a/src/gateway.js b/src/gateway.js index 27f13c5..9822295 100644 --- a/src/gateway.js +++ b/src/gateway.js @@ -3,19 +3,51 @@ import { KenariError } from './store.js'; export class AuthError extends KenariError {} -export async function fetchModels(key) { - const base = gatewayBase(); +export function validateGatewayUrl(value = gatewayBase()) { + let url; + try { url = new URL(value); } + catch { throw new KenariError(`invalid Kenari gateway URL: ${value}`); } + const allowHttp = process.env.KENARI_ALLOW_HTTP === '1'; + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && allowHttp)) { + throw new KenariError('Kenari gateway must use HTTPS. Set KENARI_ALLOW_HTTP=1 only for local development.'); + } + if (url.username || url.password) { + throw new KenariError('Kenari gateway URL must not contain credentials.'); + } + return url.toString().replace(/\/+$/, ''); +} + +export async function fetchCatalog(key, options = {}) { + if (!key) throw new AuthError('Kenari login required. Run: kenari login'); + const base = validateGatewayUrl(options.base || gatewayBase()); + const controller = new AbortController(); + const timeoutMs = options.timeoutMs ?? 5000; + const timer = setTimeout(() => controller.abort(), timeoutMs); + const abort = () => controller.abort(); + options.signal?.addEventListener('abort', abort, { once: true }); let res; try { - res = await fetch(base + '/v1/models', { headers: { authorization: 'Bearer ' + key } }); + res = await fetch(base + '/v1/models', { + headers: { authorization: 'Bearer ' + key }, + signal: controller.signal, + }); } catch (e) { + if (e.name === 'AbortError') throw new KenariError(`catalog request to ${base} timed out`); throw new KenariError(`cannot reach ${base}: ${e.cause?.code || e.message}`); + } finally { + clearTimeout(timer); + options.signal?.removeEventListener('abort', abort); } if (res.status === 401 || res.status === 403) { - throw new AuthError(`kenari rejected the API key (HTTP ${res.status}). Run: kenari key set`); + throw new AuthError(`Kenari rejected the credential (HTTP ${res.status}). Run: kenari login`); } - if (!res.ok) throw new KenariError(`gateway error (HTTP ${res.status})`); - const body = await res.json(); + if (!res.ok) throw new KenariError(`Kenari catalog error (HTTP ${res.status})`); + try { return await res.json(); } + catch { throw new KenariError('Kenari catalog response is not valid JSON'); } +} + +export async function fetchModels(key) { + const body = await fetchCatalog(key); return (body.data || []).map((m) => { // Only trust prices in the gateway's documented unit. An unknown unit // (or a future one) must not be printed as rupiah, so treat in/out as null. diff --git a/src/migrate.js b/src/migrate.js new file mode 100644 index 0000000..7d30996 --- /dev/null +++ b/src/migrate.js @@ -0,0 +1,241 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { backupsDir, claudeSettingsPath, codexConfigPath, statePath } from './paths.js'; +import { loadConfig, saveConfig } from './config.js'; +import { + KenariError, + readJson, + removeFile, + writeFileAtomic, + writeJson, + writePrivateJson, +} from './store.js'; +import * as toml from './toml.js'; + +const CLAUDE_KEYS = { + main: 'ANTHROPIC_MODEL', + opus: 'ANTHROPIC_DEFAULT_OPUS_MODEL', + sonnet: 'ANTHROPIC_DEFAULT_SONNET_MODEL', + haiku: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', +}; +const CODEX_TABLE_KEY = 'table:model_providers.kenari'; + +function fixed(value) { + return typeof value === 'string' && value + ? { mode: 'fixed', model: value.startsWith('kenari/') ? value : `kenari/${value}` } + : { mode: 'native' }; +} + +function same(a, b) { + return a === b; +} + +function timestamp(value) { + return new Date(value).toISOString().replace(/[:.]/g, '-'); +} + +function backupFile(file, label, now) { + let stat; + try { stat = fs.lstatSync(file); } + catch (error) { if (error.code === 'ENOENT') return null; throw error; } + if (stat.isSymbolicLink()) throw new KenariError(`${file} is a symlink; migration aborted`); + const destination = path.join(backupsDir(), timestamp(now), label); + writeFileAtomic(destination, fs.readFileSync(file)); + fs.chmodSync(destination, stat.mode & 0o777); + return { path: destination, mode: stat.mode & 0o777 }; +} + +function inspectClaude(state, file) { + const settings = readJson(file) || {}; + if ('env' in settings && (!settings.env || typeof settings.env !== 'object' || Array.isArray(settings.env))) { + return { settings, env: null, conflicts: ['env'] }; + } + const env = settings.env && typeof settings.env === 'object' ? settings.env : {}; + const conflicts = []; + for (const [key, record] of Object.entries(state.keys || {})) { + const current = key in env ? env[key] : null; + if (!same(current, record.applied) && !same(current, record.before)) conflicts.push(key); + } + return { settings, env, conflicts }; +} + +function migrateClaude(state, file) { + const inspected = inspectClaude(state, file); + if (inspected.conflicts.length) return { conflicts: inspected.conflicts }; + const settings = inspected.settings; + const env = inspected.env; + for (const [key, record] of Object.entries(state.keys || {})) { + if (!same(key in env ? env[key] : null, record.applied)) continue; + if (record.before === null || record.before === undefined) delete env[key]; + else env[key] = record.before; + } + if (state.containerCreated && Object.keys(env).length === 0) delete settings.env; + else settings.env = env; + if (state.fileCreated && Object.keys(settings).length === 0) removeFile(file); + else writeJson(file, settings); + + const applied = Object.fromEntries(Object.entries(state.keys || {}).map(([key, rec]) => [key, rec.applied])); + const roles = {}; + for (const [role, key] of Object.entries(CLAUDE_KEYS)) roles[role] = fixed(applied[key]); + roles.fable = { mode: 'native' }; + roles.subagents = { mode: 'native' }; + return { conflicts: [], toolConfig: { roles } }; +} + +function currentCodex(content, key) { + if (key === CODEX_TABLE_KEY) return toml.getTableText(content, 'model_providers.kenari'); + return toml.getTopLevel(content, key); +} + +function migrateCodex(state, file) { + let content; + try { content = fs.readFileSync(file, 'utf8'); } + catch (error) { if (error.code === 'ENOENT') content = ''; else throw error; } + const conflicts = []; + for (const [key, record] of Object.entries(state.keys || {})) { + const current = currentCodex(content, key); + if (key !== CODEX_TABLE_KEY && current === null && toml.hasTopLevel(content, key)) { + conflicts.push(key); + continue; + } + if (!same(current, record.applied) && !same(current, record.before)) conflicts.push(key); + } + if (conflicts.length) return { conflicts }; + for (const [key, record] of Object.entries(state.keys || {})) { + if (!same(currentCodex(content, key), record.applied)) continue; + if (key === CODEX_TABLE_KEY) { + if (record.before === null) content = toml.deleteTable(content, 'model_providers.kenari'); + else { + const lines = record.before.split('\n'); + content = toml.setTable(content, 'model_providers.kenari', lines.slice(1)); + } + } else if (record.before === null) content = toml.deleteTopLevel(content, key); + else content = toml.setTopLevel(content, key, record.before); + } + if (state.fileCreated && content.trim() === '') removeFile(file); + else writeFileAtomic(file, content); + return { + conflicts: [], + toolConfig: { + roles: { + main: fixed(state.keys?.model?.applied), + review: { mode: 'inherit' }, + subagents: { mode: 'inherit' }, + }, + }, + }; +} + +export function detectV1State() { + const state = readJson(statePath()); + if (!state) return null; + if (state.version !== undefined && state.version !== 1) return null; + return { + version: 1, + tools: state.tools && typeof state.tools === 'object' ? state.tools : {}, + migration_conflicts: Array.isArray(state.migration_conflicts) ? state.migration_conflicts : [], + }; +} + +export function detectOrphanedV1Signatures() { + if (detectV1State()) return []; + const conflicts = []; + const claude = readJson(claudeSettingsPath()); + const env = claude?.env; + if (env && typeof env === 'object' && !Array.isArray(env)) { + for (const key of ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN']) { + if (key in env) conflicts.push({ tool: 'claude', key }); + } + } + let codex = ''; + try { codex = fs.readFileSync(codexConfigPath(), 'utf8'); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + if (toml.getTableText(codex, 'model_providers.kenari') !== null) { + conflicts.push({ tool: 'codex', key: CODEX_TABLE_KEY }); + } + if (toml.getTopLevel(codex, 'model_provider') === 'kenari') { + conflicts.push({ tool: 'codex', key: 'model_provider' }); + } + return conflicts; +} + +export function migrateV1(options = {}) { + const legacy = detectV1State(); + if (!legacy) { + return { config: options.config || loadConfig(), migrated: [], conflicts: [], backups: [] }; + } + const now = options.now ?? Date.now(); + const files = { + claude: options.claudePath || claudeSettingsPath(), + codex: options.codexPath || codexConfigPath(), + }; + const labels = { claude: 'claude-settings.json', codex: 'codex-config.toml' }; + const config = options.config || loadConfig() || { version: 2, tools: {} }; + const next = structuredClone(config); + const migrated = []; + const conflicts = []; + const backups = []; + const migrationTools = {}; + + for (const tool of ['claude', 'codex']) { + const toolState = legacy.tools[tool]; + if (!toolState) continue; + const manual = legacy.migration_conflicts.filter( + (item) => item.tool === tool && !(item.key in (toolState.keys || {})), + ); + const unresolved = manual.filter((item) => { + if (tool === 'claude') { + const value = readJson(files.claude); + if (item.key === 'env') return 'env' in (value || {}); + return item.key in (value?.env || {}); + } + let content = ''; + try { content = fs.readFileSync(files.codex, 'utf8'); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + return item.key === CODEX_TABLE_KEY + ? toml.getTableText(content, 'model_providers.kenari') !== null + : toml.hasTopLevel(content, item.key); + }); + if (unresolved.length) { + conflicts.push(...unresolved); + continue; + } + const backup = backupFile(files[tool], labels[tool], now); + if (backup) backups.push(backup); + const result = tool === 'claude' + ? migrateClaude(toolState, files[tool]) + : migrateCodex(toolState, files[tool]); + if (result.conflicts.length) { + conflicts.push(...result.conflicts.map((key) => ({ tool, key }))); + migrationTools[tool] = { status: 'conflict', keys: result.conflicts }; + continue; + } + next.tools[tool] = result.toolConfig; + migrated.push(tool); + migrationTools[tool] = { status: 'migrated' }; + } + if (conflicts.length) { + const tools = structuredClone(legacy.tools); + for (const conflict of conflicts) { + const sensitive = (conflict.tool === 'claude' && conflict.key === 'ANTHROPIC_AUTH_TOKEN') + || (conflict.tool === 'codex' && conflict.key === CODEX_TABLE_KEY); + if (sensitive) delete tools[conflict.tool]?.keys?.[conflict.key]; + } + writePrivateJson(statePath(), { + version: 1, + tools, + migration_conflicts: conflicts, + }); + return { config, migrated, conflicts, backups }; + } + const saved = saveConfig(next); + writePrivateJson(statePath(), { + version: 2, + migration: { + completed_at: new Date(now).toISOString(), + tools: migrationTools, + backups, + }, + }); + return { config: saved, migrated, conflicts, backups }; +} diff --git a/src/paths.js b/src/paths.js index 99abea1..b7cbc05 100644 --- a/src/paths.js +++ b/src/paths.js @@ -5,8 +5,12 @@ export function kenariHome() { return process.env.KENARI_HOME || path.join(os.homedir(), '.kenari'); } export function credentialsPath() { return path.join(kenariHome(), 'credentials.json'); } +export function configPath() { return path.join(kenariHome(), 'config.json'); } +export function modelCachePath() { return path.join(kenariHome(), 'model-cache.json'); } export function statePath() { return path.join(kenariHome(), 'state.json'); } export function lockDir() { return path.join(kenariHome(), 'lock'); } +export function backupsDir() { return path.join(kenariHome(), 'backups'); } +export function runtimeDir() { return path.join(kenariHome(), 'run'); } export function claudeConfigDir() { return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); } diff --git a/src/router.js b/src/router.js new file mode 100644 index 0000000..2dec3e3 --- /dev/null +++ b/src/router.js @@ -0,0 +1,304 @@ +import http from 'node:http'; +import https from 'node:https'; +import { fork } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { KenariError } from './store.js'; +import { validateGatewayUrl } from './gateway.js'; + +const REQUEST_STRIP = new Set([ + 'host', 'connection', 'content-length', 'transfer-encoding', 'accept-encoding', + 'proxy-authorization', 'proxy-authenticate', 'te', 'trailer', 'upgrade', + 'keep-alive', 'proxy-connection', 'x-kenari-capability', +]); +const RESPONSE_STRIP = new Set([ + 'connection', 'content-length', 'transfer-encoding', + 'proxy-authenticate', 'te', 'trailer', 'upgrade', 'keep-alive', 'proxy-connection', +]); +const NATIVE_AUTH_HEADERS = [ + 'authorization', 'x-api-key', 'api-key', 'anthropic-api-key', +]; + +function safeHeaders(headers, strip) { + const dynamicStrip = new Set(strip); + for (const token of String(headers.connection || '').split(',')) { + if (token.trim()) dynamicStrip.add(token.trim().toLowerCase()); + } + const result = {}; + for (const [key, value] of Object.entries(headers)) { + if (!dynamicStrip.has(key.toLowerCase()) && value !== undefined) result[key] = value; + } + return result; +} + +function replyJson(res, status, message) { + const body = JSON.stringify({ error: { message } }) + '\n'; + res.writeHead(status, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + }); + res.end(body); +} + +function readBody(req, limit) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on('data', (chunk) => { + size += chunk.length; + if (size > limit) { + reject(Object.assign(new Error('request body too large'), { statusCode: 413 })); + req.destroy(); + } else { + chunks.push(chunk); + } + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +function modelMap(catalog) { + return new Map((catalog?.models || []).map((model) => [model.id, model])); +} + +function compatibilityLimit(model, body, compatibility) { + if (!model?.output_limit || !compatibility) return null; + const requested = body.max_tokens ?? body.max_output_tokens; + let desired = null; + if (typeof compatibility === 'function') desired = compatibility({ model, requested, body }); + else { + const rule = compatibility[model.id]; + if (rule && (rule.from === undefined || rule.from === requested)) desired = rule.to; + } + if (!Number.isInteger(desired) || desired <= requested || desired > model.output_limit) return null; + return desired; +} + +async function startRouterServer(options) { + if (!options?.nativeBase || !options?.kenariBase) { + throw new KenariError('router requires nativeBase and kenariBase'); + } + const nativeBase = new URL(options.nativeBase); + const kenariBase = new URL(options.kenariBase); + const models = modelMap(options.catalog); + const capabilityToken = options.capabilityToken || null; + const bodyLimit = options.bodyLimit ?? 64 * 1024 * 1024; + const sockets = new Set(); + + const server = http.createServer(async (req, res) => { + if (capabilityToken && req.headers['x-kenari-capability'] !== capabilityToken) { + replyJson(res, 403, 'invalid router capability'); + return; + } + let raw; + let body = null; + try { + raw = await readBody(req, bodyLimit); + if (raw.length) { + try { body = JSON.parse(raw.toString('utf8')); } catch {} + } else { + body = {}; + } + } catch (error) { + if (!res.headersSent) replyJson(res, error.statusCode || 400, 'invalid request body'); + return; + } + + const selected = typeof body?.model === 'string' ? body.model : ''; + const isKenari = selected.startsWith('kenari/'); + const id = isKenari ? selected.slice('kenari/'.length) : selected; + const model = isKenari ? models.get(id) : null; + if (isKenari && (!id || !model)) { + replyJson(res, 400, `unknown or unavailable Kenari model "${selected}"`); + return; + } + if (isKenari && !options.credential) { + replyJson(res, 401, 'Kenari login required. Run: kenari login'); + return; + } + + const target = isKenari ? kenariBase : nativeBase; + const outgoing = { ...(body || {}) }; + if (isKenari) outgoing.model = id; + const raised = isKenari ? compatibilityLimit(model, outgoing, options.compatibility) : null; + if (raised !== null) { + if ('max_output_tokens' in outgoing) outgoing.max_output_tokens = raised; + else outgoing.max_tokens = raised; + } + const payload = isKenari ? Buffer.from(JSON.stringify(outgoing)) : raw; + const headers = safeHeaders(req.headers, REQUEST_STRIP); + if (isKenari) { + for (const name of NATIVE_AUTH_HEADERS) delete headers[name]; + headers.authorization = `Bearer ${options.credential}`; + } + headers.host = target.host; + headers['content-length'] = String(payload.length); + const basePath = target.pathname.replace(/\/$/, ''); + const requestPath = req.url?.startsWith('/') ? req.url : `/${req.url || ''}`; + const transport = target.protocol === 'https:' ? https : http; + const upstream = transport.request({ + protocol: target.protocol, + hostname: target.hostname, + port: target.port || undefined, + method: req.method, + path: basePath + requestPath, + headers, + }, (upstreamRes) => { + const responseHeaders = safeHeaders(upstreamRes.headers, RESPONSE_STRIP); + res.writeHead(upstreamRes.statusCode || 502, responseHeaders); + upstreamRes.pipe(res); + res.on('close', () => { + if (!res.writableEnded) upstreamRes.destroy(); + }); + }); + upstream.on('error', (error) => { + if (!res.headersSent) replyJson(res, 502, `${isKenari ? 'Kenari' : 'native'} upstream error`); + else res.destroy(error); + }); + req.on('aborted', () => upstream.destroy()); + upstream.end(payload); + if (options.debug) { + const rewrite = raised === null ? '' : ` max_tokens=${body?.max_tokens ?? body?.max_output_tokens}->${raised}`; + options.debug(`route=${isKenari ? 'kenari' : 'native'} model=${id}${rewrite}`); + } + }); + server.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + const close = () => new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + for (const socket of sockets) socket.destroy(); + }); + return { + url: `http://127.0.0.1:${address.port}`, + port: address.port, + capabilityToken, + close, + }; +} + +function childError(message) { + const error = new KenariError(message || 'router child failed'); + error.code = 'KENARI_ROUTER_CHILD'; + return error; +} + +export async function startRouter(options) { + if (typeof options?.compatibility === 'function') { + throw new KenariError('router compatibility rules must be serializable data'); + } + validateGatewayUrl(options.kenariBase); + const child = fork(fileURLToPath(import.meta.url), [], { + env: { ...process.env, KENARI_ROUTER_CHILD: '1' }, + execArgv: process.execArgv.filter((arg) => !arg.startsWith('--input-type')), + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + }); + let childStderr = ''; + child.stderr.on('data', (chunk) => { + childStderr = (childStderr + chunk.toString()).slice(-4000); + }); + let settled = false; + const ready = await new Promise((resolve, reject) => { + const fail = (error) => { + if (settled) return; + settled = true; + reject(error instanceof Error ? error : childError(String(error))); + }; + child.once('error', fail); + child.once('exit', (code, signal) => { + const detail = childStderr.trim() ? `: ${childStderr.trim()}` : ''; + fail(childError(`router exited before ready (${signal || code})${detail}`)); + }); + child.on('message', (message) => { + if (message?.type === 'ready' && !settled) { + settled = true; + resolve(message); + } else if (message?.type === 'error') { + fail(childError(message.message)); + } else if (message?.type === 'debug' && typeof options.debug === 'function') { + options.debug(message.message); + } + }); + child.send({ + type: 'start', + options: { + nativeBase: options.nativeBase, + kenariBase: options.kenariBase, + credential: options.credential || null, + catalog: options.catalog || null, + capabilityToken: options.capabilityToken || null, + bodyLimit: options.bodyLimit, + compatibility: options.compatibility || null, + debug: typeof options.debug === 'function', + }, + }); + }); + let closed = false; + const close = () => { + if (closed) return Promise.resolve(); + closed = true; + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { resolve(); return; } + const timer = setTimeout(() => { + try { child.kill('SIGKILL'); } catch {} + }, 1000); + child.once('exit', () => { clearTimeout(timer); resolve(); }); + try { child.send({ type: 'shutdown' }); } + catch { clearTimeout(timer); resolve(); } + }); + }; + return { + url: ready.url, + port: ready.port, + capabilityToken: ready.capabilityToken, + childPid: child.pid, + close, + }; +} + +async function runRouterChild() { + let router = null; + let stopping = false; + const stop = async () => { + if (stopping) return; + stopping = true; + try { await router?.close(); } catch {} + process.exit(0); + }; + process.once('disconnect', stop); + process.once('SIGTERM', stop); + process.once('SIGINT', stop); + process.on('message', async (message) => { + if (message?.type === 'shutdown') { await stop(); return; } + if (message?.type !== 'start' || router) return; + try { + const childOptions = { + ...message.options, + debug: message.options.debug + ? (line) => process.send?.({ type: 'debug', message: line }) + : null, + }; + router = await startRouterServer(childOptions); + process.send?.({ + type: 'ready', + url: router.url, + port: router.port, + capabilityToken: router.capabilityToken, + }); + } catch (error) { + process.send?.({ type: 'error', message: error.message }); + await stop(); + } + }); +} + +if (process.env.KENARI_ROUTER_CHILD === '1' && typeof process.send === 'function') { + runRouterChild(); +} diff --git a/src/runtime/claude.js b/src/runtime/claude.js new file mode 100644 index 0000000..c624eee --- /dev/null +++ b/src/runtime/claude.js @@ -0,0 +1,48 @@ +import { KenariError } from '../store.js'; + +export const CLAUDE_ROLE_ENV = Object.freeze({ + main: 'ANTHROPIC_MODEL', + opus: 'ANTHROPIC_DEFAULT_OPUS_MODEL', + sonnet: 'ANTHROPIC_DEFAULT_SONNET_MODEL', + haiku: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + fable: 'ANTHROPIC_DEFAULT_FABLE_MODEL', + subagents: 'CLAUDE_CODE_SUBAGENT_MODEL', +}); + +const CONFLICT_ENV = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY']; + +export function findClaudeEnvConflicts(env = process.env) { + return CONFLICT_ENV.filter((name) => typeof env[name] === 'string' && env[name] !== ''); +} + +export function buildClaudeLaunch(options) { + const inputEnv = options.env || process.env; + const args = options.args || []; + const usesKenari = Object.values(options.toolConfig?.roles || {}) + .some((role) => role.mode === 'fixed'); + if (usesKenari && args.some((arg) => arg === '--fallback-model' || arg.startsWith('--fallback-model='))) { + throw new KenariError('Claude fallback models are disabled for mixed Kenari routing'); + } + const conflicts = findClaudeEnvConflicts(inputEnv); + if (conflicts.length && !options.allowAmbiguousEnv) { + throw new KenariError(`Claude routing environment is ambiguous: ${conflicts.join(', ')}`); + } + const env = { ...inputEnv, ANTHROPIC_BASE_URL: options.routerUrl }; + if (options.routerCapabilityToken) { + const existing = inputEnv.ANTHROPIC_CUSTOM_HEADERS?.trim(); + env.ANTHROPIC_CUSTOM_HEADERS = [ + existing, + `X-Kenari-Capability: ${options.routerCapabilityToken}`, + ].filter(Boolean).join('\n'); + } + delete env.ANTHROPIC_AUTH_TOKEN; + delete env.ANTHROPIC_API_KEY; + const roles = options.toolConfig?.roles || {}; + for (const [role, variable] of Object.entries(CLAUDE_ROLE_ENV)) { + const setting = roles[role]; + if (setting?.mode === 'fixed') env[variable] = setting.model; + else if (setting?.mode === 'native' && !(variable in inputEnv)) delete env[variable]; + } + delete env.ANTHROPIC_SMALL_FAST_MODEL; + return { args: [...args], env }; +} diff --git a/src/runtime/codex.js b/src/runtime/codex.js new file mode 100644 index 0000000..b6aa97e --- /dev/null +++ b/src/runtime/codex.js @@ -0,0 +1,93 @@ +import { spawnSync } from 'node:child_process'; +import { KenariError } from '../store.js'; + +function tomlString(value) { + return JSON.stringify(value); +} + +export function loadCodexNativeModels(binary, env = process.env) { + for (const args of [['debug', 'models'], ['debug', 'models', '--bundled']]) { + const result = spawnSync(binary, args, { + encoding: 'utf8', + env, + timeout: 5000, + windowsHide: true, + }); + if (result.error || result.status !== 0) continue; + try { + const parsed = JSON.parse(result.stdout); + if (Array.isArray(parsed.models) && parsed.models.length) return parsed.models; + } catch {} + } + throw new KenariError('cannot read the Codex native model catalog'); +} + +export function codexKenariModels(cache, nativeModels = []) { + const fallbackTemplate = nativeModels[0]; + if ((cache?.models || []).length && !fallbackTemplate) { + throw new KenariError('a native Codex model is required to build the merged catalog'); + } + const basePriority = Math.max(0, ...nativeModels.map((model) => model.priority || 0)); + return (cache?.models || []).map((model, index) => { + const template = nativeModels.find((native) => native.slug === model.id) || fallbackTemplate; + const efforts = model.reasoning_efforts?.length + ? model.reasoning_efforts + : (template.supported_reasoning_levels || []).map((level) => level.effort); + return { + ...template, + slug: `kenari/${model.id}`, + display_name: `Kenari ${model.id}`, + description: `Kenari route for ${model.id}`, + default_reasoning_level: efforts[0] || template.default_reasoning_level, + supported_reasoning_levels: efforts.map((effort) => ({ + effort, + description: `${effort} reasoning`, + })), + visibility: 'list', + supported_in_api: true, + priority: basePriority + index + 1, + context_window: model.context_limit || template.context_window, + max_context_window: model.context_limit || template.max_context_window, + upgrade: null, + }; + }); +} + +export function buildCodexLaunch(options) { + const inputArgs = options.args || []; + for (let index = 0; index < inputArgs.length; index += 1) { + if (inputArgs[index] === '--oss' || inputArgs[index] === '--local-provider' + || inputArgs[index].startsWith('--local-provider=')) { + throw new KenariError(`unsafe Codex routing override: ${inputArgs[index]}`); + } + const inline = inputArgs[index].startsWith('--config=') + ? inputArgs[index].slice('--config='.length) + : null; + if (inline === null && !['-c', '--config'].includes(inputArgs[index])) continue; + const value = inline ?? inputArgs[index + 1] ?? ''; + const key = value.split('=', 1)[0].trim(); + if (['model_provider', 'openai_base_url', 'model_catalog_json'].includes(key) + || key.startsWith('model_providers.')) { + throw new KenariError(`unsafe Codex routing override: ${key}`); + } + if (inline === null) index += 1; + } + const overrides = [ + 'model_provider="openai"', + `openai_base_url=${tomlString(options.routerUrl)}`, + ]; + if (options.catalogPath) { + overrides.push(`model_catalog_json=${tomlString(options.catalogPath)}`); + } + const roles = options.toolConfig?.roles || {}; + if (roles.main?.mode === 'fixed') overrides.push(`model=${tomlString(roles.main.model)}`); + if (roles.review?.mode === 'fixed') overrides.push(`review_model=${tomlString(roles.review.model)}`); + if (roles.subagents?.mode === 'fixed') { + overrides.push(`agents.default_subagent_model=${tomlString(roles.subagents.model)}`); + } + const args = []; + for (const value of overrides) args.push('-c', value); + args.push(...inputArgs); + const env = { ...(options.env || process.env) }; + return { args, env }; +} diff --git a/src/store.js b/src/store.js index b1b8dbc..d965c5d 100644 --- a/src/store.js +++ b/src/store.js @@ -17,7 +17,7 @@ export function writeFileAtomic(file, content) { `Point the CLI at the real file or edit it manually.`); } const mode = st ? (st.mode & 0o777) : 0o600; - fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); const tmp = path.join(path.dirname(file), `.${path.basename(file)}.kenari-${process.pid}.tmp`); fs.writeFileSync(tmp, content, { mode }); try { fs.chmodSync(tmp, mode); } catch {} @@ -48,6 +48,17 @@ export function writeJson(file, obj) { writeFileAtomic(file, JSON.stringify(obj, null, 2) + '\n'); } +export function writePrivateJson(file, obj) { + writeJson(file, obj); + try { fs.chmodSync(path.dirname(file), 0o700); } catch {} + try { fs.chmodSync(file, 0o600); } catch {} +} + +export function removeFile(file) { + try { fs.rmSync(file); return true; } + catch (e) { if (e.code === 'ENOENT') return false; throw e; } +} + export function getKey() { const c = readJson(credentialsPath()); return c && typeof c.api_key === 'string' ? c.api_key : null; @@ -57,10 +68,10 @@ export function setKey(key) { if (!/^kn-[A-Za-z0-9]{8,}$/.test(k)) { throw new KenariError('That does not look like a kenari API key (kn-...). Get one at https://kenari.id/keys'); } - writeJson(credentialsPath(), { api_key: k }); + writePrivateJson(credentialsPath(), { api_key: k }); } export function deleteKey() { - try { fs.rmSync(credentialsPath()); } catch {} + removeFile(credentialsPath()); } export function maskKey(key) { return key.slice(0, 6) + '...' + key.slice(-3); @@ -68,20 +79,33 @@ export function maskKey(key) { export function loadState() { const parsed = readJson(statePath()); + if (parsed?.version === 2) { + return { + version: 2, + migration: parsed.migration && typeof parsed.migration === 'object' ? parsed.migration : {}, + tools: {}, + }; + } if (parsed && parsed.version && parsed.version !== 1) { throw new KenariError('state.json was written by a newer kenari CLI; upgrade this CLI or remove ~/.kenari/state.json'); } - return { version: 1, tools: (parsed && typeof parsed.tools === 'object' && parsed.tools) || {} }; + return { + version: 1, + tools: (parsed && typeof parsed.tools === 'object' && parsed.tools) || {}, + migration_conflicts: Array.isArray(parsed?.migration_conflicts) ? parsed.migration_conflicts : [], + }; } -export function saveState(state) { writeJson(statePath(), state); } +export function saveState(state) { writePrivateJson(statePath(), state); } export function getToolState(id) { return loadState().tools[id] || null; } export function setToolState(id, toolState) { const s = loadState(); + if (s.version !== 1) throw new KenariError('legacy provider switching is unavailable after version 2 migration'); s.tools[id] = toolState; saveState(s); } export function clearToolState(id) { const s = loadState(); + if (s.version !== 1) throw new KenariError('legacy provider switching is unavailable after version 2 migration'); delete s.tools[id]; saveState(s); } diff --git a/src/supervisor.js b/src/supervisor.js new file mode 100644 index 0000000..680036c --- /dev/null +++ b/src/supervisor.js @@ -0,0 +1,74 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { spawn } from 'node:child_process'; +import { KenariError } from './store.js'; +import { startRouter } from './router.js'; + +export function resolveBinary(name, options = {}) { + if (!name || name.includes(path.sep)) { + if (name && fs.existsSync(name)) return name; + throw new KenariError(`original tool not found: ${name || '(missing name)'}`); + } + const env = options.env || process.env; + const exclude = new Set((options.exclude || []).map((file) => { + try { return fs.realpathSync(file); } catch { return path.resolve(file); } + })); + const searched = []; + for (const dir of (env.PATH || '').split(path.delimiter).filter(Boolean)) { + const candidate = path.join(dir, name); + searched.push(candidate); + try { + const real = fs.realpathSync(candidate); + if (!exclude.has(real) && fs.statSync(real).isFile()) { + fs.accessSync(real, fs.constants.X_OK); + return real; + } + } catch {} + } + throw new KenariError(`original ${name} not found (searched ${searched.join(', ') || 'empty PATH'})`); +} + +function exitCode(code, signal) { + if (Number.isInteger(code)) return code; + const number = signal && os.constants.signals[signal]; + return number ? 128 + number : 1; +} + +export async function runWrappedTool(options) { + const router = await startRouter(options.routerOptions); + let child; + const listeners = []; + try { + const built = options.runtimeBuilder({ + routerUrl: router.url, + routerCapabilityToken: router.capabilityToken, + args: options.args || [], + env: options.env || process.env, + ...(options.runtimeOptions || {}), + }); + child = spawn(options.binary, built.args, { + env: built.env, + stdio: 'inherit', + windowsHide: false, + }); + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGWINCH']) { + const handler = () => { + if (child.exitCode === null && !child.killed) { + try { child.kill(signal); } catch {} + } + }; + try { + process.on(signal, handler); + listeners.push([signal, handler]); + } catch {} + } + return await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => resolve(exitCode(code, signal))); + }); + } finally { + for (const [signal, handler] of listeners) process.off(signal, handler); + await router.close(); + } +} diff --git a/test/claude.test.js b/test/claude.test.js index 3106f90..427d91d 100644 --- a/test/claude.test.js +++ b/test/claude.test.js @@ -4,108 +4,21 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -let home, cdir; -beforeEach(() => { - home = fs.mkdtempSync(path.join(os.tmpdir(), 'kenari-claude-')); - cdir = path.join(home, 'claude'); - process.env.KENARI_HOME = path.join(home, 'kh'); - process.env.CLAUDE_CONFIG_DIR = cdir; - delete process.env.KENARI_BASE_URL; -}); - -const MAPPING = { opus: 'glm-5-2', sonnet: 'kimi-k2-7-code', haiku: 'deepseek-v4-flash' }; -const KEY = 'kn-f4kef4kef4kef4kef4kef4kef4ke1234'; -const settings = () => JSON.parse(fs.readFileSync(path.join(cdir, 'settings.json'), 'utf8')); - -test('detect: false when dir missing, true when present', async () => { - const claude = (await import('../src/adapters/claude.js')).default; - assert.equal(claude.detect().installed, false); - fs.mkdirSync(cdir, { recursive: true }); - assert.equal(claude.detect().installed, true); -}); - -test('apply on missing file creates minimal settings; restore deletes it', async () => { - const claude = (await import('../src/adapters/claude.js')).default; - claude.apply(MAPPING, KEY); - const s = settings(); - assert.equal(s.env.ANTHROPIC_BASE_URL, 'https://kenari.id'); - assert.equal(s.env.ANTHROPIC_AUTH_TOKEN, KEY); - assert.equal(s.env.ANTHROPIC_DEFAULT_OPUS_MODEL, 'glm-5-2'); - assert.equal(s.env.ANTHROPIC_DEFAULT_SONNET_MODEL, 'kimi-k2-7-code'); - assert.equal(s.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, 'deepseek-v4-flash'); - assert.equal(s.env.ANTHROPIC_MODEL, 'kimi-k2-7-code'); // main loop pinned to sonnet slot - assert.equal(s.env.ANTHROPIC_SMALL_FAST_MODEL, undefined); - assert.equal(claude.status().provider, 'kenari'); - const r = claude.restore(); - assert.equal(r.restored, true); - assert.deepEqual(r.conflicts, []); - assert.equal(fs.existsSync(path.join(cdir, 'settings.json')), false); -}); - -test('user keys and prior env values survive the round trip', async () => { - const claude = (await import('../src/adapters/claude.js')).default; - fs.mkdirSync(cdir, { recursive: true }); - fs.writeFileSync(path.join(cdir, 'settings.json'), JSON.stringify({ - theme: 'dark', - env: { ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-8', ANTHROPIC_MODEL: 'claude-sonnet-5', HTTP_PROXY: 'http://p:1' }, - })); - claude.apply(MAPPING, KEY); - assert.equal(settings().theme, 'dark'); - assert.equal(settings().env.HTTP_PROXY, 'http://p:1'); - assert.equal(settings().env.ANTHROPIC_MODEL, 'kimi-k2-7-code'); - claude.restore(); - const s = settings(); - assert.equal(s.theme, 'dark'); - assert.equal(s.env.ANTHROPIC_DEFAULT_OPUS_MODEL, 'claude-opus-4-8'); - assert.equal(s.env.ANTHROPIC_MODEL, 'claude-sonnet-5'); // user's prior pin restored - assert.equal(s.env.HTTP_PROXY, 'http://p:1'); - assert.equal(s.env.ANTHROPIC_BASE_URL, undefined); - assert.equal(s.env.ANTHROPIC_AUTH_TOKEN, undefined); -}); - -test('repeated apply keeps the FIRST baseline', async () => { - const claude = (await import('../src/adapters/claude.js')).default; - fs.mkdirSync(cdir, { recursive: true }); - fs.writeFileSync(path.join(cdir, 'settings.json'), JSON.stringify({ - env: { ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-8' }, - })); - claude.apply(MAPPING, KEY); - claude.apply({ ...MAPPING, opus: 'deepseek-v4-pro' }, KEY); - claude.restore(); - assert.equal(settings().env.ANTHROPIC_DEFAULT_OPUS_MODEL, 'claude-opus-4-8'); -}); - -test('hand-edited owned key: restore leaves it and reports conflict; second restore finishes', async () => { - const claude = (await import('../src/adapters/claude.js')).default; - claude.apply(MAPPING, KEY); - const f = path.join(cdir, 'settings.json'); - const s = JSON.parse(fs.readFileSync(f, 'utf8')); - s.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'my-custom'; - fs.writeFileSync(f, JSON.stringify(s)); - const r = claude.restore(); - assert.equal(r.conflicts.length, 1); - assert.match(r.conflicts[0], /ANTHROPIC_DEFAULT_OPUS_MODEL/); - const after = JSON.parse(fs.readFileSync(f, 'utf8')); - assert.equal(after.env.ANTHROPIC_DEFAULT_OPUS_MODEL, 'my-custom'); // untouched - assert.equal(after.env.ANTHROPIC_AUTH_TOKEN, undefined); // others restored - // user resolves by hand, then restore again clears cleanly - delete after.env.ANTHROPIC_DEFAULT_OPUS_MODEL; - fs.writeFileSync(f, JSON.stringify(after)); - const r2 = claude.restore(); - assert.equal(r2.conflicts.length, 0); -}); - -test('restore without state reports not switched', async () => { - const claude = (await import('../src/adapters/claude.js')).default; - const r = claude.restore(); - assert.equal(r.restored, false); -}); +let home; -test('status reads reality: default before apply, kenari after, mapping echoed', async () => { - const claude = (await import('../src/adapters/claude.js')).default; - assert.equal(claude.status().provider, 'default'); - claude.apply(MAPPING, KEY); - const st = claude.status(); - assert.equal(st.provider, 'kenari'); - assert.deepEqual(st.mapping, MAPPING); +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'kenari-claude-detect-')); + process.env.CLAUDE_CONFIG_DIR = path.join(home, 'claude'); +}); + +test('Claude adapter only detects installation and never edits settings', async () => { + const adapter = (await import('../src/adapters/claude.js')).default; + assert.equal(adapter.detect().installed, false); + fs.mkdirSync(process.env.CLAUDE_CONFIG_DIR, { recursive: true }); + const settings = path.join(process.env.CLAUDE_CONFIG_DIR, 'settings.json'); + fs.writeFileSync(settings, '{"theme":"dark"}\n'); + assert.equal(adapter.detect().installed, true); + assert.equal(adapter.detect().configPath, settings); + assert.deepEqual(Object.keys(adapter).sort(), ['detect', 'id', 'name']); + assert.equal(fs.readFileSync(settings, 'utf8'), '{"theme":"dark"}\n'); }); diff --git a/test/cli.test.js b/test/cli.test.js index 2ae11db..4bd2f56 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -1,179 +1,236 @@ -import { test, beforeEach } from 'node:test'; +import { test, beforeEach, after } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import http from 'node:http'; -let home, out; -const logs = () => out.join('\n'); +let home; +let output; +const servers = []; +const logs = () => output.join('\n'); + beforeEach(() => { - home = fs.mkdtempSync(path.join(os.tmpdir(), 'kenari-cli-')); - process.env.KENARI_HOME = path.join(home, 'kh'); - process.env.CLAUDE_CONFIG_DIR = path.join(home, 'claude'); - process.env.CODEX_HOME = path.join(home, 'codex'); + home = fs.mkdtempSync(path.join(os.tmpdir(), 'kenari-cli-v2-')); + process.env.KENARI_HOME = path.join(home, 'kenari'); + process.env.CLAUDE_CONFIG_DIR = path.join(home, 'claude-home'); + process.env.CODEX_HOME = path.join(home, 'codex-home'); delete process.env.KENARI_BASE_URL; - out = []; + delete process.env.KENARI_ALLOW_HTTP; + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.ANTHROPIC_API_KEY; + output = []; +}); + +after(() => { + for (const server of servers) server.close(); }); async function run(...argv) { - const orig = console.log; - const origErr = console.error; - console.log = (...a) => out.push(a.join(' ')); - console.error = (...a) => out.push(a.join(' ')); + const originalLog = console.log; + const originalError = console.error; + console.log = (...args) => output.push(args.join(' ')); + console.error = (...args) => output.push(args.join(' ')); try { const { main } = await import('../src/cli.js'); return await main(argv); - } finally { console.log = orig; console.error = origErr; } + } finally { + console.log = originalLog; + console.error = originalError; + } } -function stubGateway(models) { +function stubCatalog(models) { return new Promise((resolve) => { - const s = http.createServer((req, res) => { - if (req.headers.authorization !== 'Bearer kn-testkey123' && req.headers.authorization !== 'Bearer kn-newkey456789') { - res.statusCode = 401; res.end('{}'); return; + const server = http.createServer((req, res) => { + if (req.url !== '/v1/models') { + res.statusCode = 404; + res.end(); + return; + } + if (req.headers.authorization !== 'Bearer kn-testkey123') { + res.statusCode = 401; + res.end('{}'); + return; } + res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ data: models })); }); - s.unref(); - s.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${s.address().port}`)); + servers.push(server); + server.listen(0, '127.0.0.1', () => { + resolve(`http://127.0.0.1:${server.address().port}`); + }); }); } -const CATALOG = [ - { id: 'glm-5-2', pricing: { input: 13600000000, output: 43200000000, currency: 'IDR', unit: 'micro_idr_per_1m_tokens' }, context_length: 1048576 }, - { id: 'kimi-k2-7-code', pricing: { input: 10400000000, output: 53600000000, currency: 'IDR', unit: 'micro_idr_per_1m_tokens' }, context_length: 262144 }, - { id: 'deepseek-v4-flash', pricing: { input: 1700000000, output: 3400000000, currency: 'IDR', unit: 'micro_idr_per_1m_tokens' }, context_length: 1048576 }, -]; - -test('status: not found / default lifecycle', async () => { - assert.equal(await run('status'), 0); - assert.match(logs(), /claude\s+not found/); - fs.mkdirSync(process.env.CLAUDE_CONFIG_DIR, { recursive: true }); - out = []; - await run('status'); - assert.match(logs(), /claude\s+default/); +const CATALOG = [{ + id: 'glm-5-2', + pricing: { + input: 13600000000, + output: 40800000000, + currency: 'IDR', + unit: 'micro_idr_per_1m_tokens', + }, + context_length: 200000, + output_limit: 32000, + reasoning_efforts: ['low', 'high'], +}]; + +test('help exposes v2 surface and removed commands stay unknown', async () => { + assert.equal(await run('help'), 0); + assert.match(logs(), /kenari configure/); + assert.match(logs(), /kenari claude/); + assert.doesNotMatch(logs(), /kenari use/); + output = []; + assert.equal(await run('use', 'claude'), 1); + assert.match(logs(), /unknown command: use/); + output = []; + assert.equal(await run('key', 'show'), 1); + assert.match(logs(), /unknown command: key/); }); -test('use claude with flags, then status, then use claude default', async () => { - fs.mkdirSync(process.env.CLAUDE_CONFIG_DIR, { recursive: true }); - process.env.KENARI_BASE_URL = await stubGateway(CATALOG); - const { setKey } = await import('../src/store.js'); - setKey('kn-testkey123'); - const code = await run('use', 'claude', '--opus', 'glm-5-2', '--sonnet', 'kimi-k2-7-code', '--haiku', 'deepseek-v4-flash'); - assert.equal(code, 0); - out = []; - await run('status'); - assert.match(logs(), /claude\s+kenari/); - assert.match(logs(), /opus=glm-5-2/); - out = []; - assert.equal(await run('use', 'claude', 'default'), 0); - await run('status'); - assert.match(logs(), /claude\s+default/); +test('native-only automation needs no login or catalog', async () => { + assert.equal(await run( + 'configure', 'claude', + '--main', 'native', + '--opus', 'native', + '--sonnet', 'native', + '--haiku', 'native', + '--fable', 'native', + '--subagents', 'native', + '--yes', + ), 0); + const config = JSON.parse(fs.readFileSync(path.join(process.env.KENARI_HOME, 'config.json'), 'utf8')); + assert.equal(config.version, 2); + assert.equal(config.tools.claude.roles.main.mode, 'native'); + assert.equal(fs.existsSync(path.join(process.env.KENARI_HOME, 'credentials.json')), false); }); -test('use claude non-TTY without flags applies validated defaults', async () => { - fs.mkdirSync(process.env.CLAUDE_CONFIG_DIR, { recursive: true }); - process.env.KENARI_BASE_URL = await stubGateway(CATALOG); - const { setKey } = await import('../src/store.js'); - setKey('kn-testkey123'); - assert.equal(await run('use', 'claude'), 0); - const s = JSON.parse(fs.readFileSync(path.join(process.env.CLAUDE_CONFIG_DIR, 'settings.json'), 'utf8')); - assert.equal(s.env.ANTHROPIC_DEFAULT_OPUS_MODEL, 'glm-5-2'); +test('non-interactive automation rejects partial roles', async () => { + assert.equal(await run('configure', 'codex', '--main', 'native', '--yes'), 1); + assert.match(logs(), /missing --review, --subagents/); }); -test('use claude with a model id missing from catalog fails', async () => { - fs.mkdirSync(process.env.CLAUDE_CONFIG_DIR, { recursive: true }); - process.env.KENARI_BASE_URL = await stubGateway(CATALOG); +test('fixed route validates catalog and writes namespaced config', async () => { + process.env.KENARI_BASE_URL = await stubCatalog(CATALOG); + process.env.KENARI_ALLOW_HTTP = '1'; const { setKey } = await import('../src/store.js'); setKey('kn-testkey123'); - assert.equal(await run('use', 'claude', '--opus', 'not-a-model'), 1); - assert.match(logs(), /not-a-model/); + assert.equal(await run( + 'configure', 'codex', + '--main', 'picker', + '--review', 'inherit', + '--subagents', 'kenari/glm-5-2', + '--yes', + ), 0); + const config = JSON.parse(fs.readFileSync(path.join(process.env.KENARI_HOME, 'config.json'), 'utf8')); + assert.deepEqual(config.tools.codex.roles.subagents, { + mode: 'fixed', + model: 'kenari/glm-5-2', + }); + const cache = JSON.parse(fs.readFileSync(path.join(process.env.KENARI_HOME, 'model-cache.json'), 'utf8')); + assert.equal(cache.models[0].id, 'glm-5-2'); }); -test('use claude with a valueless flag fails naming the slot', async () => { - fs.mkdirSync(process.env.CLAUDE_CONFIG_DIR, { recursive: true }); - process.env.KENARI_BASE_URL = await stubGateway(CATALOG); +test('unknown fixed model fails closed', async () => { + process.env.KENARI_BASE_URL = await stubCatalog(CATALOG); + process.env.KENARI_ALLOW_HTTP = '1'; const { setKey } = await import('../src/store.js'); setKey('kn-testkey123'); - assert.equal(await run('use', 'claude', '--opus'), 1); - assert.match(logs(), /opus/); + assert.equal(await run( + 'configure', 'codex', + '--main', 'kenari/missing', + '--review', 'inherit', + '--subagents', 'inherit', + '--yes', + ), 1); + assert.match(logs(), /kenari\/missing/); + assert.equal(fs.existsSync(path.join(process.env.KENARI_HOME, 'config.json')), false); }); -test('bad key: use fails with auth message', async () => { - fs.mkdirSync(process.env.CLAUDE_CONFIG_DIR, { recursive: true }); - process.env.KENARI_BASE_URL = await stubGateway(CATALOG); +test('status JSON is offline and never prints credential', async () => { const { setKey } = await import('../src/store.js'); - setKey('kn-wrongkey1234'); - assert.equal(await run('use', 'claude'), 1); - assert.match(logs(), /rejected the API key/); + const key = 'kn-f4kef4kef4kef4kef4kef4kef4ke1234'; + setKey(key); + assert.equal(await run('status', '--json'), 0); + assert.match(logs(), /"credential": "stored"/); + assert.doesNotMatch(logs(), new RegExp(key)); }); -test('no key non-TTY: use fails with instruction', async () => { - fs.mkdirSync(process.env.CLAUDE_CONFIG_DIR, { recursive: true }); - assert.equal(await run('use', 'claude'), 1); - assert.match(logs(), /kenari login/); -}); - -test('use unknown tool / not-installed tool fail cleanly', async () => { - assert.equal(await run('use', 'gemini'), 1); - assert.match(logs(), /claude|codex/); - out = []; - assert.equal(await run('use', 'codex'), 1); - assert.match(logs(), /not found/); -}); - -test('use codex end to end with flags', async () => { - fs.mkdirSync(process.env.CODEX_HOME, { recursive: true }); - process.env.KENARI_BASE_URL = await stubGateway(CATALOG); +test('models JSON uses kenari prefix and includes limits', async () => { + process.env.KENARI_BASE_URL = await stubCatalog(CATALOG); + process.env.KENARI_ALLOW_HTTP = '1'; const { setKey } = await import('../src/store.js'); setKey('kn-testkey123'); - assert.equal(await run('use', 'codex', '--model', 'glm-5-2'), 0); - const cfgTxt = fs.readFileSync(path.join(process.env.CODEX_HOME, 'config.toml'), 'utf8'); - assert.match(cfgTxt, /model_provider = "kenari"/); - assert.equal(await run('use', 'codex', 'default'), 0); - assert.equal(fs.existsSync(path.join(process.env.CODEX_HOME, 'config.toml')), false); + assert.equal(await run('models', '--json'), 0); + assert.match(logs(), /"id": "kenari\/glm-5-2"/); + assert.match(logs(), /"output_limit": 32000/); }); -test('key show masked, key delete', async () => { +test('reset leaves login but removes unused shared cache', async () => { + process.env.KENARI_BASE_URL = await stubCatalog(CATALOG); + process.env.KENARI_ALLOW_HTTP = '1'; const { setKey, getKey } = await import('../src/store.js'); - setKey('kn-f4kef4kef4kef4kef4kef4kef4ke1234'); - assert.equal(await run('key', 'show'), 0); - assert.match(logs(), /kn-f4k\.\.\./); - assert.ok(!logs().includes(getKey())); - out = []; - assert.equal(await run('key', 'delete'), 0); - assert.equal(getKey(), null); + setKey('kn-testkey123'); + await run( + 'configure', 'codex', + '--main', 'picker', + '--review', 'inherit', + '--subagents', 'inherit', + '--yes', + ); + output = []; + assert.equal(await run('reset', 'codex'), 0); + assert.equal(getKey(), 'kn-testkey123'); + assert.equal(fs.existsSync(path.join(process.env.KENARI_HOME, 'model-cache.json')), false); }); -test('key set --stdin re-applies to switched adapters', async () => { - fs.mkdirSync(process.env.CLAUDE_CONFIG_DIR, { recursive: true }); - process.env.KENARI_BASE_URL = await stubGateway(CATALOG); - const { setKey } = await import('../src/store.js'); +test('logout deletes only the Kenari credential', async () => { + const { setKey, getKey } = await import('../src/store.js'); setKey('kn-testkey123'); - await run('use', 'claude'); - const origStdin = Object.getOwnPropertyDescriptor(process, 'stdin'); - const { Readable } = await import('node:stream'); - const fake = Readable.from(['kn-newkey456789\n']); - fake.isTTY = false; - Object.defineProperty(process, 'stdin', { value: fake, configurable: true }); - try { assert.equal(await run('key', 'set', '--stdin'), 0); } - finally { Object.defineProperty(process, 'stdin', origStdin); } - const s = JSON.parse(fs.readFileSync(path.join(process.env.CLAUDE_CONFIG_DIR, 'settings.json'), 'utf8')); - assert.equal(s.env.ANTHROPIC_AUTH_TOKEN, 'kn-newkey456789'); + await run( + 'configure', 'claude', + '--main', 'native', + '--opus', 'native', + '--sonnet', 'native', + '--haiku', 'native', + '--fable', 'native', + '--subagents', 'native', + '--yes', + ); + assert.equal(await run('logout'), 0); + assert.equal(getKey(), null); + assert.equal(fs.existsSync(path.join(process.env.KENARI_HOME, 'config.json')), true); }); -test('models prints table', async () => { - process.env.KENARI_BASE_URL = await stubGateway(CATALOG); - const { setKey } = await import('../src/store.js'); - setKey('kn-testkey123'); - assert.equal(await run('models'), 0); - assert.match(logs(), /glm-5-2/); - assert.match(logs(), /Rp13\.600/); +test('wrapper requires configuration outside a TTY', async () => { + assert.equal(await run('claude', '--version'), 1); + assert.match(logs(), /run: kenari configure claude/); }); -test('help exits 0, unknown command exits 1', async () => { - assert.equal(await run('--help'), 0); - assert.match(logs(), /kenari use/); - assert.equal(await run('frobnicate'), 1); +test('native wrapper preserves original child exit code', async (t) => { + if (process.platform === 'win32') return t.skip('POSIX executable fixture'); + const bin = path.join(home, 'bin'); + fs.mkdirSync(bin, { recursive: true }); + const fake = path.join(bin, 'claude'); + fs.writeFileSync(fake, '#!/bin/sh\nexit 7\n', { mode: 0o755 }); + const oldPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${oldPath || ''}`; + try { + await run( + 'configure', 'claude', + '--main', 'native', + '--opus', 'native', + '--sonnet', 'native', + '--haiku', 'native', + '--fable', 'native', + '--subagents', 'native', + '--yes', + ); + output = []; + assert.equal(await run('claude', '--version'), 7); + } finally { + process.env.PATH = oldPath; + } }); diff --git a/test/codex.test.js b/test/codex.test.js index 48df448..eb92375 100644 --- a/test/codex.test.js +++ b/test/codex.test.js @@ -4,151 +4,21 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -let home, cxdir, cfg; -beforeEach(() => { - home = fs.mkdtempSync(path.join(os.tmpdir(), 'kenari-codex-')); - cxdir = path.join(home, 'codex'); - cfg = path.join(cxdir, 'config.toml'); - process.env.KENARI_HOME = path.join(home, 'kh'); - process.env.CODEX_HOME = cxdir; - delete process.env.KENARI_BASE_URL; -}); - -const KEY = 'kn-f4kef4kef4kef4kef4kef4kef4ke1234'; -const USER_CFG = `# hand-tuned -model = "gpt-5-5" -approval_policy = "never" - -[mcp_servers.db] -command = "npx" -`; - -test('apply writes provider table + top-level keys, preserves user content', async () => { - const codex = (await import('../src/adapters/codex.js')).default; - fs.mkdirSync(cxdir, { recursive: true }); - fs.writeFileSync(cfg, USER_CFG); - codex.apply({ model: 'glm-5-2' }, KEY); - const out = fs.readFileSync(cfg, 'utf8'); - assert.match(out, /^model = "glm-5-2"$/m); - assert.match(out, /^model_provider = "kenari"$/m); - assert.ok(out.includes('[model_providers.kenari]')); - assert.ok(out.includes('base_url = "https://kenari.id/v1"')); - assert.ok(out.includes('wire_api = "responses"')); - assert.ok(out.includes(`http_headers = { "Authorization" = "Bearer ${KEY}" }`)); - assert.ok(out.includes('# hand-tuned')); - assert.ok(out.includes('approval_policy = "never"')); - assert.ok(out.includes('[mcp_servers.db]')); -}); - -test('status: default before, kenari after, mapping echoed', async () => { - const codex = (await import('../src/adapters/codex.js')).default; - assert.equal(codex.status().provider, 'default'); - codex.apply({ model: 'glm-5-2' }, KEY); - const st = codex.status(); - assert.equal(st.provider, 'kenari'); - assert.deepEqual(st.mapping, { model: 'glm-5-2' }); -}); - -test('restore returns exact prior file content', async () => { - const codex = (await import('../src/adapters/codex.js')).default; - fs.mkdirSync(cxdir, { recursive: true }); - fs.writeFileSync(cfg, USER_CFG); - codex.apply({ model: 'glm-5-2' }, KEY); - const r = codex.restore(); - assert.equal(r.restored, true); - assert.deepEqual(r.conflicts, []); - assert.equal(fs.readFileSync(cfg, 'utf8'), USER_CFG); -}); - -test('apply on missing file creates it; restore removes it', async () => { - const codex = (await import('../src/adapters/codex.js')).default; - codex.apply({ model: 'glm-5-2' }, KEY); - assert.ok(fs.existsSync(cfg)); - codex.restore(); - assert.equal(fs.existsSync(cfg), false); -}); +let home; -test('repeated apply keeps first baseline', async () => { - const codex = (await import('../src/adapters/codex.js')).default; - fs.mkdirSync(cxdir, { recursive: true }); - fs.writeFileSync(cfg, USER_CFG); - codex.apply({ model: 'glm-5-2' }, KEY); - codex.apply({ model: 'kimi-k2-7-code' }, KEY); - codex.restore(); - assert.equal(fs.readFileSync(cfg, 'utf8'), USER_CFG); -}); - -test('hand-edited provider table conflicts, rest restores', async () => { - const codex = (await import('../src/adapters/codex.js')).default; - fs.mkdirSync(cxdir, { recursive: true }); - fs.writeFileSync(cfg, USER_CFG); - codex.apply({ model: 'glm-5-2' }, KEY); - let cur = fs.readFileSync(cfg, 'utf8'); - cur = cur.replace('name = "Kenari"', 'name = "Mine"'); - fs.writeFileSync(cfg, cur); - const r = codex.restore(); - assert.equal(r.conflicts.length, 1); - const out = fs.readFileSync(cfg, 'utf8'); - assert.match(out, /^model = "gpt-5-5"$/m); // top-level restored - assert.ok(out.includes('name = "Mine"')); // table left alone -}); - -test('hand-edited key conflicts, second restore finishes after user resolves', async () => { - const codex = (await import('../src/adapters/codex.js')).default; - fs.mkdirSync(cxdir, { recursive: true }); - fs.writeFileSync(cfg, USER_CFG); - codex.apply({ model: 'glm-5-2' }, KEY); - // user hand-edits an owned top-level key away from what we applied - let cur = fs.readFileSync(cfg, 'utf8'); - cur = cur.replace('model_provider = "kenari"', 'model_provider = "mine"'); - fs.writeFileSync(cfg, cur); - const r = codex.restore(); - assert.equal(r.conflicts.length, 1); - assert.match(r.conflicts[0], /model_provider/); - assert.ok(fs.readFileSync(cfg, 'utf8').includes('model_provider = "mine"')); // left alone - // user resolves by hand: model_provider had no baseline, so removing it matches before=null - cur = fs.readFileSync(cfg, 'utf8'); - cur = cur.replace('model_provider = "mine"\n', ''); - fs.writeFileSync(cfg, cur); - const r2 = codex.restore(); - assert.equal(r2.conflicts.length, 0); - assert.equal(r2.restored, true); -}); - -test('hand-edited table conflicts, second restore finishes after user removes it', async () => { - const codex = (await import('../src/adapters/codex.js')).default; - fs.mkdirSync(cxdir, { recursive: true }); - fs.writeFileSync(cfg, USER_CFG); - codex.apply({ model: 'glm-5-2' }, KEY); - let cur = fs.readFileSync(cfg, 'utf8'); - cur = cur.replace('name = "Kenari"', 'name = "Mine"'); - fs.writeFileSync(cfg, cur); - const r = codex.restore(); - assert.equal(r.conflicts.length, 1); - assert.match(r.conflicts[0], /model_providers\.kenari/); - // user resolves by hand: the table had no baseline, so removing it matches before=null - const codexMod = await import('../src/adapters/codex.js'); - const toml = await import('../src/toml.js'); - cur = toml.deleteTable(fs.readFileSync(cfg, 'utf8'), 'model_providers.kenari'); - fs.writeFileSync(cfg, cur); - const r2 = codexMod.default.restore(); - assert.equal(r2.conflicts.length, 0); - assert.equal(r2.restored, true); -}); - -test('apply refuses a single-quoted (literal) top-level key, leaves file untouched', async () => { - const codex = (await import('../src/adapters/codex.js')).default; - const { KenariError } = await import('../src/store.js'); - fs.mkdirSync(cxdir, { recursive: true }); - const literal = "model = 'gpt-5-5'\napproval_policy = \"never\"\n"; - fs.writeFileSync(cfg, literal); - assert.throws(() => codex.apply({ model: 'glm-5-2' }, KEY), KenariError); - assert.equal(fs.readFileSync(cfg, 'utf8'), literal); -}); - -test('restore without state reports not switched', async () => { - const codex = (await import('../src/adapters/codex.js')).default; - const r = codex.restore(); - assert.equal(r.restored, false); - assert.deepEqual(r.conflicts, []); +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'kenari-codex-detect-')); + process.env.CODEX_HOME = path.join(home, 'codex'); +}); + +test('Codex adapter only detects installation and never edits config', async () => { + const adapter = (await import('../src/adapters/codex.js')).default; + assert.equal(adapter.detect().installed, false); + fs.mkdirSync(process.env.CODEX_HOME, { recursive: true }); + const config = path.join(process.env.CODEX_HOME, 'config.toml'); + fs.writeFileSync(config, 'approval_policy = "never"\n'); + assert.equal(adapter.detect().installed, true); + assert.equal(adapter.detect().configPath, config); + assert.deepEqual(Object.keys(adapter).sort(), ['detect', 'id', 'name']); + assert.equal(fs.readFileSync(config, 'utf8'), 'approval_policy = "never"\n'); }); diff --git a/test/contract.test.js b/test/contract.test.js index 2c17c07..01dbb15 100644 --- a/test/contract.test.js +++ b/test/contract.test.js @@ -1,83 +1,138 @@ -import { test, beforeEach } from 'node:test'; +import { test, after } from 'node:test'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; import http from 'node:http'; +import { startRouter } from '../src/router.js'; -let home; -beforeEach(() => { - home = fs.mkdtempSync(path.join(os.tmpdir(), 'kenari-contract-')); - process.env.KENARI_HOME = path.join(home, 'kh'); - process.env.CLAUDE_CONFIG_DIR = path.join(home, 'claude'); - process.env.CODEX_HOME = path.join(home, 'codex'); -}); +const servers = []; +process.env.KENARI_ALLOW_HTTP = '1'; -const KEY = 'kn-contracttest1234'; +after(() => { + for (const server of servers) server.close(); +}); -function sseGateway(seen) { +function upstream(seen, label) { return new Promise((resolve) => { - const s = http.createServer((req, res) => { - let body = ''; - req.on('data', (c) => { body += c; }); + const server = http.createServer((req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { - seen.push({ url: req.url, auth: req.headers.authorization, body }); - res.setHeader('content-type', 'text/event-stream'); - if (req.url === '/v1/messages') { - res.write('event: message_start\ndata: {"type":"message_start","message":{"id":"m1","usage":{"input_tokens":1}}}\n\n'); - res.write('event: message_stop\ndata: {"type":"message_stop"}\n\n'); - } else { - res.write('data: {"choices":[{"delta":{"tool_calls":[{"id":"t1","function":{"name":"ls","arguments":"{}"}}]}}]}\n\n'); - res.write('data: [DONE]\n\n'); - } + seen.push({ + label, + url: req.url, + authorization: req.headers.authorization, + apiKey: req.headers['x-api-key'], + body: JSON.parse(Buffer.concat(chunks).toString('utf8')), + }); + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.write(`data: {"provider":"${label}","part":1}\n\n`); + res.write(`data: {"provider":"${label}","part":2}\n\n`); res.end(); }); }); - s.unref(); - s.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${s.address().port}`)); + servers.push(server); + server.listen(0, '127.0.0.1', () => { + resolve(`http://127.0.0.1:${server.address().port}`); + }); }); } -test('claude config drives a correct /v1/messages request', async () => { - const seen = []; - process.env.KENARI_BASE_URL = await sseGateway(seen); - const claude = (await import('../src/adapters/claude.js')).default; - claude.apply({ opus: 'glm-5-2', sonnet: 'kimi-k2-7-code', haiku: 'deepseek-v4-flash' }, KEY); - // driver: reconstruct the request the way Claude Code does from settings.json env - const env = JSON.parse(fs.readFileSync(path.join(process.env.CLAUDE_CONFIG_DIR, 'settings.json'), 'utf8')).env; - const res = await fetch(env.ANTHROPIC_BASE_URL + '/v1/messages', { +async function request(router, model, headers = {}, body = {}) { + return fetch(`${router.url}/v1/messages`, { method: 'POST', - headers: { authorization: 'Bearer ' + env.ANTHROPIC_AUTH_TOKEN, 'content-type': 'application/json' }, - body: JSON.stringify({ model: env.ANTHROPIC_DEFAULT_SONNET_MODEL, stream: true, max_tokens: 8, messages: [{ role: 'user', content: 'hi' }] }), + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify({ model, stream: true, max_tokens: 128, ...body }), + }); +} + +test('one router session isolates native and Kenari authentication', async () => { + const seen = []; + const nativeBase = await upstream(seen, 'native'); + const kenariBase = await upstream(seen, 'kenari'); + const router = await startRouter({ + nativeBase, + kenariBase, + credential: 'kn-contracttest1234', + catalog: { + models: [{ + id: 'glm-5-2', + context_limit: 200000, + output_limit: 32000, + compatibility: {}, + }], + }, + }); + try { + const nativeResponse = await request(router, 'claude-sonnet-4-5', { + authorization: 'Bearer native-session-token', + 'x-api-key': 'native-api-key', + }); + assert.equal(nativeResponse.status, 200); + assert.match(await nativeResponse.text(), /"provider":"native"/); + + const kenariResponse = await request(router, 'kenari/glm-5-2', { + authorization: 'Bearer native-session-token', + 'x-api-key': 'native-api-key', + }); + assert.equal(kenariResponse.status, 200); + assert.match(await kenariResponse.text(), /"provider":"kenari"/); + } finally { + await router.close(); + } + + assert.equal(seen[0].label, 'native'); + assert.equal(seen[0].body.model, 'claude-sonnet-4-5'); + assert.equal(seen[0].authorization, 'Bearer native-session-token'); + assert.equal(seen[0].apiKey, 'native-api-key'); + + assert.equal(seen[1].label, 'kenari'); + assert.equal(seen[1].body.model, 'glm-5-2'); + assert.equal(seen[1].authorization, 'Bearer kn-contracttest1234'); + assert.equal(seen[1].apiKey, undefined); +}); + +test('unknown Kenari model fails closed without upstream request', async () => { + const seen = []; + const nativeBase = await upstream(seen, 'native'); + const kenariBase = await upstream(seen, 'kenari'); + const router = await startRouter({ + nativeBase, + kenariBase, + credential: 'kn-contracttest1234', + catalog: { models: [] }, }); - assert.equal(res.status, 200); - const text = await res.text(); - assert.match(text, /message_stop/); - assert.equal(seen[0].url, '/v1/messages'); - assert.equal(seen[0].auth, 'Bearer ' + KEY); - assert.match(seen[0].body, /"model":"kimi-k2-7-code"/); + try { + const response = await request(router, 'kenari/missing'); + assert.equal(response.status, 400); + assert.match(await response.text(), /unknown or unavailable/); + assert.equal(seen.length, 0); + } finally { + await router.close(); + } }); -test('codex config drives a correct chat/completions request with tool call stream', async () => { +test('logged-out Kenari route fails while native route continues', async () => { const seen = []; - process.env.KENARI_BASE_URL = await sseGateway(seen); - const codex = (await import('../src/adapters/codex.js')).default; - codex.apply({ model: 'glm-5-2' }, KEY); - const toml = await import('../src/toml.js'); - const content = fs.readFileSync(path.join(process.env.CODEX_HOME, 'config.toml'), 'utf8'); - const table = toml.getTableText(content, 'model_providers.kenari'); - const baseUrl = table.match(/base_url = "([^"]+)"/)[1]; - const authHeader = table.match(/"Authorization" = "([^"]+)"/)[1]; - const model = toml.getTopLevel(content, 'model'); - const res = await fetch(baseUrl + '/chat/completions', { - method: 'POST', - headers: { Authorization: authHeader, 'content-type': 'application/json' }, - body: JSON.stringify({ model, stream: true, messages: [{ role: 'user', content: 'hi' }], tools: [] }), + const nativeBase = await upstream(seen, 'native'); + const kenariBase = await upstream(seen, 'kenari'); + const router = await startRouter({ + nativeBase, + kenariBase, + credential: null, + catalog: { models: [{ id: 'glm-5-2' }] }, }); - assert.equal(res.status, 200); - const text = await res.text(); - assert.match(text, /tool_calls/); - assert.equal(seen[0].url, '/v1/chat/completions'); - assert.equal(seen[0].auth, 'Bearer ' + KEY); - assert.match(seen[0].body, /"model":"glm-5-2"/); + try { + const denied = await request(router, 'kenari/glm-5-2'); + assert.equal(denied.status, 401); + assert.match(await denied.text(), /kenari login/); + + const native = await request(router, 'gpt-5.4', { + authorization: 'Bearer native-session-token', + }); + assert.equal(native.status, 200); + assert.match(await native.text(), /"provider":"native"/); + } finally { + await router.close(); + } + assert.equal(seen.length, 1); + assert.equal(seen[0].label, 'native'); }); diff --git a/test/gateway.test.js b/test/gateway.test.js index 988a88e..7e02113 100644 --- a/test/gateway.test.js +++ b/test/gateway.test.js @@ -1,8 +1,10 @@ -import { test, after } from 'node:test'; +import { test, before, after } from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; const servers = []; +const originalAllowHttp = process.env.KENARI_ALLOW_HTTP; +before(() => { process.env.KENARI_ALLOW_HTTP = '1'; }); function stub(handler) { return new Promise((resolve) => { const s = http.createServer(handler); @@ -10,7 +12,11 @@ function stub(handler) { s.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${s.address().port}`)); }); } -after(() => servers.forEach((s) => s.close())); +after(() => { + servers.forEach((s) => s.close()); + if (originalAllowHttp === undefined) delete process.env.KENARI_ALLOW_HTTP; + else process.env.KENARI_ALLOW_HTTP = originalAllowHttp; +}); test('fetchModels: happy path maps pricing and context', async () => { const base = await stub((req, res) => { diff --git a/test/store.test.js b/test/store.test.js index 510e1d2..865332f 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -114,11 +114,11 @@ test('withLock: empty lock file backdated 5s is stale', async () => { assert.equal(fs.existsSync(lockDir()), false); }); -test('loadState rejects a newer state schema version', async () => { +test('loadState rejects an unknown newer state schema version', async () => { const { loadState, KenariError } = await import('../src/store.js'); const { statePath } = await import('../src/paths.js'); fs.mkdirSync(path.dirname(statePath()), { recursive: true }); - fs.writeFileSync(statePath(), '{"version":2,"tools":{}}'); + fs.writeFileSync(statePath(), '{"version":3,"tools":{}}'); assert.throws(() => loadState(), KenariError); }); diff --git a/test/v2-config-catalog.test.js b/test/v2-config-catalog.test.js new file mode 100644 index 0000000..0f50720 --- /dev/null +++ b/test/v2-config-catalog.test.js @@ -0,0 +1,131 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import http from 'node:http'; +import { + validateConfig, saveConfig, loadConfig, hasKenariRoutes, +} from '../src/config.js'; +import { + validateCatalogResponse, saveCatalogCache, loadCatalogCache, + catalogIsFresh, loadCatalogForLaunch, +} from '../src/catalog.js'; + +function roles(tool) { + return tool === 'claude' + ? Object.fromEntries(['main', 'opus', 'sonnet', 'haiku', 'fable', 'subagents'] + .map((role) => [role, { mode: 'native' }])) + : { + main: { mode: 'native' }, + review: { mode: 'inherit' }, + subagents: { mode: 'inherit' }, + }; +} + +function tempHome(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kenari-v2-')); + const old = process.env.KENARI_HOME; + process.env.KENARI_HOME = dir; + t.after(() => { + if (old === undefined) delete process.env.KENARI_HOME; + else process.env.KENARI_HOME = old; + fs.rmSync(dir, { recursive: true, force: true }); + }); + return dir; +} + +test('v2 config validates complete roles, persists securely, and detects Kenari routes', (t) => { + const dir = tempHome(t); + const value = { + version: 2, + tools: { + claude: { roles: roles('claude') }, + codex: { roles: { ...roles('codex'), main: { mode: 'picker' } } }, + }, + }; + assert.deepEqual(validateConfig(value), value); + saveConfig(value); + assert.deepEqual(loadConfig(), value); + assert.equal(hasKenariRoutes(value), true); + assert.equal(hasKenariRoutes(value, 'claude'), false); + assert.equal(fs.statSync(dir).mode & 0o777, 0o700); + assert.equal(fs.statSync(path.join(dir, 'config.json')).mode & 0o777, 0o600); +}); + +test('v2 config rejects partial roles, bad mode placement, and unprefixed fixed model', () => { + assert.throws(() => validateConfig({ + version: 2, tools: { claude: { roles: { main: { mode: 'native' } } } }, + }), /missing role/); + const claude = roles('claude'); + claude.main = { mode: 'picker' }; + assert.throws(() => validateConfig({ version: 2, tools: { claude: { roles: claude } } }), /unsupported mode/); + claude.main = { mode: 'fixed', model: 'gpt-5' }; + assert.throws(() => validateConfig({ version: 2, tools: { claude: { roles: claude } } }), /kenari/); +}); + +test('catalog validation and stale fallback preserve cache metadata', async (t) => { + tempHome(t); + const cache = validateCatalogResponse({ + data: [{ + id: 'glm-5', + pricing: { unit: 'micro_idr_per_1m_tokens', input: 2_000_000, output: 3_000_000 }, + context_length: 200000, + output_limit: 128000, + reasoning_efforts: ['medium'], + }], + }, 'https://127.0.0.1.invalid'); + saveCatalogCache(cache); + assert.equal(loadCatalogCache().models[0].output_limit, 128000); + assert.equal(catalogIsFresh(cache, Date.parse(cache.fetched_at) + 100), true); + const result = await loadCatalogForLaunch({ + key: 'kn-test12345', + requireKenari: true, + now: Date.parse(cache.fetched_at) + 100_000_000, + base: 'https://127.0.0.1.invalid', + timeoutMs: 10, + }); + assert.equal(result.cache.models[0].id, 'glm-5'); + assert.match(result.warning, /using cached catalog/); +}); + +test('catalog from another gateway is never reused for a Kenari route', async (t) => { + tempHome(t); + const cache = validateCatalogResponse({ + data: [{ id: 'old-model', context_length: 1000 }], + }, 'https://old.example'); + saveCatalogCache(cache); + await assert.rejects( + loadCatalogForLaunch({ + requireKenari: true, + refresh: false, + base: 'https://new.example', + }), + /catalog unavailable/, + ); +}); + +test('catalog refresh uses bounded authenticated request', async (t) => { + tempHome(t); + const oldAllow = process.env.KENARI_ALLOW_HTTP; + process.env.KENARI_ALLOW_HTTP = '1'; + t.after(() => { + if (oldAllow === undefined) delete process.env.KENARI_ALLOW_HTTP; + else process.env.KENARI_ALLOW_HTTP = oldAllow; + }); + let auth; + const server = http.createServer((req, res) => { + auth = req.headers.authorization; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ data: [{ id: 'gpt-5', context_length: 1000 }] })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => server.close()); + const base = `http://127.0.0.1:${server.address().port}`; + const result = await loadCatalogForLaunch({ + key: 'kn-test12345', requireKenari: true, base, maxAgeMs: 0, + }); + assert.equal(result.refreshed, true); + assert.equal(result.cache.models[0].id, 'gpt-5'); + assert.equal(auth, 'Bearer kn-test12345'); +}); diff --git a/test/v2-migrate.test.js b/test/v2-migrate.test.js new file mode 100644 index 0000000..7a594a2 --- /dev/null +++ b/test/v2-migrate.test.js @@ -0,0 +1,187 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { detectOrphanedV1Signatures, migrateV1 } from '../src/migrate.js'; +import { readJson, writeJson } from '../src/store.js'; +import { configPath, statePath } from '../src/paths.js'; + +function setup(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kenari-migrate-')); + const old = { + KENARI_HOME: process.env.KENARI_HOME, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + CODEX_HOME: process.env.CODEX_HOME, + }; + process.env.KENARI_HOME = path.join(root, 'kenari'); + process.env.CLAUDE_CONFIG_DIR = path.join(root, 'claude'); + process.env.CODEX_HOME = path.join(root, 'codex'); + fs.mkdirSync(process.env.CLAUDE_CONFIG_DIR, { recursive: true }); + fs.mkdirSync(process.env.CODEX_HOME, { recursive: true }); + t.after(() => { + for (const [key, value] of Object.entries(old)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(root, { recursive: true, force: true }); + }); + return root; +} + +test('clean v1 migration restores owned values, removes credentials, converts roles, and backs up', (t) => { + setup(t); + const claudeFile = path.join(process.env.CLAUDE_CONFIG_DIR, 'settings.json'); + const codexFile = path.join(process.env.CODEX_HOME, 'config.toml'); + const table = '[model_providers.kenari]\nbase_url = "https://kenari.id/v1"\nenv_key = "kn-secret"'; + writeJson(claudeFile, { + keep: true, + env: { + KEEP: 'yes', + ANTHROPIC_BASE_URL: 'https://kenari.id', + ANTHROPIC_AUTH_TOKEN: 'kn-secret', + ANTHROPIC_MODEL: 'sonnet-k', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'opus-k', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'sonnet-k', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'haiku-k', + }, + }); + fs.writeFileSync(codexFile, `model = "gpt-k"\nmodel_provider = "kenari"\n\n${table}\n`, { mode: 0o640 }); + writeJson(statePath(), { + version: 1, + tools: { + claude: { + fileCreated: false, containerCreated: false, + keys: { + ANTHROPIC_BASE_URL: { before: null, applied: 'https://kenari.id' }, + ANTHROPIC_AUTH_TOKEN: { before: null, applied: 'kn-secret' }, + ANTHROPIC_MODEL: { before: null, applied: 'sonnet-k' }, + ANTHROPIC_DEFAULT_OPUS_MODEL: { before: 'native-opus', applied: 'opus-k' }, + ANTHROPIC_DEFAULT_SONNET_MODEL: { before: null, applied: 'sonnet-k' }, + ANTHROPIC_DEFAULT_HAIKU_MODEL: { before: null, applied: 'haiku-k' }, + }, + }, + codex: { + fileCreated: false, + keys: { + model: { before: 'native-model', applied: 'gpt-k' }, + model_provider: { before: null, applied: 'kenari' }, + 'table:model_providers.kenari': { before: null, applied: table }, + }, + }, + }, + }); + + const result = migrateV1({ now: Date.UTC(2026, 0, 2, 3, 4, 5) }); + assert.deepEqual(result.migrated, ['claude', 'codex']); + assert.deepEqual(result.conflicts, []); + const claude = readJson(claudeFile); + assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, undefined); + assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, 'native-opus'); + assert.equal(claude.env.KEEP, 'yes'); + const codex = fs.readFileSync(codexFile, 'utf8'); + assert.match(codex, /model = "native-model"/); + assert.doesNotMatch(codex, /kn-secret|model_provider|model_providers\.kenari/); + assert.equal(fs.statSync(codexFile).mode & 0o777, 0o640); + const config = readJson(configPath()); + assert.deepEqual(config.tools.claude.roles.main, { mode: 'fixed', model: 'kenari/sonnet-k' }); + assert.deepEqual(config.tools.codex.roles.main, { mode: 'fixed', model: 'kenari/gpt-k' }); + assert.equal(result.backups.length, 2); + assert.equal(fs.statSync(result.backups[1].path).mode & 0o777, 0o640); + assert.equal(readJson(statePath()).version, 2); +}); + +test('ambiguous hand edit aborts that tool, preserves file, and records exact key without value', (t) => { + setup(t); + const file = path.join(process.env.CLAUDE_CONFIG_DIR, 'settings.json'); + writeJson(file, { env: { ANTHROPIC_AUTH_TOKEN: 'user-edited' } }); + writeJson(statePath(), { + version: 1, + tools: { + claude: { + fileCreated: false, containerCreated: false, + keys: { ANTHROPIC_AUTH_TOKEN: { before: null, applied: 'kn-secret' } }, + }, + }, + }); + const before = fs.readFileSync(file, 'utf8'); + const result = migrateV1({ now: 0 }); + assert.deepEqual(result.migrated, []); + assert.deepEqual(result.conflicts, [{ tool: 'claude', key: 'ANTHROPIC_AUTH_TOKEN' }]); + assert.equal(fs.readFileSync(file, 'utf8'), before); + assert.equal(JSON.stringify(readJson(statePath())).includes('user-edited'), false); + assert.equal(JSON.stringify(readJson(statePath())).includes('kn-secret'), false); +}); + +test('migration conflict remains retryable after manual credential removal', (t) => { + setup(t); + const file = path.join(process.env.CLAUDE_CONFIG_DIR, 'settings.json'); + writeJson(file, { env: { ANTHROPIC_AUTH_TOKEN: 'user-edited' } }); + writeJson(statePath(), { + version: 1, + tools: { + claude: { + fileCreated: false, + containerCreated: false, + keys: { ANTHROPIC_AUTH_TOKEN: { before: null, applied: 'kn-secret' } }, + }, + }, + }); + assert.equal(migrateV1().conflicts.length, 1); + writeJson(file, { env: {} }); + const result = migrateV1(); + assert.deepEqual(result.conflicts, []); + assert.deepEqual(result.migrated, ['claude']); + assert.equal(readJson(statePath()).version, 2); +}); + +test('non-object Claude env and unsupported Codex scalar stay byte-for-byte unchanged', (t) => { + setup(t); + const claudeFile = path.join(process.env.CLAUDE_CONFIG_DIR, 'settings.json'); + const codexFile = path.join(process.env.CODEX_HOME, 'config.toml'); + writeJson(claudeFile, { env: 'owned-by-user' }); + fs.writeFileSync(codexFile, "model_provider = 'mine'\n"); + writeJson(statePath(), { + version: 1, + tools: { + claude: { + fileCreated: false, + containerCreated: false, + keys: { ANTHROPIC_BASE_URL: { before: null, applied: 'https://kenari.id' } }, + }, + codex: { + fileCreated: false, + keys: { model_provider: { before: null, applied: 'kenari' } }, + }, + }, + }); + const claudeBefore = fs.readFileSync(claudeFile); + const codexBefore = fs.readFileSync(codexFile); + const result = migrateV1(); + assert.deepEqual(result.conflicts, [ + { tool: 'claude', key: 'env' }, + { tool: 'codex', key: 'model_provider' }, + ]); + assert.deepEqual(fs.readFileSync(claudeFile), claudeBefore); + assert.deepEqual(fs.readFileSync(codexFile), codexBefore); +}); + +test('orphaned version 1 signatures are reported without values', (t) => { + setup(t); + writeJson(path.join(process.env.CLAUDE_CONFIG_DIR, 'settings.json'), { + env: { + ANTHROPIC_BASE_URL: 'https://kenari.id', + ANTHROPIC_AUTH_TOKEN: 'kn-secret', + }, + }); + fs.writeFileSync( + path.join(process.env.CODEX_HOME, 'config.toml'), + 'model_provider = "kenari"\n\n[model_providers.kenari]\nbase_url = "https://kenari.id/v1"\n', + ); + assert.deepEqual(detectOrphanedV1Signatures(), [ + { tool: 'claude', key: 'ANTHROPIC_BASE_URL' }, + { tool: 'claude', key: 'ANTHROPIC_AUTH_TOKEN' }, + { tool: 'codex', key: 'table:model_providers.kenari' }, + { tool: 'codex', key: 'model_provider' }, + ]); +}); diff --git a/test/v2-router.test.js b/test/v2-router.test.js new file mode 100644 index 0000000..19f4e59 --- /dev/null +++ b/test/v2-router.test.js @@ -0,0 +1,157 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { spawn } from 'node:child_process'; +import { startRouter } from '../src/router.js'; + +const originalAllowHttp = process.env.KENARI_ALLOW_HTTP; +test.before(() => { process.env.KENARI_ALLOW_HTTP = '1'; }); +test.after(() => { + if (originalAllowHttp === undefined) delete process.env.KENARI_ALLOW_HTTP; + else process.env.KENARI_ALLOW_HTTP = originalAllowHttp; +}); + +async function upstream(t, handler) { + const server = http.createServer(handler); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => server.close()); + return `http://127.0.0.1:${server.address().port}`; +} + +function collect(req) { + return new Promise((resolve) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks)))); + }); +} + +function collectRaw(req) { + return new Promise((resolve) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + }); +} + +test('router isolates native and Kenari model, auth, headers, and token rewrites', async (t) => { + const seen = []; + const nativeBase = await upstream(t, async (req, res) => { + const raw = await collectRaw(req); + seen.push({ route: 'native', headers: req.headers, raw, body: JSON.parse(raw) }); + res.writeHead(200, { 'content-type': 'text/event-stream', 'x-route': 'native' }); + res.write('data: one\n\n'); + res.end('data: two\n\n'); + }); + const kenariBase = await upstream(t, async (req, res) => { + seen.push({ route: 'kenari', headers: req.headers, body: await collect(req) }); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); + }); + const router = await startRouter({ + nativeBase, + kenariBase, + credential: 'kn-secret123', + catalog: { models: [{ id: 'gpt-5', output_limit: 128000 }] }, + compatibility: { 'gpt-5': { from: 32000, to: 128000 } }, + }); + t.after(() => router.close()); + + const nativeBody = '{ "model": "gpt-5", "max_tokens": 32000 }\n'; + const native = await fetch(router.url + '/v1/messages', { + method: 'POST', + headers: { authorization: 'Bearer native-secret', 'content-type': 'application/json' }, + body: nativeBody, + }); + assert.equal(native.status, 200); + assert.equal(native.headers.get('x-route'), 'native'); + assert.equal(await native.text(), 'data: one\n\ndata: two\n\n'); + + const kenari = await fetch(router.url + '/v1/messages', { + method: 'POST', + headers: { + authorization: 'Bearer native-secret', + 'x-api-key': 'native-api-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ model: 'kenari/gpt-5', max_tokens: 32000 }), + }); + assert.equal(kenari.status, 200); + assert.deepEqual(await kenari.json(), { ok: true }); + + assert.equal(seen[0].body.model, 'gpt-5'); + assert.equal(seen[0].raw.toString(), nativeBody); + assert.equal(seen[0].body.max_tokens, 32000); + assert.equal(seen[0].headers.authorization, 'Bearer native-secret'); + assert.equal(seen[0].headers['x-api-key'], undefined); + assert.equal(seen[1].body.model, 'gpt-5'); + assert.equal(seen[1].body.max_tokens, 128000); + assert.equal(seen[1].headers.authorization, 'Bearer kn-secret123'); + assert.equal(seen[1].headers['x-api-key'], undefined); + assert.ok(router.childPid > 0); +}); + +test('router fails closed for unknown or logged-out Kenari model', async (t) => { + let requests = 0; + const base = await upstream(t, (_req, res) => { requests += 1; res.end(); }); + const router = await startRouter({ + nativeBase: base, kenariBase: base, credential: null, + catalog: { models: [{ id: 'known' }] }, + }); + t.after(() => router.close()); + const unknown = await fetch(router.url, { + method: 'POST', body: JSON.stringify({ model: 'kenari/missing' }), + }); + assert.equal(unknown.status, 400); + const loggedOut = await fetch(router.url, { + method: 'POST', body: JSON.stringify({ model: 'kenari/known' }), + }); + assert.equal(loggedOut.status, 401); + assert.equal(requests, 0); +}); + +test('router capability rejects callers before reading credentials or forwarding', async (t) => { + let requests = 0; + const base = await upstream(t, (_req, res) => { requests += 1; res.end(); }); + const router = await startRouter({ + nativeBase: base, kenariBase: base, credential: 'kn-secret', + capabilityToken: 'capability', catalog: { models: [] }, + }); + t.after(() => router.close()); + const response = await fetch(router.url, { + method: 'POST', body: JSON.stringify({ model: 'native' }), + }); + assert.equal(response.status, 403); + assert.equal(requests, 0); +}); + +test('router child exits when wrapper parent IPC closes', async () => { + const script = ` + import { startRouter } from './src/router.js'; + const router = await startRouter({ + nativeBase: 'http://127.0.0.1:9', + kenariBase: 'http://127.0.0.1:9', + catalog: { models: [] } + }); + process.stdout.write(String(router.childPid) + '\\n', () => process.exit(0)); + `; + const parent = spawn(process.execPath, ['--input-type=module', '-e', script], { + cwd: process.cwd(), + env: { ...process.env, KENARI_ALLOW_HTTP: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const chunks = []; + const errors = []; + parent.stdout.on('data', (chunk) => chunks.push(chunk)); + parent.stderr.on('data', (chunk) => errors.push(chunk)); + let parentCode; + await new Promise((resolve, reject) => { + parent.once('error', reject); + parent.once('exit', (code) => { parentCode = code; resolve(); }); + }); + const pid = Number(Buffer.concat(chunks).toString().trim()); + assert.equal(parentCode, 0, Buffer.concat(errors).toString()); + assert.ok(pid > 0, Buffer.concat(errors).toString()); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.throws(() => process.kill(pid, 0), /ESRCH/); +}); diff --git a/test/v2-runtime-supervisor.test.js b/test/v2-runtime-supervisor.test.js new file mode 100644 index 0000000..a9fe807 --- /dev/null +++ b/test/v2-runtime-supervisor.test.js @@ -0,0 +1,163 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import http from 'node:http'; +import { buildClaudeLaunch, findClaudeEnvConflicts } from '../src/runtime/claude.js'; +import { buildCodexLaunch, codexKenariModels } from '../src/runtime/codex.js'; +import { resolveBinary, runWrappedTool } from '../src/supervisor.js'; + +const originalAllowHttp = process.env.KENARI_ALLOW_HTTP; +test.before(() => { process.env.KENARI_ALLOW_HTTP = '1'; }); +test.after(() => { + if (originalAllowHttp === undefined) delete process.env.KENARI_ALLOW_HTTP; + else process.env.KENARI_ALLOW_HTTP = originalAllowHttp; +}); + +const claudeRoles = { + main: { mode: 'native' }, + opus: { mode: 'fixed', model: 'kenari/gpt-5' }, + sonnet: { mode: 'native' }, + haiku: { mode: 'native' }, + fable: { mode: 'native' }, + subagents: { mode: 'fixed', model: 'kenari/glm-5' }, +}; + +const claudeEnvByRole = { + main: 'ANTHROPIC_MODEL', + opus: 'ANTHROPIC_DEFAULT_OPUS_MODEL', + sonnet: 'ANTHROPIC_DEFAULT_SONNET_MODEL', + haiku: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + fable: 'ANTHROPIC_DEFAULT_FABLE_MODEL', + subagents: 'CLAUDE_CODE_SUBAGENT_MODEL', +}; + +for (const [role, variable] of Object.entries(claudeEnvByRole)) { + test(`Claude ${role} role maps independently`, () => { + const roles = Object.fromEntries( + Object.keys(claudeEnvByRole).map((id) => [id, { mode: 'native' }]), + ); + roles[role] = { mode: 'fixed', model: `kenari/${role}-model` }; + const built = buildClaudeLaunch({ + toolConfig: { roles }, + routerUrl: 'http://127.0.0.1:1', + env: {}, + }); + assert.equal(built.env[variable], `kenari/${role}-model`); + for (const [otherRole, otherVariable] of Object.entries(claudeEnvByRole)) { + if (otherRole !== role) assert.equal(built.env[otherVariable], undefined); + } + }); +} + +for (const [role, key] of [ + ['main', 'model="kenari/main-model"'], + ['review', 'review_model="kenari/review-model"'], + ['subagents', 'agents.default_subagent_model="kenari/subagents-model"'], +]) { + test(`Codex ${role} role maps independently`, () => { + const roles = { + main: { mode: 'native' }, + review: { mode: 'inherit' }, + subagents: { mode: 'inherit' }, + }; + roles[role] = { mode: 'fixed', model: `kenari/${role}-model` }; + const built = buildCodexLaunch({ + toolConfig: { roles }, + routerUrl: 'http://127.0.0.1:2', + env: {}, + }); + assert.ok(built.args.includes(key)); + }); +} + +test('Claude launch layers only fixed roles and removes credential environment', () => { + const original = { + PATH: '/bin', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'user-sonnet', + ANTHROPIC_API_KEY: 'secret', + }; + assert.deepEqual(findClaudeEnvConflicts(original), ['ANTHROPIC_API_KEY']); + assert.throws(() => buildClaudeLaunch({ + toolConfig: { roles: claudeRoles }, routerUrl: 'http://127.0.0.1:1', env: original, + }), /ambiguous/); + const built = buildClaudeLaunch({ + toolConfig: { roles: claudeRoles }, + routerUrl: 'http://127.0.0.1:1', + routerCapabilityToken: 'capability', + env: original, + allowAmbiguousEnv: true, + args: ['-p', 'hello'], + }); + assert.deepEqual(built.args, ['-p', 'hello']); + assert.equal(built.env.ANTHROPIC_BASE_URL, 'http://127.0.0.1:1'); + assert.equal(built.env.ANTHROPIC_CUSTOM_HEADERS, 'X-Kenari-Capability: capability'); + assert.equal(built.env.ANTHROPIC_API_KEY, undefined); + assert.equal(built.env.ANTHROPIC_DEFAULT_OPUS_MODEL, 'kenari/gpt-5'); + assert.equal(built.env.ANTHROPIC_DEFAULT_SONNET_MODEL, 'user-sonnet'); + assert.equal(built.env.CLAUDE_CODE_SUBAGENT_MODEL, 'kenari/glm-5'); + assert.throws(() => buildClaudeLaunch({ + toolConfig: { roles: claudeRoles }, + routerUrl: 'http://127.0.0.1:1', + env: {}, + args: ['--fallback-model', 'sonnet'], + }), /fallback models are disabled/); +}); + +test('Codex launch injects temporary controls before original args', () => { + const built = buildCodexLaunch({ + toolConfig: { + roles: { + main: { mode: 'picker' }, + review: { mode: 'fixed', model: 'kenari/reviewer' }, + subagents: { mode: 'inherit' }, + }, + }, + routerUrl: 'http://127.0.0.1:2', + catalogPath: '/tmp/catalog.json', + args: ['exec', '-c', 'review_model="native-review"', 'hello'], + env: { OPENAI_API_KEY: 'secret', KEEP: 'yes' }, + }); + assert.deepEqual(built.args.slice(-4), ['exec', '-c', 'review_model="native-review"', 'hello']); + assert.ok(built.args.indexOf('review_model="kenari/reviewer"') < built.args.indexOf('exec')); + assert.ok(built.args.includes('openai_base_url="http://127.0.0.1:2"')); + assert.equal(built.env.OPENAI_API_KEY, 'secret'); + assert.equal(built.env.KEEP, 'yes'); + const native = [{ + slug: 'gpt-native', + supported_reasoning_levels: [{ effort: 'low', description: 'Low' }], + context_window: 100000, + max_context_window: 100000, + }]; + const kenari = codexKenariModels({ models: [{ id: 'gpt-5', context_limit: 200000 }] }, native)[0]; + assert.equal(kenari.slug, 'kenari/gpt-5'); + assert.equal(kenari.context_window, 200000); + assert.throws(() => buildCodexLaunch({ + toolConfig: { roles: {} }, + routerUrl: 'http://127.0.0.1:2', + args: ['-c', 'openai_base_url="https://bypass.example"'], + }), /unsafe Codex routing override/); +}); + +test('resolveBinary skips excluded wrapper and supervisor returns child exit code', async (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kenari-bin-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const first = path.join(dir, 'tool'); + fs.symlinkSync(process.execPath, first); + assert.throws(() => resolveBinary('tool', { env: { PATH: dir }, exclude: [first] }), /not found/); + assert.equal(resolveBinary('tool', { env: { PATH: dir } }), fs.realpathSync(first)); + + const upstream = http.createServer((_req, res) => res.end()); + await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve)); + t.after(() => upstream.close()); + const base = `http://127.0.0.1:${upstream.address().port}`; + const code = await runWrappedTool({ + binary: process.execPath, + args: ['-e', 'process.exit(7)'], + env: process.env, + routerOptions: { nativeBase: base, kenariBase: base, catalog: { models: [] } }, + runtimeBuilder: ({ args, env }) => ({ args, env }), + }); + assert.equal(code, 7); +}); From d312da5d95f2aa16fa2043216acc2d13362b42e6 Mon Sep 17 00:00:00 2001 From: Nur Ad-Duja Date: Mon, 27 Jul 2026 13:45:28 +0700 Subject: [PATCH 2/3] fix(router): sanitize fork exec arguments --- src/router.js | 19 ++++++++++++++++++- test/v2-router.test.js | 15 ++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/router.js b/src/router.js index 2dec3e3..10851a6 100644 --- a/src/router.js +++ b/src/router.js @@ -190,6 +190,23 @@ function childError(message) { return error; } +export function sanitizeForkExecArgv(execArgv = process.execArgv) { + const safe = []; + for (let index = 0; index < execArgv.length; index += 1) { + const arg = execArgv[index]; + if (['-e', '--eval', '-p', '--print'].includes(arg)) { + index += 1; + continue; + } + if (arg.startsWith('--eval=') || arg.startsWith('--print=') + || arg.startsWith('--input-type')) { + continue; + } + safe.push(arg); + } + return safe; +} + export async function startRouter(options) { if (typeof options?.compatibility === 'function') { throw new KenariError('router compatibility rules must be serializable data'); @@ -197,7 +214,7 @@ export async function startRouter(options) { validateGatewayUrl(options.kenariBase); const child = fork(fileURLToPath(import.meta.url), [], { env: { ...process.env, KENARI_ROUTER_CHILD: '1' }, - execArgv: process.execArgv.filter((arg) => !arg.startsWith('--input-type')), + execArgv: sanitizeForkExecArgv(), stdio: ['ignore', 'ignore', 'pipe', 'ipc'], }); let childStderr = ''; diff --git a/test/v2-router.test.js b/test/v2-router.test.js index 19f4e59..a0e7640 100644 --- a/test/v2-router.test.js +++ b/test/v2-router.test.js @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; import { spawn } from 'node:child_process'; -import { startRouter } from '../src/router.js'; +import { sanitizeForkExecArgv, startRouter } from '../src/router.js'; const originalAllowHttp = process.env.KENARI_ALLOW_HTTP; test.before(() => { process.env.KENARI_ALLOW_HTTP = '1'; }); @@ -155,3 +155,16 @@ test('router child exits when wrapper parent IPC closes', async () => { await new Promise((resolve) => setTimeout(resolve, 50)); assert.throws(() => process.kill(pid, 0), /ESRCH/); }); + +test('router child strips eval-only parent arguments', () => { + assert.deepEqual( + sanitizeForkExecArgv([ + '--trace-warnings', + '--input-type=module', + '-e', + 'import "./parent.js"', + '--no-warnings', + ]), + ['--trace-warnings', '--no-warnings'], + ); +}); From 35f6459b898be648f45b46745771b67d9414d47b Mon Sep 17 00:00:00 2001 From: Nur Ad-Duja Date: Mon, 27 Jul 2026 13:47:29 +0700 Subject: [PATCH 3/3] test: gate POSIX mode checks on Windows --- test/v2-config-catalog.test.js | 6 ++++-- test/v2-migrate.test.js | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/test/v2-config-catalog.test.js b/test/v2-config-catalog.test.js index 0f50720..6c5bd19 100644 --- a/test/v2-config-catalog.test.js +++ b/test/v2-config-catalog.test.js @@ -49,8 +49,10 @@ test('v2 config validates complete roles, persists securely, and detects Kenari assert.deepEqual(loadConfig(), value); assert.equal(hasKenariRoutes(value), true); assert.equal(hasKenariRoutes(value, 'claude'), false); - assert.equal(fs.statSync(dir).mode & 0o777, 0o700); - assert.equal(fs.statSync(path.join(dir, 'config.json')).mode & 0o777, 0o600); + if (process.platform !== 'win32') { + assert.equal(fs.statSync(dir).mode & 0o777, 0o700); + assert.equal(fs.statSync(path.join(dir, 'config.json')).mode & 0o777, 0o600); + } }); test('v2 config rejects partial roles, bad mode placement, and unprefixed fixed model', () => { diff --git a/test/v2-migrate.test.js b/test/v2-migrate.test.js index 7a594a2..75478e7 100644 --- a/test/v2-migrate.test.js +++ b/test/v2-migrate.test.js @@ -82,12 +82,16 @@ test('clean v1 migration restores owned values, removes credentials, converts ro const codex = fs.readFileSync(codexFile, 'utf8'); assert.match(codex, /model = "native-model"/); assert.doesNotMatch(codex, /kn-secret|model_provider|model_providers\.kenari/); - assert.equal(fs.statSync(codexFile).mode & 0o777, 0o640); + if (process.platform !== 'win32') { + assert.equal(fs.statSync(codexFile).mode & 0o777, 0o640); + } const config = readJson(configPath()); assert.deepEqual(config.tools.claude.roles.main, { mode: 'fixed', model: 'kenari/sonnet-k' }); assert.deepEqual(config.tools.codex.roles.main, { mode: 'fixed', model: 'kenari/gpt-k' }); assert.equal(result.backups.length, 2); - assert.equal(fs.statSync(result.backups[1].path).mode & 0o777, 0o640); + if (process.platform !== 'win32') { + assert.equal(fs.statSync(result.backups[1].path).mode & 0o777, 0o640); + } assert.equal(readJson(statePath()).version, 2); });