diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b4ec31d1b..2bba532ec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **feat(oauth):** Kiro/Amazon Q auto-import now supports enterprise **External IdP** ("Your organization") logins via Microsoft Entra/Okta/Auth0/OneLogin/Ping/Google/Cognito — these org-issued tokens are not AWS SSO tokens (no `aorAAAAAG`-prefixed refresh token) and can't refresh through the AWS OIDC/Kiro-social path, so `tryAwsSsoCache()` now detects them (`authMethod`/`provider === "externalidp"`) and refreshes via the org IdP's own `tokenEndpoint` (public-client OAuth2 refresh grant, no client secret), persisting `TokenType: EXTERNAL_IDP` gating so the runtime executor sends the header the AWS CodeWhisperer API requires for these accounts; `tokenEndpoint` is SSRF-guarded against an HTTPS + known-IdP-host-suffix allowlist. (#6363 — thanks @artickc) - **Kiro long-lived API key auth**: new `/api/oauth/kiro/api-key` route + `KiroService.validateApiKey` let a Kiro account be linked with a long-lived AWS CodeWhisperer/Kiro API key instead of the interactive OAuth device flow, with live per-account model discovery (`ListAvailableModels`, 5-minute cache) layered over the existing static registry fallback (#6587 — thanks @strangersp) - **Chaos Mode**: multi-model parallel/collaborative task execution — dispatches a task to every active provider connection at once (parallel) or chains outputs sequentially so each model builds on the previous one's answer (collaborative), configurable via Dashboard → Chaos Mode (`GET`/`PUT`/`DELETE /api/chaos/config`) and gated per-API-key via a new `chaosModeEnabled` permission (opt-in — disabled by default globally and per key). `POST /api/chaos/run` (dashboard session) and `POST /api/skills/collect/chaos` (external Bearer-token) delegate to a shared `executeChaosRun()` engine (`src/lib/chaos/chaosExecutor.ts`) that dispatches in-process via the established synthetic-Request/route-handler pattern (no network hop, no hardcoded port), with a concurrency cap (max 10 parallel), configurable `max_tokens` (256–128k), a clear error when `stream` is requested, and collaborative-chain info (provider order + input size). Fixes external Bearer-auth bypass and stale config-cache leakage. Regression guard: `tests/unit/chaos-config.test.ts`, `tests/unit/chaos-executor.test.ts`, `tests/unit/chaos-api-routes.test.ts`. ([#6728](https://github.com/diegosouzapw/OmniRoute/pull/6728) — thanks @Moseyuh333) +- **feat(cli):** 2 new CLI tool integrations on Dashboard → CLI Tools — **omp** (Oh My Pi) and **letta** — each with binary detection, config apply/reset, and a settings card following the existing tool-card pattern. Both settings routes shell out to `which omp`/`which letta` to detect the local install, so they're loopback-gated (`LOCAL_ONLY_API_PREFIXES`, Hard Rules #15/#17) in addition to the shared `requireCliToolsAuth()` management-auth guard every cli-tools route requires, and route errors through `sanitizeErrorMessage()`; `src/lib/db/omp.ts` isolates the `omp` CLI's own local SQLite reads behind parameterized queries. (Note: the original PR also proposed **pi**, **codewhale**, and **jcode** integrations — those three had already shipped via a separate PR by the time this one was reconciled, so only omp+letta landed here.) Regression guard: `tests/unit/db/omp.test.ts`, `tests/unit/cli-tools-auth-hardening.test.ts`, `tests/integration/cli-settings-omp.test.ts`, `tests/integration/cli-settings-letta.test.ts`. ([#6318](https://github.com/diegosouzapw/OmniRoute/pull/6318) — thanks @hamsa0x7) ### 🐛 Bug Fixes diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 229016bdd7..18b169fa72 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -254,11 +254,12 @@ "src/shared/components/OAuthModal.tsx": 993, "src/shared/components/RequestLoggerV2.tsx": 1629, "src/shared/components/analytics/charts.tsx": 1558, - "src/shared/constants/cliTools.ts": 875, + "_rebaseline_2026_07_10_6318_omp_letta": "PR #6318 (@hamsa0x7, omp+letta CLI integrations) own growth: cliTools.ts (+53 = 2 registry entries incl. omp docsUrl) and cliRuntime.ts (+18 = runtime-detection wiring for the 2 new tools). Cohesive registry/wiring growth at the existing chokepoints; scope reduced from the original 5 tools (pi/codewhale/jcode shipped separately).", + "src/shared/constants/cliTools.ts": 916, "src/shared/constants/pricing.ts": 1662, "src/shared/constants/providers.ts": 3276, "src/shared/constants/sidebarVisibility.ts": 1198, - "src/shared/services/cliRuntime.ts": 1110, + "src/shared/services/cliRuntime.ts": 1128, "src/shared/validation/schemas.ts": 2523, "_rebaseline_2026_06_28_5275_correlation_id_extract": "Extraction of the safe CorrelationId subset of #5275 (hartmark) — request correlation id stored in call_logs (migration 109) and returned via the X-Correlation-Id response header, WITHOUT the combo/resilience or build/lazy-loading changes (those stay in #5275). Own growth: callLogs.ts 975->985 (correlation_id column on CallLogSummaryRow + read/map), usageHistory.ts 983->988 (correlationId metadata normalize), chat.ts 1575->1632 (withCorrelationId response wiring + combo-failure log carrying correlationId), chatHelpers.ts new 811 (withCorrelationId helper + reqId threading; was 791786 (single ProviderAccountRoutingCard mount + import), auth.ts 2448->2458 (providerStrategies override resolution: fallbackStrategy/stickyRoundRobinLimit per-provider cascade in getProviderCredentials). Both additive, zero unrelated refactor; new UI/logic lives in new files (ProviderAccountRoutingCard.tsx, RoutingStrategyCard.tsx, rrState.ts::resolveComboStickyRoundRobinLimit). chat.ts value below reflects the current release tip (grown by other concurrent PRs, e.g. #6640), not this PR own change.", diff --git a/public/providers/letta.png b/public/providers/letta.png new file mode 100644 index 0000000000..100759b999 Binary files /dev/null and b/public/providers/letta.png differ diff --git a/public/providers/omp.png b/public/providers/omp.png new file mode 100644 index 0000000000..9da36e296a Binary files /dev/null and b/public/providers/omp.png differ diff --git a/src/app/api/cli-tools/letta-settings/route.ts b/src/app/api/cli-tools/letta-settings/route.ts new file mode 100644 index 0000000000..f10f1a42a7 --- /dev/null +++ b/src/app/api/cli-tools/letta-settings/route.ts @@ -0,0 +1,329 @@ +export const dynamic = "force-dynamic"; + +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { cliAuthOnlyConfigSchema } from "@/shared/validation/schemas/cli"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const execAsync = promisify(exec); + +// ── Paths ────────────────────────────────────────────────────────────── +const getLettaDir = () => path.join(os.homedir(), ".letta"); +const getSettingsPath = () => path.join(getLettaDir(), "settings.json"); +const getLocalBackendDir = () => path.join(getLettaDir(), "lc-local-backend"); +const getProviderAuthPath = () => path.join(getLocalBackendDir(), "providers", "auth.json"); +const getBackupPath = () => + path.join(getLocalBackendDir(), "providers", "auth.json.omniroute-backup"); + +// ── Provider name in auth.json ───────────────────────────────────────── +// "lmstudio" provider type has localModelDiscovery: "openai-compatible" +// which auto-discovers models from /v1/models and shows them in /model picker +// Models appear as "lmstudio/" in the CLI +const PROVIDER_NAME = "lmstudio"; +const PROVIDER_TYPE = "lmstudio_openai"; + +// ── Check if Letta CLI is installed ──────────────────────────────────── +const checkLettaInstalled = async () => { + try { + const isWindows = os.platform() === "win32"; + const command = isWindows ? "where letta" : "which letta"; + const env = isWindows + ? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` } + : process.env; + await execAsync(command, { windowsHide: true, env }); + return true; + } catch { + // Also check if config directory exists (CLI may be installed but not on PATH) + try { + await fs.access(getLettaDir()); + return true; + } catch { + return false; + } + } +}; + +// ── Read settings.json ───────────────────────────────────────────────── +const readSettings = async () => { + try { + const content = await fs.readFile(getSettingsPath(), "utf-8"); + return JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") return {}; + throw error; + } +}; + +// ── Read auth.json ────────────────────────────────────────────────────── +const readAuthFile = async () => { + try { + const content = await fs.readFile(getProviderAuthPath(), "utf-8"); + return JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") return { version: 1, providers: {} }; + throw error; + } +}; + +// ── Check if a base_url points to OmniRoute ────────────────────────────── +const isOmniRouteUrl = (baseUrl) => { + if (!baseUrl) return false; + return baseUrl.includes(":20128") || baseUrl.includes(":3000") || baseUrl.includes("omniroute"); +}; + +// ── Check if OmniRoute is configured ───────────────────────────────────── +const hasOmniRouteConfig = (authFile) => { + if (!authFile?.providers) return false; + const provider = authFile.providers[PROVIDER_NAME]; + if (!provider) return false; + return isOmniRouteUrl(provider.base_url); +}; + +// ── GET - Check Letta CLI and read current settings ──────────────────── +export async function GET(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { + const isInstalled = await checkLettaInstalled(); + + if (!isInstalled) { + return NextResponse.json({ + installed: false, + config: null, + message: "Letta CLI is not installed", + }); + } + + const settings = await readSettings(); + const authFile = await readAuthFile(); + const provider = authFile?.providers?.[PROVIDER_NAME]; + + // Detect if lmstudio is already configured for a non-OmniRoute endpoint + let lmstudioConflict = false; + if (provider && !isOmniRouteUrl(provider.base_url)) { + lmstudioConflict = true; + } + + return NextResponse.json({ + installed: true, + config: authFile, + hasOmniRoute: hasOmniRouteConfig(authFile), + lmstudioConflict, + configPath: getProviderAuthPath(), + letta: { + baseURL: provider?.base_url || null, + }, + backendMode: settings.preferredBackendMode || "api", + }); + } catch (error) { + return NextResponse.json( + { error: { message: sanitizeErrorMessage(error) } }, + { status: 500 } + ); + } +} + +// ── POST - Apply OmniRoute as LM Studio provider + switch to local mode ── +/** + * Steps 1-2 of POST: read the existing Letta auth.json, refuse to clobber a real + * LM Studio configuration unless `overwrite` is set (409 with conflict info), and back + * up a non-OmniRoute provider before it is overwritten. Extracted to keep POST under + * the complexity gate. + */ +async function prepareLettaAuthFile( + overwrite: boolean | undefined +): Promise< + | { conflictResponse: NextResponse } + | { authFile: { version: number; providers: Record }; authPath: string } +> { + const localBackendDir = getLocalBackendDir(); + const authPath = getProviderAuthPath(); + await fs.mkdir(path.join(localBackendDir, "providers"), { recursive: true }); + + let authFile = { version: 1, providers: {} as Record }; + try { + const existing = await fs.readFile(authPath, "utf-8"); + authFile = JSON.parse(existing); + } catch { + /* No existing file */ + } + + const existingProvider = authFile.providers?.[PROVIDER_NAME]; + if (existingProvider && !isOmniRouteUrl(existingProvider.base_url) && !overwrite) { + // User has lmstudio configured for actual LM Studio — refuse to overwrite + return { + conflictResponse: NextResponse.json( + { + error: `lmstudio provider is already configured for ${existingProvider.base_url}. Overwriting will break your existing LM Studio connection. Apply again to overwrite.`, + conflict: true, + existingBaseUrl: existingProvider.base_url, + }, + { status: 409 } + ), + }; + } + + // Back up existing lmstudio provider before overwriting + if (existingProvider && !isOmniRouteUrl(existingProvider.base_url)) { + const backupPath = getBackupPath(); + await fs.writeFile(backupPath, JSON.stringify(existingProvider, null, 2)); + } + + return { authFile, authPath }; +} + +export async function POST(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); + } + + try { + const validation = validateBody(cliAuthOnlyConfigSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const { baseUrl, apiKey, overwrite } = validation.data; + + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + + // ── 1-2. Read auth.json, guard non-OmniRoute conflicts, back up before overwrite ── + const prepared = await prepareLettaAuthFile(overwrite); + if ("conflictResponse" in prepared) { + return prepared.conflictResponse; + } + const { authFile, authPath } = prepared; + + // ── 3. Switch to local mode in settings.json ── + const settingsPath = getSettingsPath(); + const lettaDir = getLettaDir(); + await fs.mkdir(lettaDir, { recursive: true }); + + let settings = {}; + try { + const existing = await fs.readFile(settingsPath, "utf-8"); + settings = JSON.parse(existing); + } catch { + /* No existing settings */ + } + + settings.preferredBackendMode = "local"; + await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); + + // ── 4. Write lmstudio provider to auth.json ── + // Clean up legacy lc-omniroute provider if present + if (authFile.providers?.["lc-omniroute"]) { + delete authFile.providers["lc-omniroute"]; + } + + // Create or update lmstudio provider + authFile.providers[PROVIDER_NAME] = { + id: `local-provider-${PROVIDER_NAME}`, + name: PROVIDER_NAME, + provider_type: PROVIDER_TYPE, + provider_category: "byok", + auth: { type: "api", key: apiKey }, + base_url: normalizedBaseUrl, + created_at: authFile.providers[PROVIDER_NAME]?.created_at || new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + await fs.writeFile(authPath, JSON.stringify(authFile, null, 2)); + + return NextResponse.json({ + success: true, + message: "Settings applied. Restart Letta CLI, then use /model to select a OmniRoute model.", + needsRestart: true, + }); + } catch (error) { + return NextResponse.json( + { error: { message: sanitizeErrorMessage(error) } }, + { status: 500 } + ); + } +} + +// ── DELETE - Remove OmniRoute configuration ────────────────────────────── +export async function DELETE(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { + // ── 1. Remove lmstudio provider from auth.json, restore backup if exists ── + const authPath = getProviderAuthPath(); + const backupPath = getBackupPath(); + let authFile = { version: 1, providers: {} }; + try { + const existing = await fs.readFile(authPath, "utf-8"); + authFile = JSON.parse(existing); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + + let changed = false; + let restored = false; + + if (authFile.providers?.[PROVIDER_NAME]) { + // Check if there's a backup of a pre-existing lmstudio config + try { + const backupContent = await fs.readFile(backupPath, "utf-8"); + const backupProvider = JSON.parse(backupContent); + // Restore the original lmstudio config + authFile.providers[PROVIDER_NAME] = backupProvider; + restored = true; + await fs.unlink(backupPath); + } catch { + // No backup — just remove the provider + delete authFile.providers[PROVIDER_NAME]; + } + changed = true; + } + + // Clean up legacy lc-omniroute provider if present + if (authFile.providers?.["lc-omniroute"]) { + delete authFile.providers["lc-omniroute"]; + changed = true; + } + + if (changed) { + await fs.writeFile(authPath, JSON.stringify(authFile, null, 2)); + } + + // ── 2. Reset backend mode to api in settings.json ── + const settingsPath = getSettingsPath(); + try { + const existing = await fs.readFile(settingsPath, "utf-8"); + const settings = JSON.parse(existing); + if (settings.preferredBackendMode === "local") { + settings.preferredBackendMode = "api"; + await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); + } + } catch { + /* No settings file */ + } + + const message = restored + ? "OmniRoute config removed. Your original LM Studio provider has been restored. Restart Letta CLI to take effect." + : "OmniRoute config removed. Restart Letta CLI to take effect."; + + return NextResponse.json({ + success: true, + message, + needsRestart: true, + }); + } catch (error) { + return NextResponse.json( + { error: { message: sanitizeErrorMessage(error) } }, + { status: 500 } + ); + } +} diff --git a/src/app/api/cli-tools/omp-settings/route.ts b/src/app/api/cli-tools/omp-settings/route.ts new file mode 100644 index 0000000000..dc1e840c6c --- /dev/null +++ b/src/app/api/cli-tools/omp-settings/route.ts @@ -0,0 +1,180 @@ +export const dynamic = "force-dynamic"; + +import { NextResponse } from "next/server"; +import { exec } from "child_process"; +import { promisify } from "util"; +import path from "path"; +import os from "os"; +import fs from "fs/promises"; +import { load as yamlLoad, dump as yamlDump } from "js-yaml"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { cliAuthOnlyConfigSchema } from "@/shared/validation/schemas/cli"; +import { getOmpCredentials, saveOmpCredentials, deleteOmpCredentials } from "@/lib/db/omp"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const execAsync = promisify(exec); + +const PROVIDER_ID = "omniroute"; + +const getOmpDir = () => path.join(os.homedir(), ".omp", "agent"); +const getOmpDbPath = () => path.join(getOmpDir(), "agent.db"); +const getOmpModelsYmlPath = () => path.join(getOmpDir(), "models.yml"); + +const checkOmpInstalled = async () => { + const isWindows = os.platform() === "win32"; + try { + const command = isWindows ? "where omp" : "which omp"; + await execAsync(command, { windowsHide: true }); + return true; + } catch { + try { + await fs.access(getOmpDbPath()); + return true; + } catch { + if (isWindows) { + try { + const appDataPath = path.join(process.env.LOCALAPPDATA || "", "omp", "omp.exe"); + await fs.access(appDataPath); + return true; + } catch {} + } + return false; + } + } +}; + +const readModelsYml = async () => { + try { + const content = await fs.readFile(getOmpModelsYmlPath(), "utf-8"); + return yamlLoad(content) || {}; + } catch { + return {}; + } +}; + +export async function GET(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { + const installed = await checkOmpInstalled(); + + if (!installed) { + return NextResponse.json({ + installed: false, + config: null, + message: "Oh My Pi is not installed", + }); + } + + const creds = getOmpCredentials(PROVIDER_ID); + const modelsYml = await readModelsYml(); + const ymlProvider = modelsYml?.providers?.[PROVIDER_ID]; + + return NextResponse.json({ + installed: true, + config: { + providers: { + [PROVIDER_ID]: { + baseUrl: ymlProvider?.baseUrl || creds.baseUrl, + apiKey: ymlProvider?.apiKey || creds.apiKey, + discovery: ymlProvider?.discovery?.type || null, + }, + }, + }, + hasOmniRoute: !!(ymlProvider || creds.hasOmniRoute), + configPath: getOmpModelsYmlPath(), + }); + } catch (error) { + return NextResponse.json( + { error: { message: sanitizeErrorMessage(error) } }, + { status: 500 } + ); + } +} + +export async function POST(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); + } + + try { + const validation = validateBody(cliAuthOnlyConfigSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const { baseUrl, apiKey } = validation.data; + + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + const keyRef = apiKey || "sk_omniroute"; + + await fs.mkdir(getOmpDir(), { recursive: true }); + + // 1. Write models.yml — provider config + auto-discovery + const modelsYml = await readModelsYml(); + if (!modelsYml.providers) modelsYml.providers = {}; + + modelsYml.providers[PROVIDER_ID] = { + baseUrl: normalizedBaseUrl, + apiKey: keyRef, + api: "openai-completions", + authHeader: true, + disableStrictTools: true, + discovery: { type: "proxy" }, + }; + + await fs.writeFile(getOmpModelsYmlPath(), yamlDump(modelsYml, { lineWidth: -1 }), "utf-8"); + + // 2. Write auth_credentials — so omp sees omniroute as "logged in" + saveOmpCredentials(PROVIDER_ID, keyRef, normalizedBaseUrl); + + return NextResponse.json({ + success: true, + message: + "Oh My Pi settings applied! Run omp and all OmniRoute models appear under omniroute in /model.", + configPath: getOmpModelsYmlPath(), + }); + } catch (error) { + return NextResponse.json( + { error: { message: sanitizeErrorMessage(error) } }, + { status: 500 } + ); + } +} + +export async function DELETE(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { + // 1. Remove from models.yml + const modelsYml = await readModelsYml(); + if (modelsYml?.providers?.[PROVIDER_ID]) { + delete modelsYml.providers[PROVIDER_ID]; + if (Object.keys(modelsYml.providers).length === 0) delete modelsYml.providers; + await fs.mkdir(getOmpDir(), { recursive: true }); + if (Object.keys(modelsYml).length === 0) { + await fs.unlink(getOmpModelsYmlPath()).catch(() => {}); + } else { + await fs.writeFile(getOmpModelsYmlPath(), yamlDump(modelsYml, { lineWidth: -1 }), "utf-8"); + } + } + + // 2. Remove from auth_credentials + deleteOmpCredentials(PROVIDER_ID); + + return NextResponse.json({ + success: true, + message: "OmniRoute removed from Oh My Pi", + }); + } catch (error) { + return NextResponse.json( + { error: { message: sanitizeErrorMessage(error) } }, + { status: 500 } + ); + } +} diff --git a/src/lib/db/omp.ts b/src/lib/db/omp.ts new file mode 100644 index 0000000000..08f9b05aa8 --- /dev/null +++ b/src/lib/db/omp.ts @@ -0,0 +1,52 @@ +import os from "os"; +import path from "path"; +import Database from "better-sqlite3"; + +const getOmpDir = () => path.join(os.homedir(), ".omp", "agent"); +const getOmpDbPath = () => path.join(getOmpDir(), "agent.db"); + +export function getOmpCredentials(providerId: string) { + const dbPath = getOmpDbPath(); + try { + const db = new Database(dbPath, { readonly: true }); + const row = db + .prepare( + "SELECT data FROM auth_credentials WHERE provider = ? AND credential_type = 'api_key'" + ) + .get(providerId) as { data: string } | undefined; + db.close(); + + if (row?.data) { + const parsed = JSON.parse(row.data); + return { hasOmniRoute: true, baseUrl: parsed.baseUrl || null, apiKey: parsed.apiKey || null }; + } + return { hasOmniRoute: false, baseUrl: null, apiKey: null }; + } catch { + return { hasOmniRoute: false, baseUrl: null, apiKey: null }; + } +} + +export function saveOmpCredentials(providerId: string, apiKey: string, baseUrl: string) { + const dbPath = getOmpDbPath(); + const db = new Database(dbPath); + + db.prepare("DELETE FROM auth_credentials WHERE provider = ?").run(providerId); + db.prepare( + "INSERT INTO auth_credentials (provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) VALUES (?, ?, ?, NULL, NULL, ?, ?)" + ).run( + providerId, + "api_key", + JSON.stringify({ apiKey, baseUrl }), + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000) + ); + + db.close(); +} + +export function deleteOmpCredentials(providerId: string) { + const dbPath = getOmpDbPath(); + const db = new Database(dbPath); + db.prepare("DELETE FROM auth_credentials WHERE provider = ?").run(providerId); + db.close(); +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index ddadd4666b..9301182fbb 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -589,15 +589,15 @@ export { markAllMemoriesNeedReindex, getMemoryReindexQueue, countMemoryReindexPending, + type MemoryVecMeta, } from "./db/memoryVec"; - -export type { MemoryVecMeta } from "./db/memoryVec"; // T-A-F2: AgentBridge state/mappings/bypass + Inspector custom hosts/sessions export * from "./db/agentBridgeState"; export * from "./db/agentBridgeMappings"; export * from "./db/agentBridgeBypass"; export * from "./db/inspectorCustomHosts"; export * from "./db/inspectorSessions"; +export * from "./db/omp"; // Quota Sharing — Group B (planos 16+22) export { listPools, diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index f044516ee5..d9479e1e89 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -29,6 +29,8 @@ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/mcp/", "/api/cli-tools/runtime/", + "/api/cli-tools/omp-settings", // spawns `which omp` to detect the CLI install (Hard Rules #15 + #17, #6318) + "/api/cli-tools/letta-settings", // spawns `which letta` to detect the CLI install (Hard Rules #15 + #17, #6318) "/api/services/", // T-10: embedded service lifecycle (spawn child processes) "/dashboard/providers/services/", // T-07: reverse proxy to embedded service UIs "/api/copilot/", // unauthenticated LLM driver — CLI-only by default; admins can opt-in to remote access via manage-scope bypass diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index 70245a675b..178e6ae156 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -796,6 +796,59 @@ OPENAI_API_KEY: "{{apiKey}}"`, }, }, + omp: { + id: "omp", + name: "Oh My Pi", + image: "/providers/omp.png", + color: "#111111", + docsUrl: "https://github.com/can1357/oh-my-pi", + description: "Oh My Pi terminal coding agent via OmniRoute", + configType: "custom", + category: "agent", + vendor: "OSS", + acpSpawnable: true, + baseUrlSupport: "full", + defaultCommand: "omp", + notes: [ + { + type: "info", + text: "Oh My Pi reads custom OpenAI-compatible providers from ~/.omp/agent/models.yml. OmniRoute adds itself as a provider with auto-discovery — models appear automatically in omp's /model menu.", + }, + { + type: "warning", + text: "Config path: Linux/macOS ~/.omp/agent/models.yml • Windows %USERPROFILE%\\.omp\\.omp\\agent\\models.yml", + }, + ], + }, + + letta: { + id: "letta", + name: "Letta CLI", + image: "/providers/letta.png", + color: "#FF6B35", + description: "Letta CLI — AI agent with persistent memory and tool use", + configType: "custom", + category: "agent", + vendor: "Letta", + acpSpawnable: false, + baseUrlSupport: "full", + docsUrl: "https://docs.letta.com", + notes: [ + { + type: "info", + text: "Letta CLI uses pi-ai which sends OpenAI-compatible requests. OmniRoute configures it as an OpenAI provider with custom base URL.", + }, + { + type: "info", + text: "CLI (Local Mode): OmniRoute auto-configures ~/.letta/lc-local-backend/providers/auth.json. Use 'letta --info' to check if local mode is enabled.", + }, + { + type: "warning", + text: "Local mode config path: ~/.letta/lc-local-backend/providers/auth.json (CLI only)", + }, + ], + }, + /** ★ Added by plan 14 (CLI Pages Redesign) — 2026-05-27 */ warp: { id: "warp", diff --git a/src/shared/schemas/cliCatalog.ts b/src/shared/schemas/cliCatalog.ts index 22ef529ec5..d5b43a55bc 100644 --- a/src/shared/schemas/cliCatalog.ts +++ b/src/shared/schemas/cliCatalog.ts @@ -63,4 +63,7 @@ export const CliCatalogSchema = z.record(CliCatalogEntrySchema); // +1 (2026-07-02): "codewhale" added as a dual entry alongside "deepseek-tui" // (CodeWhale is the actively-maintained successor to DeepSeek TUI). export const EXPECTED_CODE_COUNT = 20; -export const EXPECTED_AGENT_COUNT = 6; +// +2 (#6318): "omp" (Oh My Pi) and "letta" (Letta CLI) added as agent entries. +// Note: #6318 originally also shipped duplicate "pi"/"jcode"/"codewhale" entries — +// those tools were already delivered by a separate PR, so only omp+letta landed here. +export const EXPECTED_AGENT_COUNT = 8; diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index e7088f73ea..5ccfd27221 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -210,6 +210,24 @@ const CLI_TOOLS: Record = { config: ".config/deepseek-tui/config.toml", }, }, + omp: { + defaultCommand: "omp", + envBinKey: "CLI_OMP_BIN", + requiresBinary: true, + healthcheckTimeoutMs: 8000, + paths: { + config: ".omp/agent/models.yml", + }, + }, + letta: { + defaultCommand: "letta", + envBinKey: "CLI_LETTA_BIN", + requiresBinary: true, + healthcheckTimeoutMs: 8000, + paths: { + config: ".letta/lc-local-backend/providers/auth.json", + }, + }, codewhale: { defaultCommand: "codewhale", envBinKey: "CLI_CODEWHALE_BIN", diff --git a/src/shared/validation/schemas/cli.ts b/src/shared/validation/schemas/cli.ts index b3e6a223e8..6c8f09113e 100644 --- a/src/shared/validation/schemas/cli.ts +++ b/src/shared/validation/schemas/cli.ts @@ -14,7 +14,6 @@ import { } from "@/shared/constants/upstreamHeaders"; import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts"; - export const cliMitmStartSchema = z.object({ apiKey: z.string().trim().min(1).nullable().optional(), keyId: z.string().trim().min(1).nullable().optional(), @@ -83,4 +82,10 @@ export const cliModelConfigSchema = z.object({ export const cliMultiModelConfigSchema = cliModelConfigSchema.extend({ models: z.array(z.string().trim().min(1)).optional(), activeModel: z.string().optional(), -}); \ No newline at end of file +}); + +export const cliAuthOnlyConfigSchema = z.object({ + baseUrl: z.string().trim().min(1, "baseUrl is required"), + apiKey: z.string().nullable().optional(), + overwrite: z.boolean().optional(), +}); diff --git a/tests/integration/cli-settings-letta.test.ts b/tests/integration/cli-settings-letta.test.ts new file mode 100644 index 0000000000..881e8a32bf --- /dev/null +++ b/tests/integration/cli-settings-letta.test.ts @@ -0,0 +1,196 @@ +/** + * Integration tests for /api/cli-tools/letta-settings + * + * Letta configures OmniRoute as its "lmstudio" provider (localModelDiscovery: + * openai-compatible auto-discovers models from /v1/models). The route shells + * out to `which letta` to detect the CLI install, so it is classified + * local-only in routeGuard.ts (Hard Rules #15 + #17) AND guarded by + * requireCliToolsAuth() like every other cli-tools route + * (tests/unit/cli-tools-auth-hardening.test.ts). + */ +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"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-letta-settings-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-api-key-secret-letta"; +process.env.JWT_SECRET = "test-jwt-secret-letta"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); + +const { GET, POST, DELETE } = await import( + "../../src/app/api/cli-tools/letta-settings/route.ts" +); + +let tmpHome: string; +let origHome: string | undefined; + +function getAuthPath() { + return path.join(tmpHome, ".letta", "lc-local-backend", "providers", "auth.json"); +} + +function req(init?: RequestInit) { + return new Request("http://localhost/api/cli-tools/letta-settings", init); +} + +async function resetStorage() { + delete process.env.INITIAL_PASSWORD; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function enableAuth() { + process.env.INITIAL_PASSWORD = "test-bootstrap"; + await localDb.updateSettings({ requireLogin: true, password: "" }); +} + +test.beforeEach(async () => { + await resetStorage(); + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "letta-settings-home-")); + origHome = process.env.HOME; + process.env.HOME = tmpHome; +}); + +test.afterEach(() => { + process.env.HOME = origHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); +}); + +// ── Test 1: GET without auth → 401 ────────────────────────────────────────── + +test("letta-settings GET: returns 401 when auth required and no token", async () => { + await enableAuth(); + const res = await GET(req()); + assert.equal(res.status, 401, `Expected 401, got ${res.status}`); +}); + +// ── Test 2: GET → 200 installed:false when the letta CLI is absent ────────── + +test("letta-settings GET: returns 200 installed:false when Letta CLI is absent", async () => { + const res = await GET(req()); + assert.equal(res.status, 200, `Expected 200, got ${res.status}`); + const body = await res.json(); + assert.equal(body.installed, false); + assert.equal(body.config, null); +}); + +// ── Test 3: GET → detects "installed" via an existing ~/.letta dir ────────── + +test("letta-settings GET: treats an existing ~/.letta directory as installed", async () => { + fs.mkdirSync(path.join(tmpHome, ".letta"), { recursive: true }); + const res = await GET(req()); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.installed, true); + assert.equal(body.hasOmniRoute, false); +}); + +// ── Test 4: POST with invalid body → 400 ───────────────────────────────────── + +test("letta-settings POST: 400 when baseUrl is missing", async () => { + const res = await POST( + req({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ apiKey: "sk-test" }), + }) + ); + assert.equal(res.status, 400, `Expected 400, got ${res.status}`); + const body = await res.json(); + assert.ok(body.error !== undefined); +}); + +// ── Test 5: POST with valid body → writes the lmstudio provider to auth.json ─ + +test("letta-settings POST: writes the lmstudio provider entry for a fresh install", async () => { + const res = await POST( + req({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl: "http://localhost:20128", apiKey: "sk-test-letta" }), + }) + ); + assert.equal(res.status, 200, `Expected 200, got ${res.status}`); + const body = await res.json(); + assert.equal(body.success, true); + + const authPath = getAuthPath(); + assert.ok(fs.existsSync(authPath), "auth.json must be written"); + const authFile = JSON.parse(fs.readFileSync(authPath, "utf-8")); + assert.equal(authFile.providers.lmstudio.base_url, "http://localhost:20128/v1"); + assert.equal(authFile.providers.lmstudio.auth.key, "sk-test-letta"); +}); + +// ── Test 6: POST refuses to overwrite an existing non-OmniRoute lmstudio config ── + +test("letta-settings POST: 409 when lmstudio is already configured for real LM Studio", async () => { + const providersDir = path.join(tmpHome, ".letta", "lc-local-backend", "providers"); + fs.mkdirSync(providersDir, { recursive: true }); + fs.writeFileSync( + path.join(providersDir, "auth.json"), + JSON.stringify({ + version: 1, + providers: { lmstudio: { base_url: "http://localhost:1234/v1" } }, + }) + ); + + const res = await POST( + req({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl: "http://localhost:20128", apiKey: "sk-test-letta" }), + }) + ); + assert.equal(res.status, 409, `Expected 409, got ${res.status}`); + const body = await res.json(); + assert.equal(body.conflict, true); +}); + +// ── Test 7: DELETE → removes the OmniRoute lmstudio config ────────────────── + +test("letta-settings DELETE: removes the lmstudio provider written by POST", async () => { + await POST( + req({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl: "http://localhost:20128", apiKey: "sk-test-letta" }), + }) + ); + + const res = await DELETE(req({ method: "DELETE" })); + assert.equal(res.status, 200, `Expected 200, got ${res.status}`); + const body = await res.json(); + assert.equal(body.success, true); + + const authFile = JSON.parse(fs.readFileSync(getAuthPath(), "utf-8")); + assert.ok(!authFile.providers.lmstudio, "lmstudio provider must be removed"); +}); + +// ── Test 8: Error sanitization (Hard Rule #12) ─────────────────────────────── + +test("letta-settings: error responses do not leak stack traces", async () => { + const badReq = req({ + method: "POST", + headers: { "content-type": "application/json" }, + body: "{ bad json }", + }); + const res = await POST(badReq); + const bodyStr = JSON.stringify(await res.json()); + assert.ok( + !bodyStr.match(/\s+at\s+\/[^\s]/), + "Error response must not contain absolute-path stack traces" + ); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + delete process.env.DATA_DIR; + delete process.env.API_KEY_SECRET; + delete process.env.JWT_SECRET; +}); diff --git a/tests/integration/cli-settings-omp.test.ts b/tests/integration/cli-settings-omp.test.ts new file mode 100644 index 0000000000..54ae67f3a1 --- /dev/null +++ b/tests/integration/cli-settings-omp.test.ts @@ -0,0 +1,197 @@ +/** + * Integration tests for /api/cli-tools/omp-settings + * + * Oh My Pi (omp) reads its own local sqlite DB (~/.omp/agent/agent.db, + * created by the omp CLI itself) via src/lib/db/omp.ts, plus a + * ~/.omp/agent/models.yml file for provider/model discovery config. The route + * shells out to `which omp` to detect the CLI install, so it is classified + * local-only in routeGuard.ts (Hard Rules #15 + #17) AND guarded by + * requireCliToolsAuth() like every other cli-tools route + * (tests/unit/cli-tools-auth-hardening.test.ts). + */ +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 Database from "better-sqlite3"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-omp-settings-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-api-key-secret-omp"; +process.env.JWT_SECRET = "test-jwt-secret-omp"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); + +const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/omp-settings/route.ts"); + +let tmpHome: string; +let origHome: string | undefined; + +function getOmpDir() { + return path.join(tmpHome, ".omp", "agent"); +} + +function req(init?: RequestInit) { + return new Request("http://localhost/api/cli-tools/omp-settings", init); +} + +/** Simulate the omp CLI having already created its sqlite DB + schema. */ +function seedOmpDb() { + const dbPath = path.join(getOmpDir(), "agent.db"); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE IF NOT EXISTS auth_credentials ( + provider TEXT NOT NULL, + credential_type TEXT NOT NULL, + data TEXT, + disabled_cause TEXT, + identity_key TEXT, + created_at INTEGER, + updated_at INTEGER + ) + `); + db.close(); +} + +async function resetStorage() { + delete process.env.INITIAL_PASSWORD; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function enableAuth() { + process.env.INITIAL_PASSWORD = "test-bootstrap"; + await localDb.updateSettings({ requireLogin: true, password: "" }); +} + +test.beforeEach(async () => { + await resetStorage(); + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omp-settings-home-")); + origHome = process.env.HOME; + process.env.HOME = tmpHome; +}); + +test.afterEach(() => { + process.env.HOME = origHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); +}); + +// ── Test 1: GET without auth → 401 ────────────────────────────────────────── + +test("omp-settings GET: returns 401 when auth required and no token", async () => { + await enableAuth(); + const res = await GET(req()); + assert.equal(res.status, 401, `Expected 401, got ${res.status}`); +}); + +// ── Test 2: GET → 200 with installed:false when omp is not present ────────── + +test("omp-settings GET: returns 200 installed:false when omp CLI and DB are both absent", async () => { + const res = await GET(req()); + assert.equal(res.status, 200, `Expected 200, got ${res.status}`); + const body = await res.json(); + assert.equal(body.installed, false); + assert.equal(body.config, null); +}); + +// ── Test 3: GET → detects "installed" via the DB file even without the binary on PATH ── + +test("omp-settings GET: treats an existing agent.db as installed", async () => { + seedOmpDb(); + const res = await GET(req()); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.installed, true); + assert.equal(body.hasOmniRoute, false); +}); + +// ── Test 4: POST with invalid body → 400 ───────────────────────────────────── + +test("omp-settings POST: 400 when baseUrl is missing", async () => { + const res = await POST( + req({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ apiKey: "sk-test" }), + }) + ); + assert.equal(res.status, 400, `Expected 400, got ${res.status}`); + const body = await res.json(); + assert.ok(body.error !== undefined); +}); + +// ── Test 5: POST with valid body → writes models.yml + persists credentials ── + +test("omp-settings POST: writes models.yml and persists credentials for a seeded DB", async () => { + seedOmpDb(); + + const res = await POST( + req({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl: "http://localhost:20128", apiKey: "sk-test-omp" }), + }) + ); + assert.equal(res.status, 200, `Expected 200, got ${res.status}`); + const body = await res.json(); + assert.equal(body.success, true); + + const modelsYmlPath = path.join(getOmpDir(), "models.yml"); + assert.ok(fs.existsSync(modelsYmlPath), "models.yml must be written"); + const content = fs.readFileSync(modelsYmlPath, "utf-8"); + assert.ok(content.includes("http://localhost:20128/v1"), "models.yml must contain the base URL"); + + const getRes = await GET(req()); + const getBody = await getRes.json(); + assert.equal(getBody.hasOmniRoute, true); +}); + +// ── Test 6: DELETE → removes OmniRoute provider entry ──────────────────────── + +test("omp-settings DELETE: removes the OmniRoute provider from models.yml and credentials", async () => { + seedOmpDb(); + await POST( + req({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl: "http://localhost:20128", apiKey: "sk-test-omp" }), + }) + ); + + const res = await DELETE(req({ method: "DELETE" })); + assert.equal(res.status, 200, `Expected 200, got ${res.status}`); + const body = await res.json(); + assert.equal(body.success, true); + + const getRes = await GET(req()); + const getBody = await getRes.json(); + assert.equal(getBody.hasOmniRoute, false); +}); + +// ── Test 7: Error sanitization (Hard Rule #12) ─────────────────────────────── + +test("omp-settings: error responses do not leak stack traces", async () => { + const badReq = req({ + method: "POST", + headers: { "content-type": "application/json" }, + body: "{ bad json }", + }); + const res = await POST(badReq); + const bodyStr = JSON.stringify(await res.json()); + assert.ok( + !bodyStr.match(/\s+at\s+\/[^\s]/), + "Error response must not contain absolute-path stack traces" + ); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + delete process.env.DATA_DIR; + delete process.env.API_KEY_SECRET; + delete process.env.JWT_SECRET; +}); diff --git a/tests/unit/cli-catalog-counts.test.ts b/tests/unit/cli-catalog-counts.test.ts index e65516f228..681993eccb 100644 --- a/tests/unit/cli-catalog-counts.test.ts +++ b/tests/unit/cli-catalog-counts.test.ts @@ -41,8 +41,8 @@ test("CLI_TOOLS total code entries (including none) equals 24 (20 visible + 4 no assert.equal(codeAll.length, 24, `Expected 24 total code entries, got ${codeAll.length}`); }); -test("CLI_TOOLS total (code + agent) = 30", () => { - assert.equal(all.length, 30, `Expected 30 total entries, got ${all.length}`); +test("CLI_TOOLS total (code + agent) = 32", () => { + assert.equal(all.length, 32, `Expected 32 total entries, got ${all.length}`); }); test("All code-none entries have configType mitm OR are legacy excluded entries", () => { @@ -98,7 +98,7 @@ test("The 20 visible code entries match D15 list exactly (+ crush + codewhale)", } }); -test("The 6 agent entries match D15 list exactly", () => { +test("The 8 agent entries match D15 list exactly (+ omp + letta, #6318)", () => { const d15Agents = new Set([ "hermes-agent", "openclaw", @@ -106,6 +106,8 @@ test("The 6 agent entries match D15 list exactly", () => { "interpreter", "warp", "agent-deck", + "omp", + "letta", ]); const agentIds = new Set(agentAll.map((t) => t.id)); for (const id of d15Agents) { diff --git a/tests/unit/cli-tools-schema.test.ts b/tests/unit/cli-tools-schema.test.ts index b74e274eef..4648f448ee 100644 --- a/tests/unit/cli-tools-schema.test.ts +++ b/tests/unit/cli-tools-schema.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -test("CLI_TOOLS registry contains all expected tools (plan 14 — 30 total + crush + codewhale)", async () => { +test("CLI_TOOLS registry contains all expected tools (plan 14 — 32 total + crush + codewhale + omp + letta)", async () => { const { CLI_TOOLS } = await import("../../src/shared/constants/cliTools.ts"); // windsurf and amp removed per plan 14 D17 (MITM backlog plan 11) // New entries added: roo, jcode, deepseek-tui, smelt, pi, aider, forge, @@ -9,6 +9,7 @@ test("CLI_TOOLS registry contains all expected tools (plan 14 — 30 total + cru // crush added — ported from upstream decolua/9router#1233 // codewhale added 2026-07-02 as a dual entry alongside deepseek-tui // (CodeWhale is the actively-maintained successor to DeepSeek TUI). + // omp + letta added by #6318 (agent-category CLI integrations). const expected = [ "claude", "codex", @@ -38,6 +39,8 @@ test("CLI_TOOLS registry contains all expected tools (plan 14 — 30 total + cru "goose", "interpreter", "warp", + "omp", + "letta", "agent-deck", "crush", ]; diff --git a/tests/unit/db/omp.test.ts b/tests/unit/db/omp.test.ts new file mode 100644 index 0000000000..19dc0177c0 --- /dev/null +++ b/tests/unit/db/omp.test.ts @@ -0,0 +1,140 @@ +/** + * Unit tests for src/lib/db/omp.ts — OMP (Oh My Pi) credential CRUD. + * + * omp.ts opens the third-party OMP CLI's OWN local sqlite database + * (~/.omp/agent/agent.db) directly, per request — NOT OmniRoute's own DB. + * These tests cover both the happy path (round trip against a fixture DB + * with the omp CLI's real `auth_credentials` schema) and the missing-DB-file + * path (omp CLI never run yet), which each exported function must handle + * gracefully without throwing. + */ +import { describe, it, beforeEach, afterEach } 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 Database from "better-sqlite3"; + +const { + getOmpCredentials, + saveOmpCredentials, + deleteOmpCredentials, +} = await import("../../../src/lib/db/omp.ts"); + +const PROVIDER_ID = "omniroute"; + +let tmpHome: string; +let origHome: string | undefined; + +function getOmpDbPath() { + return path.join(tmpHome, ".omp", "agent", "agent.db"); +} + +/** Simulate the omp CLI having already created its sqlite DB + schema. */ +function seedOmpDb() { + const dbPath = getOmpDbPath(); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE IF NOT EXISTS auth_credentials ( + provider TEXT NOT NULL, + credential_type TEXT NOT NULL, + data TEXT, + disabled_cause TEXT, + identity_key TEXT, + created_at INTEGER, + updated_at INTEGER + ) + `); + db.close(); +} + +beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omp-db-test-")); + origHome = process.env.HOME; + process.env.HOME = tmpHome; +}); + +afterEach(() => { + process.env.HOME = origHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); +}); + +describe("db/omp.ts — getOmpCredentials", () => { + it("returns hasOmniRoute:false without throwing when the omp DB file does not exist", () => { + assert.ok(!fs.existsSync(getOmpDbPath()), "precondition: no DB file yet"); + const creds = getOmpCredentials(PROVIDER_ID); + assert.deepEqual(creds, { hasOmniRoute: false, baseUrl: null, apiKey: null }); + }); + + it("returns hasOmniRoute:false when the DB exists but has no matching row", () => { + seedOmpDb(); + const creds = getOmpCredentials(PROVIDER_ID); + assert.deepEqual(creds, { hasOmniRoute: false, baseUrl: null, apiKey: null }); + }); + + it("returns hasOmniRoute:false gracefully when the schema itself is missing (corrupt/foreign DB)", () => { + const dbPath = getOmpDbPath(); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + // Valid sqlite file, but no auth_credentials table at all. + const db = new Database(dbPath); + db.exec("CREATE TABLE unrelated (id INTEGER)"); + db.close(); + + const creds = getOmpCredentials(PROVIDER_ID); + assert.deepEqual(creds, { hasOmniRoute: false, baseUrl: null, apiKey: null }); + }); +}); + +describe("db/omp.ts — saveOmpCredentials + getOmpCredentials round trip", () => { + it("persists apiKey/baseUrl so a subsequent read sees them", () => { + seedOmpDb(); + + saveOmpCredentials(PROVIDER_ID, "sk-test-omp-key", "http://localhost:20128/v1"); + + const creds = getOmpCredentials(PROVIDER_ID); + assert.equal(creds.hasOmniRoute, true); + assert.equal(creds.apiKey, "sk-test-omp-key"); + assert.equal(creds.baseUrl, "http://localhost:20128/v1"); + }); + + it("overwrites an existing row for the same provider instead of duplicating it", () => { + seedOmpDb(); + + saveOmpCredentials(PROVIDER_ID, "sk-old-key", "http://localhost:20128/v1"); + saveOmpCredentials(PROVIDER_ID, "sk-new-key", "http://localhost:20129/v1"); + + const dbPath = getOmpDbPath(); + const db = new Database(dbPath, { readonly: true }); + const rows = db + .prepare("SELECT data FROM auth_credentials WHERE provider = ?") + .all(PROVIDER_ID) as { data: string }[]; + db.close(); + + assert.equal(rows.length, 1, "must not accumulate duplicate rows for the same provider"); + const parsed = JSON.parse(rows[0].data); + assert.equal(parsed.apiKey, "sk-new-key"); + assert.equal(parsed.baseUrl, "http://localhost:20129/v1"); + }); +}); + +describe("db/omp.ts — deleteOmpCredentials", () => { + it("removes the row so a subsequent get reports hasOmniRoute:false", () => { + seedOmpDb(); + saveOmpCredentials(PROVIDER_ID, "sk-test-omp-key", "http://localhost:20128/v1"); + assert.equal(getOmpCredentials(PROVIDER_ID).hasOmniRoute, true); + + deleteOmpCredentials(PROVIDER_ID); + + assert.deepEqual(getOmpCredentials(PROVIDER_ID), { + hasOmniRoute: false, + baseUrl: null, + apiKey: null, + }); + }); + + it("does not throw when deleting a provider that was never saved", () => { + seedOmpDb(); + assert.doesNotThrow(() => deleteOmpCredentials(PROVIDER_ID)); + }); +});