From 1bc6da531802c64a417f79cfd58651a9e26d2ae6 Mon Sep 17 00:00:00 2001 From: Hamsa_M <116961508+hamsa0x7@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:57:52 +0530 Subject: [PATCH] feat(cli): add CLI tools for pi, omp, letta, codewhale and jcode (#6318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add CLI tools for pi, omp, letta, codewhale and jcode * fix(build): resolve CI build and lint errors * fix(cli): resolve merge conflicts, add tests, align error handling for cli-additions Resolve duplicate codewhale key from base merge, add unit/integration tests for omp/letta settings routes and the omp DB module, and align omp-settings/letta-settings error handling with sanitizeErrorMessage() + the pattern used by sibling jcode/pi/codewhale routes in this PR. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(quality): correct cliRuntime.ts file-size baseline to actual post-merge line count Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): re-restore #6318 bullet after release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(merge): restore #6126 clinepass files reverted by release auto-resolve + baseline re-merge The release sync's auto-resolve reverted sibling PR #6126's clinepass work (registry, catalog, oauth constants, clineAuth.ts, token-refresh case, tests) and the file-size baseline — all outside this PR's scope. Restored to the release versions, re-applied only this PR's own baseline entries, restored the #6126 CHANGELOG bullet (re-inserting only this PR's own). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(db): re-export db/omp from localDb (check:db-rules #2) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(db): keep localDb.ts at the 800-line cap after the omp re-export Folded the MemoryVecMeta type re-export into the memoryVec named-export block (inline 'type' specifier) so adding the db/omp line stays within the new-file cap. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(cli): reduce #6318 scope to omp + letta (pi/codewhale/jcode already shipped) pi, codewhale, and jcode landed via a separate PR before this one was reconciled — re-adding parallel versions of their catalog entries, routes, dashboard card, and i18n strings would have been a straight regression (duplicate "pi" key silently shadowing the release's own entry, orphaned JcodeToolCard/BaseUrlSelect/ApiKeySelect/cliEndpointMatch UI files with no release-side wiring, and unrelated formatting/refactor drift in codewhale-settings/pi-settings/config-generator/routeGuard picked up along the way). This PR now ships only the two tools that are genuinely new: omp (Oh My Pi) and letta. Both settings routes shell out to `which omp`/`which letta` to detect the local install, so they're loopback-gated in LOCAL_ONLY_API_PREFIXES (Hard Rules #15/#17) in addition to the shared requireCliToolsAuth() guard every cli-tools route requires (tests/unit/cli-tools-auth-hardening.test.ts) — neither route had the guard wired in yet. cli-catalog-counts.test.ts is updated to the real cardinality (8 agent entries / 32 total, since omp+letta are both category "agent"; pi/codewhale/jcode were always category "code" and are unaffected). The integration tests for omp/letta now pass a Request object to GET/DELETE and assert the 401-when-auth-required path, matching the pattern already used by the codewhale/jcode sibling routes. complexity-baseline.json is back to the release's 2053 (the #6318 rebaseline note is gone — dropping the duplicate JcodeToolCard.tsx/BaseUrlSelect.tsx removed the violations it was covering); file-size-baseline.json's cliTools.ts entry shrank 955->915 to match the smaller real file. CHANGELOG bullet rewritten to describe only omp+letta, with a note on why pi/codewhale/jcode aren't part of this PR; also restores the Kiro External IdP bullet that a prior merge auto-resolve had dropped from the living section. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test(cli-tools): align cli-tools-schema registry count with omp+letta (30→32) Second exact-count guard missed in the scope-reduction pass; same legitimate alignment as cli-catalog-counts. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(cli-tools): omp entry needs docsUrl (CliCatalogEntrySchema requires it) https://github.com/can1357/oh-my-pi — verified official repo. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(quality): cliTools.ts frozen 915→916 (+1 omp docsUrl line, own growth) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(changelog): restore base + re-insert #6318 bullet after release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(sync): merge release tip + restore own CHANGELOG bullet Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: hamsa0x7 Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- CHANGELOG.md | 1 + config/quality/file-size-baseline.json | 5 +- public/providers/letta.png | Bin 0 -> 1500 bytes public/providers/omp.png | Bin 0 -> 30118 bytes src/app/api/cli-tools/letta-settings/route.ts | 329 ++++++++++++++++++ src/app/api/cli-tools/omp-settings/route.ts | 180 ++++++++++ src/lib/db/omp.ts | 52 +++ src/lib/localDb.ts | 4 +- src/server/authz/routeGuard.ts | 2 + src/shared/constants/cliTools.ts | 53 +++ src/shared/schemas/cliCatalog.ts | 5 +- src/shared/services/cliRuntime.ts | 18 + src/shared/validation/schemas/cli.ts | 9 +- tests/integration/cli-settings-letta.test.ts | 196 +++++++++++ tests/integration/cli-settings-omp.test.ts | 197 +++++++++++ tests/unit/cli-catalog-counts.test.ts | 8 +- tests/unit/cli-tools-schema.test.ts | 5 +- tests/unit/db/omp.test.ts | 140 ++++++++ 18 files changed, 1193 insertions(+), 11 deletions(-) create mode 100644 public/providers/letta.png create mode 100644 public/providers/omp.png create mode 100644 src/app/api/cli-tools/letta-settings/route.ts create mode 100644 src/app/api/cli-tools/omp-settings/route.ts create mode 100644 src/lib/db/omp.ts create mode 100644 tests/integration/cli-settings-letta.test.ts create mode 100644 tests/integration/cli-settings-omp.test.ts create mode 100644 tests/unit/db/omp.test.ts 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 0000000000000000000000000000000000000000..100759b999aa2f29904fcdd5c43619bcdee689a0 GIT binary patch literal 1500 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k2}mkgS)OEIU|sC#;uumf=gpnF-qGm{$3M>S zdK_AN*LPCW+Pgij=?g>6w>EDy`et6T?(WsN7q`|q>2tMmw%+53F}FUr`s~?_`xk9l z6ZL_2TB%N_PxVC=Z6D#Ur#dY8UY=cZ+otpR_UBI?{@$4r_oKDW^PDFC+IjQ(*%{We z`tmo-<=_@-VdXs3#FFUPv_Zi!VuFH>hk%&M00^)0<^OQ+%9F!6YPnUeoJtcj^8ZI( z*0xDYjObq`ckZmu?6|HDnN=Q^K_`#gcyYb&@eH{~lHPt3mQCuE-zV>{D!i>^6qW7N#jrpga7U#XUpEbLm^IGMBdD(0BXJ=X( zmb~7Uzq(3KrjO_DuG&3IkA4y0r>mO}E@-Xi7OPuw zIepimhaE3P^IDZJ0z>?=_MY^_2-&&Ief%|itKFBsPeXlhl{cf#rB&~o?5zB}vQp1SobN>YD~ZP4$Z;_;kfp<0J-rPkKw=9X^l5s#{{ zdzkP;vxUS&sqRs3S+wL^eci&f9NFUM)>gO9cOsaWxewpEchAh0(X4H2)V2R}(gN(J zo7*0&7Hj+Z`tgpJoJ&{kIG7h9aqZ2S60^x>dk?Mkv9dL{POfVGuETS>aLz>Q!>vvS zzfAZm%GEkizrFkDqL+%G&~Z@Muw~1Sn`fnGZdt#7`=*&+E}WJ2pC;zZKk>uMi%$wv zZe6|m_}R?avuDpNsjB>WUi{ymlb0TSJ*d6>d`W4^yjAO0Uq7RB{M)&=uKx=EJl}a! z@_6AlyM-H9Y6{s(S=HP)ySr66@SqzgjomJII>Sq?evX8{N}aLo6NjXYlbOZu*Z-Uv zcG=k`!878#{@P=1@9lQfpZ_w!Xz$N!JNfN)iSR@f6(*fLe&eUg!3XW~@malFXUYZj zMcnqBy=s%znz{VNFD~qUA8WFAk<4>;y&EA@a}!ya8m~n}J0~xGDY`T>t7@gw{*3?4 zVBwDV{3-u@RiYA`?*h|7fNw7_%q-u$ix<=CZcRMMPar|G_@Cb+?~;H2=CfzX%fhED xc+{7?Bk}yjlpkF7tpisSVL1ASHUq=|{}-Nc81$qm?FN?G44$rjF6*2UngDngk0Ag6 literal 0 HcmV?d00001 diff --git a/public/providers/omp.png b/public/providers/omp.png new file mode 100644 index 0000000000000000000000000000000000000000..9da36e296aa9bcc58604476ad73eff0c4be81c00 GIT binary patch literal 30118 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 VXk)x=hc`;W|8j%5pB|I{e*lo}V*~&I literal 0 HcmV?d00001 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)); + }); +});