From 3355920db943e16b8ed639820fffcf86e2d34a06 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 19 May 2026 10:43:11 -0300 Subject: [PATCH] feat(installer): detect Termux and skip incompatible setup steps (#1764) --- CHANGELOG.md | 1 + bin/cli/commands/providers.mjs | 203 +++++++- bin/cli/commands/serve.mjs | 3 +- bin/cli/locales/en.json | 22 + bin/cli/locales/pt-BR.json | 22 + bin/cli/provider-store.mjs | 26 + open-sse/executors/index.ts | 4 + open-sse/executors/t3-chat-web.ts | 476 ++++++++++++++++++ open-sse/handlers/chatCore.ts | 18 +- open-sse/utils/error.ts | 55 +- scripts/build/postinstall.mjs | 13 +- scripts/build/postinstallSupport.mjs | 22 + src/app/api/oauth/kiro/auto-import/route.ts | 17 + src/app/api/oauth/kiro/import/route.ts | 18 +- .../api/oauth/kiro/social-exchange/route.ts | 24 + src/app/api/providers/zed/import/route.ts | 4 +- src/lib/oauth/services/kiro.ts | 40 +- src/lib/zed-oauth/dockerDetect.ts | 26 + src/lib/zed-oauth/keychain-reader.ts | 5 +- src/shared/constants/providers.ts | 29 ++ src/shared/validation/schemas.ts | 1 + .../unit/kiro-multi-account-isolation.test.ts | 147 ++++++ tests/unit/postinstall-support.test.ts | 19 +- .../provider-validation-specialty.test.ts | 16 + .../providers-route-managed-catalog.test.ts | 10 + 25 files changed, 1188 insertions(+), 33 deletions(-) create mode 100644 open-sse/executors/t3-chat-web.ts create mode 100644 src/lib/zed-oauth/dockerDetect.ts create mode 100644 tests/unit/kiro-multi-account-isolation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 43856a81a3..2f2835b2bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - **feat(providers):** improve Cohere provider support, expanding models and accurately updating OpenAI context limits. ([#2313](https://github.com/diegosouzapw/OmniRoute/pull/2313) — thanks @backryun) - **feat(claude-web):** implement session-based Claude Web executor with auto-refresh authentication — enables direct Claude Web API access without an API key. ([#2283](https://github.com/diegosouzapw/OmniRoute/pull/2283) — thanks @oyi77) - **feat(skills):** add 5 CLI skill manifests + AgentSkills / OmniSkills dashboard pages — enables external AI agents to discover and invoke OmniRoute capabilities. ([#2284](https://github.com/diegosouzapw/OmniRoute/pull/2284)) +- **feat(providers):** add llama.cpp as local provider — `llama-cpp` (alias `llamacpp`) added to `LOCAL_PROVIDERS` and `SELF_HOSTED_CHAT_PROVIDER_IDS`; default base URL `http://127.0.0.1:8080/v1`; no API key required; uses the default OpenAI-compatible executor ([#1980](https://github.com/diegosouzapw/OmniRoute/issues/1980)) - **feat(providers):** bulk add API keys with Single/Bulk tabs. - **feat(provider):** add Gitlawb Opengateway provider (xiaomi-mimo + gmi-cloud) with hasFree flag support. ([#2314](https://github.com/diegosouzapw/OmniRoute/pull/2314) — thanks @oyi77) - **feat(ui):** comprehensive dashboard UX rework including simple/advanced modes for RTK/Caveman, human-readable error badges, InfoTooltip/PresetSlider shared components, sidebar subtitles, and provider category filters. ([#2315](https://github.com/diegosouzapw/OmniRoute/pull/2315), [#2316](https://github.com/diegosouzapw/OmniRoute/pull/2316) — thanks @dhaern, @oyi77) diff --git a/bin/cli/commands/providers.mjs b/bin/cli/commands/providers.mjs index f197bbf598..10b15414bb 100644 --- a/bin/cli/commands/providers.mjs +++ b/bin/cli/commands/providers.mjs @@ -1,4 +1,4 @@ -import { apiFetch } from "../api.mjs"; +import { apiFetch, isServerUp } from "../api.mjs"; import { emit } from "../output.mjs"; import { printHeading } from "../io.mjs"; import { getAvailableProviderCategories, loadAvailableProviders } from "../provider-catalog.mjs"; @@ -7,8 +7,10 @@ import { findProviderConnection, getProviderApiKey, listProviderConnections, + updateProviderApiKey, updateProviderTestResult, } from "../provider-store.mjs"; +import { encryptCredential } from "../encryption.mjs"; import { openOmniRouteDb } from "../sqlite.mjs"; import { t } from "../i18n.mjs"; @@ -301,6 +303,179 @@ export async function runValidateCommand(opts = {}) { } } +export async function runProvidersRotateCommand(selector, opts = {}) { + if (!selector) { + console.error("Provider connection id or name is required."); + return 2; + } + + // --- Resolve connection --- + const { db } = await openOmniRouteDb(); + let connection; + try { + connection = findProviderConnection(db, selector); + } finally { + db.close(); + } + + if (!connection) { + console.error(`Provider connection not found: ${selector}`); + return 2; + } + + // --- OAuth short-circuit --- + if (opts.oauth || connection.authType !== "apikey") { + console.log(t("providers.rotate.oauthHint", { provider: connection.provider })); + return 0; + } + + // --- Source new key --- + let newKey; + if (opts.fromEnv) { + newKey = process.env[opts.fromEnv]; + if (!newKey) { + console.error(t("providers.rotate.envVarEmpty", { var: opts.fromEnv })); + return 2; + } + } else if (opts.newKey) { + newKey = opts.newKey; + } else { + // Interactive prompt (echo-off not strictly needed for a key value, but best practice) + const readline = await import("node:readline"); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + newKey = await new Promise((resolve) => + rl.question(`New API key for ${connection.name}: `, (a) => { rl.close(); resolve(a.trim()); }) + ); + if (!newKey) { + console.error("No key provided."); + return 2; + } + } + + // --- Dry-run --- + if (opts.dryRun) { + console.log(t("providers.rotate.dryRunResult", { name: connection.name, id: connection.id.slice(0, 8) })); + return 0; + } + + // --- Confirm --- + if (!opts.yes) { + const readline = await import("node:readline"); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => + rl.question(t("providers.rotate.confirmPrompt", { name: connection.name, id: connection.id.slice(0, 8) }), resolve) + ); + rl.close(); + if (!/^y(es|s)?$/i.test(answer)) { + console.log(t("common.cancelled")); + return 0; + } + } + + // --- Write --- + const serverUp = await isServerUp(); + if (serverUp) { + try { + const res = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, { + method: "PATCH", + body: { apiKey: newKey, testStatus: "unknown", lastError: null, rateLimitedUntil: null, backoffLevel: 0 }, + retry: false, + acceptNotOk: true, + }); + if (!res.ok) { + console.error(t("common.error", { message: `HTTP ${res.status}` })); + return 1; + } + } catch { + // Fall through to direct DB write + const { db: db2 } = await openOmniRouteDb(); + try { + updateProviderApiKey(db2, connection.id, encryptCredential(newKey)); + } finally { + db2.close(); + } + } + } else { + const { db: db2 } = await openOmniRouteDb(); + try { + updateProviderApiKey(db2, connection.id, encryptCredential(newKey)); + } finally { + db2.close(); + } + } + + console.log(t("providers.rotate.success", { name: connection.name, id: connection.id.slice(0, 8) })); + + // --- Post-rotation test --- + if (!opts.skipTest) { + const { db: db3 } = await openOmniRouteDb(); + try { + const fresh = findProviderConnection(db3, connection.id); + if (fresh) { + const result = await runProviderTest(db3, fresh); + if (result.valid) { + console.log(t("providers.rotate.testPassed")); + } else { + console.error(t("providers.rotate.testFailed", { error: result.error })); + } + } + } finally { + db3.close(); + } + } + + return 0; +} + +export async function runProvidersStatusCommand(opts = {}) { + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("providers.status.requiresServer")); + return 3; + } + + const res = await apiFetch("/api/providers/expiration", { acceptNotOk: true, retry: false }); + if (!res.ok) { + console.error(t("common.error", { message: `HTTP ${res.status}` })); + return 1; + } + + const data = await res.json(); + const list = data.list || []; + + // Optional provider filter + const filter = opts.provider ? String(opts.provider).toLowerCase() : null; + const rows = filter ? list.filter((item) => item.provider?.toLowerCase().includes(filter)) : list; + + if (opts.json || opts.output === "json") { + console.log(JSON.stringify({ count: rows.length, connections: rows }, null, 2)); + return 0; + } + + if (rows.length === 0) { + console.log(t("providers.status.noData")); + return 0; + } + + console.log(t("providers.status.header")); + for (const item of rows) { + const shortId = (item.connectionId || item.id || "").slice(0, 8); + const expiry = item.expiresAt ? new Date(item.expiresAt).toLocaleDateString() : "-"; + const expiryStatus = item.status || "unknown"; + const testStatus = item.testStatus || "unknown"; + const cooldown = item.rateLimitedUntil ? new Date(item.rateLimitedUntil).toLocaleString() : "-"; + const expiryColor = statusColor(expiryStatus); + const testColor = statusColor(testStatus); + console.log( + `${shortId.padEnd(10)} ${String(item.provider || "").padEnd(14)} ${String(item.name || "").padEnd(24)} ` + + `${expiry.padEnd(12)} ${expiryColor}${expiryStatus.padEnd(8)}\x1b[0m ` + + `${testColor}${testStatus.padEnd(12)}\x1b[0m ${cooldown}` + ); + } + + return 0; +} + export function registerProviders(program) { const providers = program.command("providers").description(t("providers.title")); @@ -357,6 +532,32 @@ export function registerProviders(program) { if (exitCode !== 0) process.exit(exitCode); }); + providers + .command("rotate ") + .description(t("providers.rotate.description")) + .option("--new-key ", t("providers.rotate.newKeyOpt")) + .option("--from-env ", t("providers.rotate.fromEnvOpt")) + .option("--oauth", t("providers.rotate.oauthOpt")) + .option("--yes", t("common.yesOpt")) + .option("--skip-test", t("providers.rotate.skipTestOpt")) + .option("--dry-run", t("providers.rotate.dryRunOpt")) + .action(async (idOrName, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runProvidersRotateCommand(idOrName, { ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); + + providers + .command("status") + .description(t("providers.status.description")) + .option("--provider ", t("providers.status.providerOpt")) + .option("--json", "Print machine-readable JSON") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runProvidersStatusCommand({ ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); + extendProvidersMetrics(providers); } diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs index 85fc2c095e..17b58e30c6 100644 --- a/bin/cli/commands/serve.mjs +++ b/bin/cli/commands/serve.mjs @@ -6,6 +6,7 @@ import { platform } from "node:os"; import { t } from "../i18n.mjs"; import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs"; import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs"; +import { isTermux } from "../../../scripts/build/postinstallSupport.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, "..", "..", ".."); @@ -330,7 +331,7 @@ async function onReady(dashboardPort, apiPort, noOpen) { \x1b[2m Press Ctrl+C to stop\x1b[0m `); - if (!noOpen) { + if (!noOpen && !isTermux()) { try { const open = await import("open"); await open.default(dashboardUrl); diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index c8d5071298..9434b9e8d8 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -60,6 +60,28 @@ }, "metric_single": { "description": "Get a single metric value for a specific connection" + }, + "rotate": { + "description": "Rotate the upstream API key for a provider connection", + "newKeyOpt": "New API key value (avoid: prefer --from-env)", + "fromEnvOpt": "Read new key from environment variable VAR", + "oauthOpt": "Trigger OAuth re-authentication flow instead", + "skipTestOpt": "Skip post-rotation connectivity test", + "dryRunOpt": "Print what would change without writing", + "confirmPrompt": "Replace API key for connection \"{name}\" ({id})? [y/N] ", + "dryRunResult": "[dry-run] Would rotate key for \"{name}\" ({id}). No changes made.", + "oauthHint": "OAuth connection — run: omniroute oauth {provider}", + "envVarEmpty": "Environment variable {var} is not set or is empty.", + "success": "Key rotated for \"{name}\". Run `providers test {id}` to verify.", + "testPassed": "Post-rotation test passed.", + "testFailed": "Post-rotation test failed: {error}" + }, + "status": { + "description": "Show key health for all provider connections (age, expiry, cooldown)", + "providerOpt": "Filter by provider name", + "header": "ID Provider Name Expiry Status Test Status Cooldown Until", + "noData": "No provider connection data available.", + "requiresServer": "providers status requires the OmniRoute server to be running." } }, "keys": { diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index 37d2efc9f0..6096dfb087 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -60,6 +60,28 @@ }, "metric_single": { "description": "Obter um único valor de métrica para uma conexão específica" + }, + "rotate": { + "description": "Rotacionar a chave de API upstream de uma conexão de provedor", + "newKeyOpt": "Novo valor de chave de API (evite: prefira --from-env)", + "fromEnvOpt": "Ler nova chave da variável de ambiente VAR", + "oauthOpt": "Iniciar fluxo de reautenticação OAuth", + "skipTestOpt": "Pular teste de conectividade pós-rotação", + "dryRunOpt": "Exibir o que seria alterado sem gravar", + "confirmPrompt": "Substituir a chave de API da conexão \"{name}\" ({id})? [s/N] ", + "dryRunResult": "[dry-run] Rotacionaria a chave para \"{name}\" ({id}). Nenhuma alteração feita.", + "oauthHint": "Conexão OAuth — execute: omniroute oauth {provider}", + "envVarEmpty": "A variável de ambiente {var} não está definida ou está vazia.", + "success": "Chave rotacionada para \"{name}\". Execute `providers test {id}` para verificar.", + "testPassed": "Teste pós-rotação aprovado.", + "testFailed": "Teste pós-rotação falhou: {error}" + }, + "status": { + "description": "Exibir saúde das chaves de todas as conexões de provedores (idade, validade, cooldown)", + "providerOpt": "Filtrar por nome do provedor", + "header": "ID Provedor Nome Validade Status Status Teste Cooldown Até", + "noData": "Nenhum dado de conexão de provedor disponível.", + "requiresServer": "providers status requer o servidor OmniRoute em execução." } }, "keys": { diff --git a/bin/cli/provider-store.mjs b/bin/cli/provider-store.mjs index ac76666d39..f42fc89685 100644 --- a/bin/cli/provider-store.mjs +++ b/bin/cli/provider-store.mjs @@ -251,6 +251,32 @@ export function removeProviderConnectionByProvider(db, provider) { return result.changes; } +/** + * Replace the encrypted API key for a connection and clear any cooldown state. + * `encryptedKey` must already be passed through `encryptCredential()`. + */ +export function updateProviderApiKey(db, connectionId, encryptedKey) { + ensureProviderSchema(db); + const now = new Date().toISOString(); + const result = db + .prepare( + `UPDATE provider_connections + SET api_key = @apiKey, + test_status = 'unknown', + last_error = NULL, + last_error_at = NULL, + last_error_type = NULL, + last_error_source = NULL, + error_code = NULL, + rate_limited_until = NULL, + backoff_level = 0, + updated_at = @updatedAt + WHERE id = @id` + ) + .run({ id: connectionId, apiKey: encryptedKey, updatedAt: now }); + return result.changes; +} + export function updateProviderTestResult(db, connectionId, result) { ensureProviderSchema(db); const now = new Date().toISOString(); diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index cf5a64c223..c4afe15ef2 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -30,6 +30,7 @@ import { DeepSeekWebExecutor } from "./deepseek-web.ts"; import { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-refresh.ts"; import { CopilotWebExecutor } from "./copilot-web.ts"; import { VeoAIFreeWebExecutor } from "./veoaifree-web.ts"; +import { T3ChatWebExecutor } from "./t3-chat-web.ts"; const executors = { antigravity: new AntigravityExecutor(), @@ -84,6 +85,8 @@ const executors = { copilot: new CopilotWebExecutor(), // Alias "veoaifree-web": new VeoAIFreeWebExecutor(), "veo-free": new VeoAIFreeWebExecutor(), // Alias + "t3-web": new T3ChatWebExecutor(), + t3chat: new T3ChatWebExecutor(), // Alias }; const defaultCache = new Map(); @@ -132,3 +135,4 @@ export { CopilotWebExecutor } from "./copilot-web.ts"; export { VeoAIFreeWebExecutor } from "./veoaifree-web.ts"; export { DeepSeekWebExecutor } from "./deepseek-web.ts"; export { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-refresh.ts"; +export { T3ChatWebExecutor } from "./t3-chat-web.ts"; diff --git a/open-sse/executors/t3-chat-web.ts b/open-sse/executors/t3-chat-web.ts new file mode 100644 index 0000000000..0467d54757 --- /dev/null +++ b/open-sse/executors/t3-chat-web.ts @@ -0,0 +1,476 @@ +// ## Known TODOs — Requires Manual DevTools Capture (Step 0 from plan #1909) +// +// Before this skeleton can serve live traffic, a human must open https://t3.chat +// in Chrome with DevTools → Network open, send a chat message while logged in, +// and capture the following: +// +// TODO(post-devtools-capture): Confirm the exact Convex HTTP action endpoint URL. +// Current guess based on Convex pattern: "https://t3.chat/api/chat" +// Alternative guesses: "https://t3.chat/api/sync/streamRoom", +// "https://api.t3.chat/api/chat", or a convex.cloud deployment URL. +// Reference: T3Router Rust source (github.com/vibheksoni/t3router) BASE_URL const. +// +// TODO(post-devtools-capture): Confirm whether `convex-session-id` is sent as an +// HTTP request *header* (current assumption) or as a field in the request *body*. +// Also confirm the exact header/field name (e.g. "convex-session-id", +// "x-convex-session-id", or "sessionId"). +// +// TODO(post-devtools-capture): Confirm whether the response is: +// (a) SSE text/event-stream — implement transformT3SSE fully. +// (b) Chunked newline-delimited JSON — adapt decoder. +// (c) Full JSON (non-streaming) — use collectContent path only. +// +// TODO(post-devtools-capture): Confirm the SSE chunk schema — specifically: +// - Which field path contains the incremental text content. +// - What the end-of-stream marker looks like ("[DONE]", a `status` field, etc.). +// +// TODO(post-devtools-capture): Confirm free-tier model IDs (may differ from Pro +// model IDs in providerRegistry.ts). Update registry entries accordingly. +// +// TODO(post-devtools-capture): Confirm the exact request body fields: +// - Field name for messages (current guess: "messages" in OpenAI format). +// - Field name for model (current guess: "model"). +// - Whether a conversation/thread ID is required. +// - Whether "stream" is a supported field. + +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; + +export const T3_CHAT_BASE = "https://t3.chat"; + +// TODO(post-devtools-capture): Replace with confirmed endpoint URL. +// Guesses based on Convex HTTP action pattern and reference implementations: +// - https://t3.chat/api/chat +// - https://t3.chat/api/sync/streamRoom +// Check T3Router Rust source for the BASE_URL constant before going live. +const COMPLETION_URL = `${T3_CHAT_BASE}/api/chat`; + +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + +// ── Types ──────────────────────────────────────────────────────────────── + +export interface T3ChatCredentials { + cookies: string; + convexSessionId: string; +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +function validateCredentials(creds: unknown): creds is T3ChatCredentials { + const raw = + typeof creds === "object" && creds !== null ? (creds as Record) : {}; + return ( + typeof raw.cookies === "string" && + raw.cookies.length > 0 && + typeof raw.convexSessionId === "string" && + raw.convexSessionId.length > 0 + ); +} + +function buildErrorResponse(status: number, message: string): Response { + return new Response( + JSON.stringify({ + error: { + message: sanitizeErrorMessage(message), + type: "upstream_error", + code: `HTTP_${status}`, + }, + }), + { status, headers: { "Content-Type": "application/json" } } + ); +} + +// ── SSE Transform (t3.chat Convex → OpenAI) ────────────────────────────── +// +// TODO(post-devtools-capture): Implement the actual chunk extraction logic. +// The field paths below are best guesses based on the Convex streaming protocol. +// Common Convex patterns: { type: "text", text: "..." } or { delta: "..." }. +// Replace `chunk.text ?? chunk.delta ?? chunk.content` with the real field path. + +function transformT3SSE(t3Stream: ReadableStream, model: string): ReadableStream { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const id = `chatcmpl-t3-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const created = Math.floor(Date.now() / 1000); + let emittedRole = false; + + return new ReadableStream({ + async start(controller) { + const reader = t3Stream.getReader(); + let buffer = ""; + + const emit = (obj: object) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`)); + }; + + const chunk = (delta: object, finish?: string) => { + emit({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta, finish_reason: finish ?? null }], + }); + }; + + const close = () => { + if (!emittedRole) { + emittedRole = true; + chunk({ role: "assistant", content: "" }); + } + chunk({}, "stop"); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6).trim(); + + if (payload === "[DONE]") { + close(); + return; + } + + let data: Record; + try { + data = JSON.parse(payload); + } catch { + continue; + } + + // TODO(post-devtools-capture): Replace this extraction with the real + // field path from the captured Convex SSE chunk structure. + // Current guess covers common Convex streaming patterns. + const textContent = + (data as any)?.text ?? + (data as any)?.delta ?? + (data as any)?.content ?? + (data as any)?.v?.text ?? + null; + + if (typeof textContent === "string" && textContent.length > 0) { + if (!emittedRole) { + emittedRole = true; + chunk({ role: "assistant", content: "" }); + } + chunk({ content: textContent }); + } + + // TODO(post-devtools-capture): Replace with real end-of-stream detection. + // Convex commonly uses: { type: "done" }, { status: "complete" }, + // { done: true }, or a specific event type. + const isDone = + (data as any)?.type === "done" || + (data as any)?.done === true || + (data as any)?.status === "complete" || + (data as any)?.finish_reason === "stop"; + + if (isDone) { + close(); + return; + } + } + } + } catch { + // Stream error — fall through to close + } + + close(); + }, + }); +} + +async function collectSSEContent(t3Stream: ReadableStream): Promise { + const decoder = new TextDecoder(); + const reader = t3Stream.getReader(); + let buffer = ""; + const parts: string[] = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6).trim(); + if (payload === "[DONE]") break; + try { + const data = JSON.parse(payload); + // TODO(post-devtools-capture): Use real field path. + const textContent = + (data as any)?.text ?? + (data as any)?.delta ?? + (data as any)?.content ?? + (data as any)?.v?.text ?? + null; + if (typeof textContent === "string") parts.push(textContent); + } catch { + // skip + } + } + } + + return parts.join(""); +} + +// ── Executor ───────────────────────────────────────────────────────────── + +export class T3ChatWebExecutor extends BaseExecutor { + constructor() { + super("t3-web", { baseUrl: T3_CHAT_BASE }); + } + + async testConnection( + credentials: Record, + signal?: AbortSignal + ): Promise { + try { + if (!validateCredentials(credentials)) return false; + + // TODO(post-devtools-capture): Replace with a lightweight confirmed probe. + // Current guess: HEAD or GET to T3_CHAT_BASE checks reachability. + // A better probe might be a lightweight OPTIONS or an auth-gated endpoint. + const resp = await fetch(T3_CHAT_BASE, { + method: "HEAD", + headers: { + "User-Agent": USER_AGENT, + Cookie: credentials.cookies, + }, + signal, + }); + // A 200/302/404 all indicate the site is reachable and the cookie was accepted + // without a hard 401. This is a best-effort probe until a proper endpoint is confirmed. + return resp.status < 500; + } catch { + return false; + } + } + + async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) { + const bodyObj = (body || {}) as Record; + const messages = (Array.isArray(bodyObj.messages) ? bodyObj.messages : []) as Array<{ + role: string; + content: string | unknown; + }>; + const rawCreds = credentials as unknown as Record; + + // 1. Validate credentials + if (!validateCredentials(rawCreds)) { + const missing = !rawCreds.cookies + ? "cookies" + : !rawCreds.convexSessionId + ? "convexSessionId" + : "both fields"; + return { + response: buildErrorResponse( + 400, + `t3.chat credentials invalid: missing or empty ${missing}. Both 'cookies' and 'convexSessionId' are required.` + ), + url: COMPLETION_URL, + headers: {}, + transformedBody: body, + }; + } + + const { cookies, convexSessionId } = rawCreds as T3ChatCredentials; + + try { + // 2. Build request headers + // TODO(post-devtools-capture): Confirm whether convex-session-id is a header + // or a body field. Current assumption: HTTP header. + const headers: Record = { + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + Accept: "text/event-stream, application/json", + Cookie: cookies, + // TODO(post-devtools-capture): Confirm header name — may be "x-convex-session-id" + // or sent as a body field instead. + "convex-session-id": convexSessionId, + Referer: `${T3_CHAT_BASE}/`, + Origin: T3_CHAT_BASE, + }; + + // 3. Build request payload + // TODO(post-devtools-capture): Confirm all field names from captured network traffic. + // Current guess: OpenAI-compatible messages array + model passthrough. + const requestPayload: Record = { + model, + messages, + stream: stream !== false, + }; + + log?.info?.("T3-CHAT-WEB", `POST ${COMPLETION_URL} model=${model}`); + + const resp = await fetch(COMPLETION_URL, { + method: "POST", + headers, + body: JSON.stringify(requestPayload), + signal, + }); + + // 4. Handle HTTP errors + if (!resp.ok) { + const status = resp.status; + let errMsg = `t3.chat API error (${status})`; + if (status === 401 || status === 403) { + errMsg = + "t3.chat session expired or unauthorized — re-paste your cookies and convex-session-id."; + } else if (status === 429) { + errMsg = "t3.chat rate limited. Wait and retry."; + } + log?.warn?.("T3-CHAT-WEB", errMsg); + return { + response: buildErrorResponse(status, errMsg), + url: COMPLETION_URL, + headers, + transformedBody: requestPayload, + }; + } + + const ct = resp.headers.get("content-type") || ""; + + // 5. Non-streaming full JSON response path + if (ct.includes("application/json")) { + const json = await resp.json(); + // Check for error in JSON body + if (json?.error) { + const errMsg = `t3.chat error: ${json.error?.message ?? JSON.stringify(json.error)}`; + log?.warn?.("T3-CHAT-WEB", errMsg); + return { + response: buildErrorResponse(502, errMsg), + url: COMPLETION_URL, + headers, + transformedBody: requestPayload, + }; + } + // If the JSON already looks like an OpenAI response, return it directly. + // Otherwise wrap it. + if (json?.choices) { + return { + response: new Response(JSON.stringify(json), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + url: COMPLETION_URL, + headers, + transformedBody: requestPayload, + }; + } + // TODO(post-devtools-capture): Map the actual t3.chat non-streaming response + // shape to OpenAI format once the real field names are confirmed. + const content = + (json as any)?.content ?? + (json as any)?.text ?? + (json as any)?.message?.content ?? + ""; + const openaiResponse = { + id: `chatcmpl-t3-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: model || "unknown", + choices: [ + { + index: 0, + message: { role: "assistant", content: String(content) }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }; + return { + response: new Response(JSON.stringify(openaiResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + url: COMPLETION_URL, + headers, + transformedBody: requestPayload, + }; + } + + // 6. Streaming SSE path + if (!resp.body) { + return { + response: buildErrorResponse(502, "t3.chat returned an empty response body"), + url: COMPLETION_URL, + headers, + transformedBody: requestPayload, + }; + } + + if (stream !== false) { + const openaiStream = transformT3SSE(resp.body, model || "unknown"); + return { + response: new Response(openaiStream, { + status: 200, + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" }, + }), + url: COMPLETION_URL, + headers, + transformedBody: requestPayload, + }; + } + + // Non-streaming: collect SSE content and return OpenAI JSON + const content = await collectSSEContent(resp.body); + const openaiResponse = { + id: `chatcmpl-t3-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: model || "unknown", + choices: [ + { + index: 0, + message: { role: "assistant", content }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }; + return { + response: new Response(JSON.stringify(openaiResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + url: COMPLETION_URL, + headers, + transformedBody: requestPayload, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log?.error?.("T3-CHAT-WEB", `Execute failed: ${msg}`); + + if (err instanceof DOMException && err.name === "AbortError") { + return { + response: buildErrorResponse(499, "Request cancelled"), + url: COMPLETION_URL, + headers: {}, + transformedBody: body, + }; + } + + return { + response: buildErrorResponse(502, `t3.chat connection error: ${msg}`), + url: COMPLETION_URL, + headers: {}, + transformedBody: body, + }; + } + } +} + +export const t3ChatWebExecutor = new T3ChatWebExecutor(); diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index b7a22f2b8e..a1a49f2b33 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -3915,7 +3915,8 @@ export async function handleChatCore({ errMsg, retryAfterMs, upstreamErrorCode, - upstreamErrorType + upstreamErrorType, + upstreamErrorBody ); } } catch { @@ -3933,7 +3934,8 @@ export async function handleChatCore({ errMsg, retryAfterMs, upstreamErrorCode, - upstreamErrorType + upstreamErrorType, + upstreamErrorBody ); } } else { @@ -3951,7 +3953,8 @@ export async function handleChatCore({ errMsg, retryAfterMs, upstreamErrorCode, - upstreamErrorType + upstreamErrorType, + upstreamErrorBody ); } } else if (isContextOverflowError(statusCode, message)) { @@ -3993,7 +3996,8 @@ export async function handleChatCore({ errMsg, retryAfterMs, upstreamErrorCode, - upstreamErrorType + upstreamErrorType, + upstreamErrorBody ); } } catch { @@ -4011,7 +4015,8 @@ export async function handleChatCore({ errMsg, retryAfterMs, upstreamErrorCode, - upstreamErrorType + upstreamErrorType, + upstreamErrorBody ); } } else { @@ -4029,7 +4034,8 @@ export async function handleChatCore({ errMsg, retryAfterMs, upstreamErrorCode, - upstreamErrorType + upstreamErrorType, + upstreamErrorBody ); } } else { diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 4e27a2b786..91a886a86a 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -14,6 +14,7 @@ interface ErrorResponseBody { type?: string; code?: string; }; + upstream_details?: Record | null; // sanitized upstream provider body } // Length cap protects against pathological inputs even before tokenization. @@ -56,21 +57,66 @@ export function sanitizeErrorMessage(message: unknown): string { return parts.join(""); } +const BLOCKED_KEYS = /stack|trace|path|file|cwd|dir|password|secret|token|key/i; +const MAX_DEPTH = 4; + +/** + * Recursively sanitize an arbitrary JSON value from an upstream provider body. + * - Strings: run through sanitizeErrorMessage (strips stacks + absolute paths). + * - Keys matching BLOCKED_KEYS are dropped (credential/path guards). + * - Depth capped at MAX_DEPTH to prevent pathological nesting. + * - Arrays capped at 32 elements. + * - Returns null for null/undefined/non-JSON-serializable values. + */ +export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown { + if (depth > MAX_DEPTH) return "[truncated]"; + if (value === null || value === undefined) return null; + if (typeof value === "string") return sanitizeErrorMessage(value); + if (typeof value === "number" || typeof value === "boolean") return value; + if (Array.isArray(value)) { + return value.slice(0, 32).map((v) => sanitizeUpstreamDetails(v, depth + 1)); + } + if (typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + if (BLOCKED_KEYS.test(k)) continue; + out[k] = sanitizeUpstreamDetails(v, depth + 1); + } + return out; + } + return null; +} + /** * Build OpenAI-compatible error response body. Message is always sanitized * so callers do not need to remember to strip stack traces themselves. + * Optional third argument `upstreamDetails` (raw parsed provider body) is + * sanitized by sanitizeUpstreamDetails before inclusion as `upstream_details`. */ -export function buildErrorBody(statusCode: number, message: string): ErrorResponseBody { +export function buildErrorBody( + statusCode: number, + message: string, + upstreamDetails?: unknown +): ErrorResponseBody { const errorInfo = getErrorInfo(statusCode); const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode); - return { + const body: ErrorResponseBody = { error: { message: safeMessage, type: errorInfo.type, code: errorInfo.code, }, }; + + if (upstreamDetails !== undefined && upstreamDetails !== null) { + const sanitized = sanitizeUpstreamDetails(upstreamDetails); + if (sanitized !== null && typeof sanitized === "object" && !Array.isArray(sanitized)) { + body.upstream_details = sanitized as Record; + } + } + + return body; } /** @@ -255,9 +301,10 @@ export function createErrorResult( message: string, retryAfterMs: number | null = null, errorCode?: string, - errorType?: string + errorType?: string, + upstreamDetails?: unknown ) { - const body = buildErrorBody(statusCode, message); + const body = buildErrorBody(statusCode, message, upstreamDetails); if (errorCode) { body.error.code = errorCode; } diff --git a/scripts/build/postinstall.mjs b/scripts/build/postinstall.mjs index 28b23df35a..5e83b4883a 100644 --- a/scripts/build/postinstall.mjs +++ b/scripts/build/postinstall.mjs @@ -27,7 +27,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { PUBLISHED_BUILD_ARCH, PUBLISHED_BUILD_PLATFORM } from "./native-binary-compat.mjs"; -import { hasStandaloneAppBundle } from "./postinstallSupport.mjs"; +import { hasStandaloneAppBundle, isTermux } from "./postinstallSupport.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -136,7 +136,7 @@ async function fixBetterSqliteBinary() { const { execSync } = await import("node:child_process"); // On Android/Termux, rebuild from source with --build-from-source flag - const isAndroid = process.platform === "android"; + const isAndroid = process.platform === "android" || isTermux(); const rebuildCmd = isAndroid ? "npm install better-sqlite3 --build-from-source --force" : "npm rebuild better-sqlite3"; @@ -196,8 +196,13 @@ async function fixBetterSqliteBinary() { * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/1634 */ async function fixWreqJsBinary() { - if (process.platform === "android") { - console.log(" [postinstall] wreq-js: skipped on android (unsupported platform)"); + // wreq-js native module is not loadable in Termux (libgcc path mismatch). + // The runtime already falls back gracefully when wreq-js is unavailable. + if (process.platform === "android" || isTermux()) { + console.log( + " [postinstall] wreq-js: skipped on Termux/Android " + + "(libgcc not available — OAuth TLS fingerprinting will use the fallback path)" + ); return; } diff --git a/scripts/build/postinstallSupport.mjs b/scripts/build/postinstallSupport.mjs index bb4f2837f7..9bd399aa5c 100644 --- a/scripts/build/postinstallSupport.mjs +++ b/scripts/build/postinstallSupport.mjs @@ -14,3 +14,25 @@ import { join } from "node:path"; export function hasStandaloneAppBundle(rootDir) { return existsSync(join(rootDir, "app", "server.js")); } + +/** + * Returns true when running inside a Termux environment on Android. + * + * Node.js on Termux reports process.platform === "linux" (not "android"), + * so OS-level platform checks are insufficient. Use Termux-specific signals: + * 1. TERMUX_VERSION env var (set by Termux bootstrap, most reliable) + * 2. PREFIX env var containing "com.termux" + * 3. Filesystem probe at /data/data/com.termux (last resort, no env needed) + * + * @param {object} [env] Override process.env for testing. + * @returns {boolean} + */ +export function isTermux(env = process.env) { + if (env.TERMUX_VERSION) return true; + if (typeof env.PREFIX === "string" && env.PREFIX.includes("com.termux")) return true; + try { + return existsSync("/data/data/com.termux"); + } catch { + return false; + } +} diff --git a/src/app/api/oauth/kiro/auto-import/route.ts b/src/app/api/oauth/kiro/auto-import/route.ts index 56e43835b7..7fb1915898 100755 --- a/src/app/api/oauth/kiro/auto-import/route.ts +++ b/src/app/api/oauth/kiro/auto-import/route.ts @@ -222,6 +222,23 @@ async function saveAndRespond( if (result.region) providerSpecificData.region = result.region; if (profileArn) providerSpecificData.profileArn = profileArn; + // For the SSO-cache fallback path the token came from ~/.aws/sso/cache and has no + // per-connection OIDC client. Register one now so this connection gets an isolated + // refresh session (#2328). The SQLite path already sets result.clientId. + if (!result.clientId) { + try { + const reg = await runWithProxyContext(proxy, () => kiroService.registerClient()); + providerSpecificData.clientId = reg.clientId; + providerSpecificData.clientSecret = reg.clientSecret; + providerSpecificData.region = "us-east-1"; + if (reg.clientSecretExpiresAt) { + providerSpecificData.clientSecretExpiresAt = reg.clientSecretExpiresAt; + } + } catch (err) { + console.warn("[kiro auto-import] registerClient failed, continuing without isolated client:", err); + } + } + // Refresh token to get a fresh access token and confirm it works const refreshed = await runWithProxyContext(proxy, () => kiroService.refreshToken(refreshToken, providerSpecificData) diff --git a/src/app/api/oauth/kiro/import/route.ts b/src/app/api/oauth/kiro/import/route.ts index ac65ed0ce6..ec61572fb5 100755 --- a/src/app/api/oauth/kiro/import/route.ts +++ b/src/app/api/oauth/kiro/import/route.ts @@ -44,16 +44,18 @@ export async function POST(request: Request) { if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const { refreshToken } = validation.data; + const { refreshToken, region } = validation.data; const kiroService = new KiroService(); // Resolve proxy for this provider (provider-level → global → direct) const proxy = await resolveProxyForProvider(targetProvider); - // Validate and refresh token (through proxy if configured) + // Validate and refresh token (through proxy if configured). + // validateImportToken also calls registerClient() to obtain a per-connection OIDC + // client pair so multiple Kiro accounts do not share a single backend session (#2328). const tokenData = await runWithProxyContext(proxy, () => - kiroService.validateImportToken(refreshToken.trim()) + kiroService.validateImportToken(refreshToken.trim(), region) ); // Extract email from JWT if available @@ -71,6 +73,16 @@ export async function POST(request: Request) { profileArn: tokenData.profileArn, authMethod: "imported", provider: "Imported", + ...(tokenData.clientId + ? { + clientId: tokenData.clientId, + clientSecret: tokenData.clientSecret, + region, + ...(tokenData.clientSecretExpiresAt + ? { clientSecretExpiresAt: tokenData.clientSecretExpiresAt } + : {}), + } + : {}), }, testStatus: "active", }); diff --git a/src/app/api/oauth/kiro/social-exchange/route.ts b/src/app/api/oauth/kiro/social-exchange/route.ts index 10bafd4cf4..888b600ad9 100755 --- a/src/app/api/oauth/kiro/social-exchange/route.ts +++ b/src/app/api/oauth/kiro/social-exchange/route.ts @@ -44,6 +44,20 @@ export async function POST(request: Request) { // Exchange code for tokens (redirect_uri handled internally) const tokenData = await kiroService.exchangeSocialCode(code, codeVerifier); + // Register an independent OIDC client for this connection so multiple Kiro accounts + // do not share a single backend session (#2328). Failure is non-fatal; the + // connection will degrade to the shared social-auth refresh path. + let oidcRegistration: { + clientId?: string; + clientSecret?: string; + clientSecretExpiresAt?: number; + } = {}; + try { + oidcRegistration = await kiroService.registerClient(); + } catch (err) { + console.warn("[kiro social-exchange] registerClient failed, continuing without it:", err); + } + // Extract email from JWT if available const email = kiroService.extractEmailFromJWT(tokenData.accessToken); @@ -59,6 +73,16 @@ export async function POST(request: Request) { profileArn: tokenData.profileArn, authMethod: provider, // "google" or "github" provider: provider.charAt(0).toUpperCase() + provider.slice(1), + ...(oidcRegistration.clientId + ? { + clientId: oidcRegistration.clientId, + clientSecret: oidcRegistration.clientSecret, + region: "us-east-1", + ...(oidcRegistration.clientSecretExpiresAt + ? { clientSecretExpiresAt: oidcRegistration.clientSecretExpiresAt } + : {}), + } + : {}), }, testStatus: "active", }); diff --git a/src/app/api/providers/zed/import/route.ts b/src/app/api/providers/zed/import/route.ts index 1ee8296d9e..37a438750e 100644 --- a/src/app/api/providers/zed/import/route.ts +++ b/src/app/api/providers/zed/import/route.ts @@ -14,6 +14,7 @@ import { discoverZedCredentials, isZedInstalled } from "@/lib/zed-oauth/keychain import { partitionZedCredentials } from "@/lib/zed-oauth/importUtils"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { createProviderConnection } from "@/lib/db/providers"; +import { isRunningInDocker } from "@/lib/zed-oauth/dockerDetect"; interface ImportResponse { success: boolean; @@ -27,6 +28,7 @@ interface ImportResponse { }>; error?: string; zedInstalled?: boolean; + zedDockerEnvironment?: boolean; } export async function POST(request: Request): Promise | Response> { @@ -82,7 +84,7 @@ export async function POST(request: Request): Promise>; try { - const result = await this.refreshToken(refreshToken); - return { - accessToken: result.accessToken, - refreshToken: result.refreshToken || refreshToken, - profileArn: result.profileArn, - expiresIn: result.expiresIn, - authMethod: "imported", - }; + result = await this.refreshToken(refreshToken); } catch (error: any) { throw new Error(`Token validation failed: ${error.message}`); } + + // Register an independent OIDC client for this connection so multiple accounts + // do not share a single Kiro backend session (issue #2328). + let clientId: string | undefined; + let clientSecret: string | undefined; + let clientSecretExpiresAt: number | undefined; + try { + const registration = await this.registerClient(region); + clientId = registration.clientId; + clientSecret = registration.clientSecret; + clientSecretExpiresAt = registration.clientSecretExpiresAt; + } catch (err: any) { + console.warn("[kiro import] registerClient failed, continuing without isolated client:", err); + } + + return { + accessToken: result.accessToken, + refreshToken: result.refreshToken || refreshToken, + profileArn: result.profileArn, + expiresIn: result.expiresIn, + authMethod: "imported", + ...(clientId ? { clientId, clientSecret, clientSecretExpiresAt } : {}), + }; } /** diff --git a/src/lib/zed-oauth/dockerDetect.ts b/src/lib/zed-oauth/dockerDetect.ts new file mode 100644 index 0000000000..683e85837f --- /dev/null +++ b/src/lib/zed-oauth/dockerDetect.ts @@ -0,0 +1,26 @@ +import fs from "fs"; + +/** + * Returns true when OmniRoute appears to be running inside a Docker container. + * Uses two complementary heuristics that work on Linux-based Docker images: + * 1. Presence of /.dockerenv (written by Docker at container startup). + * 2. The string "docker" appearing in /proc/1/cgroup (Linux only). + * + * This is intentionally a best-effort check; false negatives on exotic runtimes + * (e.g. podman without Docker compatibility) are acceptable — the caller degrades + * gracefully and still surfaces the manual-import option. + */ +export function isRunningInDocker(): boolean { + try { + if (fs.existsSync("/.dockerenv")) return true; + } catch { + // ignore — not Linux or permission denied + } + try { + const cgroup = fs.readFileSync("/proc/1/cgroup", "utf8"); + if (cgroup.includes("docker")) return true; + } catch { + // ignore — not Linux or /proc not mounted + } + return false; +} diff --git a/src/lib/zed-oauth/keychain-reader.ts b/src/lib/zed-oauth/keychain-reader.ts index 1945806757..c7fd61a822 100644 --- a/src/lib/zed-oauth/keychain-reader.ts +++ b/src/lib/zed-oauth/keychain-reader.ts @@ -13,6 +13,7 @@ import fs from "fs"; import os from "os"; import path from "path"; +import { isRunningInDocker } from "./dockerDetect"; /** Minimal keytar surface (CJS/native; typings may not expose `default`). */ type KeytarModule = { @@ -132,7 +133,7 @@ export async function discoverZedCredentials(): Promise { }); } } catch (error: any) { - console.debug(`No credentials found for ${pattern}:`, error?.message || error); + console.debug("No credentials found for %s:", pattern, error?.message || error); // Continue to next pattern } } @@ -186,7 +187,7 @@ export async function getZedCredential(provider: string): Promise Promise) { + const original = globalThis.fetch; + globalThis.fetch = impl; + return fn().finally(() => { + globalThis.fetch = original; + }); +} + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +/** + * Build a fetch mock that handles: + * - /token → returns a minimal token refresh response + * - /client/register → returns the given registration pair + */ +function buildFetchMock(registration: { clientId: string; clientSecret: string; clientSecretExpiresAt?: number }) { + return (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/client/register")) { + return jsonResponse(registration); + } + // Treat any other URL as a social-auth/refresh endpoint + return jsonResponse({ + accessToken: "at-mock", + refreshToken: "rt-next-mock", + expiresIn: 3600, + }); + }) as typeof fetch; +} + +// A valid-looking Kiro refresh token (must start with "aorAAAAAG") +const VALID_REFRESH_TOKEN = "aorAAAAAG-mock-refresh-token-for-tests"; + +// ── tests ───────────────────────────────────────────────────────────────────── + +test("validateImportToken registers a client and returns clientId + clientSecret", async () => { + const service = new KiroService(); + const reg = { clientId: "test-client-id", clientSecret: "test-client-secret", clientSecretExpiresAt: 9999999999 }; + + await withMockedFetch(buildFetchMock(reg), async () => { + const result = await service.validateImportToken(VALID_REFRESH_TOKEN); + assert.equal(result.clientId, reg.clientId, "clientId should be returned"); + assert.equal(result.clientSecret, reg.clientSecret, "clientSecret should be returned"); + assert.equal(result.clientSecretExpiresAt, reg.clientSecretExpiresAt, "clientSecretExpiresAt should be returned"); + assert.equal(result.authMethod, "imported"); + assert.equal(result.accessToken, "at-mock"); + }); +}); + +test("validateImportToken succeeds without clientId when registerClient fails", async () => { + const service = new KiroService(); + let callCount = 0; + + await withMockedFetch(async (input) => { + const url = String(input); + callCount++; + if (url.endsWith("/client/register")) { + return new Response("Service Unavailable", { status: 503 }); + } + return jsonResponse({ + accessToken: "at-degraded", + refreshToken: "rt-degraded", + expiresIn: 3600, + }); + }, async () => { + // Should not throw even though registerClient fails + const result = await service.validateImportToken(VALID_REFRESH_TOKEN); + assert.equal(result.accessToken, "at-degraded", "import should succeed with a degraded token"); + assert.equal(result.authMethod, "imported"); + // clientId must not be set — the connection degrades to shared social-auth path + assert.equal(result.clientId, undefined, "clientId should be absent on degraded import"); + assert.equal(result.clientSecret, undefined, "clientSecret should be absent on degraded import"); + }); + + assert.ok(callCount >= 1, "fetch should have been called at least once"); +}); + +test("validateImportToken throws when token format is invalid", async () => { + const service = new KiroService(); + await assert.rejects( + () => service.validateImportToken("invalid-token-does-not-start-correctly"), + /Invalid token format/ + ); +}); + +test("two validateImportToken calls return different clientIds when registerClient returns distinct pairs", async () => { + const service = new KiroService(); + let registrationIndex = 0; + const registrations = [ + { clientId: "client-alpha", clientSecret: "secret-alpha" }, + { clientId: "client-beta", clientSecret: "secret-beta" }, + ]; + + const mockFetch: typeof fetch = async (input) => { + const url = String(input); + if (url.endsWith("/client/register")) { + return jsonResponse(registrations[registrationIndex++] ?? registrations[0]); + } + return jsonResponse({ accessToken: "at", refreshToken: "rt", expiresIn: 3600 }); + }; + + await withMockedFetch(mockFetch, async () => { + const result1 = await service.validateImportToken(VALID_REFRESH_TOKEN); + const result2 = await service.validateImportToken(VALID_REFRESH_TOKEN); + + assert.notEqual(result1.clientId, result2.clientId, + "each import call should receive a distinct clientId for session isolation"); + assert.equal(result1.clientId, "client-alpha"); + assert.equal(result2.clientId, "client-beta"); + }); +}); + +test("registerClient uses the provided region in the OIDC endpoint URL", async () => { + const service = new KiroService(); + const calls: string[] = []; + + await withMockedFetch(async (input) => { + calls.push(String(input)); + return jsonResponse({ clientId: "cid", clientSecret: "csec" }); + }, async () => { + await service.registerClient("ap-southeast-1"); + }); + + assert.ok( + calls.some((url) => url.includes("ap-southeast-1")), + "registerClient should call the OIDC endpoint for the specified region" + ); +}); diff --git a/tests/unit/postinstall-support.test.ts b/tests/unit/postinstall-support.test.ts index 55472d8aae..c007b35ae0 100644 --- a/tests/unit/postinstall-support.test.ts +++ b/tests/unit/postinstall-support.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { hasStandaloneAppBundle } from "../../scripts/build/postinstallSupport.mjs"; +import { hasStandaloneAppBundle, isTermux } from "../../scripts/build/postinstallSupport.mjs"; test("hasStandaloneAppBundle returns false for source checkout without standalone app", () => { const root = mkdtempSync(join(tmpdir(), "omniroute-postinstall-src-")); @@ -28,3 +28,20 @@ test("hasStandaloneAppBundle returns true for published standalone app bundle", rmSync(root, { recursive: true, force: true }); } }); + +// isTermux detection +test("isTermux returns false when no termux signals present", () => { + assert.equal(isTermux({}), false); +}); + +test("isTermux returns true when TERMUX_VERSION is set", () => { + assert.equal(isTermux({ TERMUX_VERSION: "0.119" }), true); +}); + +test("isTermux returns true when PREFIX contains com.termux", () => { + assert.equal(isTermux({ PREFIX: "/data/data/com.termux/files/usr" }), true); +}); + +test("isTermux returns false for non-termux PREFIX", () => { + assert.equal(isTermux({ PREFIX: "/usr/local" }), false); +}); diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index 3ae0d2f51f..a5b2525c11 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -863,21 +863,28 @@ test("local OpenAI-style providers validate without sending Authorization when a provider: "lemonade", providerSpecificData: { baseUrl: "http://localhost:13305/api/v1" }, }); + const llamaCpp = await validateProviderApiKey({ + provider: "llama-cpp", + providerSpecificData: { baseUrl: "http://127.0.0.1:8080/v1" }, + }); assert.equal(lmStudio.valid, true); assert.equal(vllm.valid, true); assert.equal(lemonade.valid, true); + assert.equal(llamaCpp.valid, true); assert.deepEqual( calls.map((call) => call.url), [ "http://localhost:1234/v1/models", "http://localhost:8000/v1/models", "http://localhost:13305/api/v1/models", + "http://127.0.0.1:8080/v1/models", ] ); assert.equal(calls[0].headers.Authorization, undefined); assert.equal(calls[1].headers.Authorization, undefined); assert.equal(calls[2].headers.Authorization, undefined); + assert.equal(calls[3].headers.Authorization, undefined); } finally { if (originalAllowPrivateProviderUrls === undefined) { delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS; @@ -1981,3 +1988,12 @@ test("validateCommandCodeProvider rejects auth failures and provider outages", a error: "Provider unavailable (500)", }); }); + +test("llama-cpp is classified as a self-hosted chat provider", async () => { + const { isSelfHostedChatProvider, isLocalProvider, providerAllowsOptionalApiKey } = + await import("../../src/shared/constants/providers.ts"); + + assert.equal(isSelfHostedChatProvider("llama-cpp"), true); + assert.equal(isLocalProvider("llama-cpp"), true); + assert.equal(providerAllowsOptionalApiKey("llama-cpp"), true); +}); diff --git a/tests/unit/providers-route-managed-catalog.test.ts b/tests/unit/providers-route-managed-catalog.test.ts index a5eed52490..c59f3e5fbc 100644 --- a/tests/unit/providers-route-managed-catalog.test.ts +++ b/tests/unit/providers-route-managed-catalog.test.ts @@ -280,6 +280,16 @@ test("providers route accepts managed local, audio, web-cookie and search provid }, }, }, + { + provider: "llama-cpp", + body: { + provider: "llama-cpp", + name: "llama.cpp Local", + providerSpecificData: { + baseUrl: "http://127.0.0.1:8080/v1", + }, + }, + }, { provider: "triton", body: {