From 196375b8a9e9bf84cd2fba07ae3ab3fab91c6225 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:35:43 -0300 Subject: [PATCH] feat(providers): add Augment (Auggie CLI) local provider (#5972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(providers): add Augment (Auggie CLI) local provider Adds a new local, no-auth provider that spawns the user's local `auggie` CLI (`auggie --print --quiet --model --`) and pipes a flattened prompt via stdin, wrapping stdout as an OpenAI-compatible SSE stream or a single chat.completion JSON body depending on the request's `stream` flag. Auth is delegated entirely to `auggie login` outside OmniRoute — the connection is registered `noAuth: true` and `refreshCredentials()` is a no-op, matching the existing `NOAUTH_PROVIDERS` credential-less flow (synthetic connection, no DB row required). An optional connection row is still admitted via `FREE_APIKEY_PROVIDER_IDS` for display/priority tracking, consistent with `opencode`. The dashboard "Test Connection" flow spawns `auggie --version` to confirm the CLI is installed and runnable, since there is no API key to validate upstream. Security hardening (spawn is an untrusted-input sink): - Command injection: spawn no longer passes `shell: true` on Windows. The binary is resolved to a concrete path/name and argv is handed straight to the OS loader, so no cmd.exe metacharacter interpretation is possible. - Argument injection (flag smuggling): `model` is validated against the registry allowlist (`auggieProvider.models`) before any spawn — a model that is unknown or starts with "-" is rejected with a sanitized error and the subprocess is never started. A trailing `--` marks end-of-options in the argv as belt-and-suspenders. Co-authored-by: chamdanilukman <16629923+chamdanilukman@users.noreply.github.com> Inspired-by: https://github.com/decolua/9router/pull/1200 * test(golden): regenerate translate-path for auggie provider --------- Co-authored-by: chamdanilukman <16629923+chamdanilukman@users.noreply.github.com> --- CHANGELOG.md | 1 + open-sse/config/providers/index.ts | 2 + .../config/providers/registry/auggie/index.ts | 38 ++ open-sse/executors/auggie.ts | 569 ++++++++++++++++++ open-sse/executors/index.ts | 3 + src/lib/providers/validation.ts | 17 + src/shared/constants/providers.ts | 5 + src/shared/constants/providers/noauth.ts | 20 + tests/snapshots/provider/translate-path.json | 23 + tests/unit/auggie-executor.test.ts | 380 ++++++++++++ 10 files changed, 1058 insertions(+) create mode 100644 open-sse/config/providers/registry/auggie/index.ts create mode 100644 open-sse/executors/auggie.ts create mode 100644 tests/unit/auggie-executor.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9892b9b7bd..57d1e503ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - **feat(dashboard):** add a settings toggle for tool-source diagnostics logging. (thanks @DuyPrX) - **feat(oauth):** import a ChatGPT/Codex connection from a raw access token (no refresh token required). (thanks @ryanngit) - **feat(providers):** add NVIDIA NIM image generation (FLUX models). (thanks @eng2007) +- **feat(providers):** add Augment (Auggie CLI) as a local no-auth provider. (thanks @chamdanilukman) ### 🔧 Bug Fixes diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 9913c7ea1c..a3e30f42c7 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -142,6 +142,7 @@ import { kilo_gatewayProvider } from "./registry/kilo-gateway/index.ts"; import { bailian_coding_planProvider } from "./registry/bailian-coding-plan/index.ts"; import { gigachatProvider } from "./registry/gigachat/index.ts"; import { devin_cliProvider } from "./registry/devin-cli/index.ts"; +import { auggieProvider } from "./registry/auggie/index.ts"; import { chutesProvider } from "./registry/chutes/index.ts"; import { factoryProvider } from "./registry/factory/index.ts"; import { databricksProvider } from "./registry/databricks/index.ts"; @@ -313,6 +314,7 @@ export const REGISTRY: Record = { "bailian-coding-plan": bailian_coding_planProvider, gigachat: gigachatProvider, "devin-cli": devin_cliProvider, + auggie: auggieProvider, chutes: chutesProvider, factory: factoryProvider, databricks: databricksProvider, diff --git a/open-sse/config/providers/registry/auggie/index.ts b/open-sse/config/providers/registry/auggie/index.ts new file mode 100644 index 0000000000..ee346426a0 --- /dev/null +++ b/open-sse/config/providers/registry/auggie/index.ts @@ -0,0 +1,38 @@ +import type { RegistryEntry } from "../../shared.ts"; + +// Augment / Auggie CLI — local no-auth provider. The executor spawns the +// user's local `auggie` binary (auth handled entirely by `auggie login`); +// OmniRoute never stores credentials for this connection. +export const auggieProvider: RegistryEntry = { + id: "auggie", + alias: "aug", + format: "openai", + executor: "auggie", + baseUrl: "auggie://cli/stdio", + authType: "none", + authHeader: "none", + defaultContextLength: 200000, + models: [ + // Claude + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", contextLength: 200000 }, + { + id: "claude-sonnet-4.6-thinking", + name: "Claude Sonnet 4.6 Thinking", + contextLength: 200000, + }, + { id: "claude-opus-4.6", name: "Claude Opus 4.6", contextLength: 200000 }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000 }, + // Gemini + { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", contextLength: 1000000 }, + { id: "gemini-3.0-flash", name: "Gemini 3 Flash", contextLength: 1000000 }, + // GPT-5.x + { id: "gpt-5.5-high", name: "GPT-5.5 High", contextLength: 200000 }, + { id: "gpt-5.5-medium", name: "GPT-5.5 Medium", contextLength: 200000 }, + { id: "gpt-5.4-high", name: "GPT-5.4 High", contextLength: 200000 }, + { id: "gpt-5.4-medium", name: "GPT-5.4 Medium", contextLength: 200000 }, + // Kimi + { id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 131000 }, + // Prism (Augment's in-house model) + { id: "prism", name: "Augment Prism", contextLength: 200000 }, + ], +}; diff --git a/open-sse/executors/auggie.ts b/open-sse/executors/auggie.ts new file mode 100644 index 0000000000..f15d82c7e3 --- /dev/null +++ b/open-sse/executors/auggie.ts @@ -0,0 +1,569 @@ +/** + * AuggieExecutor — routes completions through the local Augment CLI ("auggie") + * binary via a one-shot stdin/stdout text pipe (no JSON-RPC / ACP protocol). + * + * Flow: + * 1. Flatten the OpenAI-shaped `messages[]` into a single prompt string. + * 2. Spawn `auggie --print --quiet --model ` and pipe the prompt on stdin. + * 3. Relay stdout chunks as OpenAI-compatible SSE deltas (stream=true) or + * buffer them into a single chat.completion JSON body (stream=false). + * 4. Kill the subprocess on abort / stream close. + * + * Authentication: + * None. Auggie delegates auth entirely to the user's local `auggie login` + * session — OmniRoute never sees or stores credentials for this provider. + * The connection is registered `noAuth: true` and `refreshCredentials()` is + * a no-op (nothing to refresh). + * + * Binary discovery: + * 1. AUGGIE_BIN / CLI_AUGGIE_BIN env var (absolute path override) + * 2. PATH lookup ("auggie" / "auggie.cmd") + * 3. %LOCALAPPDATA%\auggie\bin\auggie.exe (Windows installer) + * 4. ~/.local/share/auggie/bin/auggie (Linux installer) + * 5. ~/.auggie/bin/auggie (alternate installer layout) + */ + +import { spawn } from "node:child_process"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts"; +import { buildErrorBody, errorResponse, sanitizeErrorMessage } from "../utils/error.ts"; +import { auggieProvider } from "../config/providers/registry/auggie/index.ts"; + +const AUGGIE_URL = "auggie://cli/stdio"; + +// ─── Model allowlist (argument-injection defense) ───────────────────────────── +// The `model` value is forwarded straight into the `auggie` argv, so it is an +// untrusted-input sink. We only ever pass a model that is declared in the +// registry entry — this closes flag-smuggling (a `model` starting with "-" would +// otherwise be parsed by auggie as an option) and unknown-model passthrough. +const AUGGIE_MODEL_ALLOWLIST: ReadonlySet = new Set( + auggieProvider.models.map((m) => m.id) +); +const DEFAULT_AUGGIE_MODEL = auggieProvider.models[0]?.id ?? "claude-sonnet-4.6"; + +type AuggieModelResolution = { ok: true; model: string } | { ok: false; error: string }; + +/** + * Validate + resolve the requested model against the registry allowlist. + * Rejects flag-smuggling (leading "-") and any id not declared in the registry. + * An empty/absent model resolves to the registry's first (default) model. + */ +export function resolveAuggieModel(model: unknown): AuggieModelResolution { + const requested = typeof model === "string" ? model.trim() : ""; + if (!requested) return { ok: true, model: DEFAULT_AUGGIE_MODEL }; + if (requested.startsWith("-")) { + return { + ok: false, + error: `Invalid Auggie model "${requested}": model must not start with "-".`, + }; + } + if (!AUGGIE_MODEL_ALLOWLIST.has(requested)) { + return { + ok: false, + error: `Unknown Auggie model "${requested}". Supported models: ${[ + ...AUGGIE_MODEL_ALLOWLIST, + ].join(", ")}.`, + }; + } + return { ok: true, model: requested }; +} + +/** + * Build the auggie argv. `model` MUST already be allowlist-validated via + * resolveAuggieModel(). The trailing `--` marks the end of options so no + * later positional value can be reinterpreted as a flag. + */ +function buildAuggieArgs(model: string): string[] { + return ["--print", "--quiet", "--model", model, "--"]; +} + +// ─── Binary discovery ──────────────────────────────────────────────────────── + +export function resolveAuggieBin(): string { + // 1. Explicit override + const envBin = (process.env.AUGGIE_BIN || process.env.CLI_AUGGIE_BIN || "").trim(); + if (envBin) return envBin; + + const isWin = process.platform === "win32"; + + // 2. Windows installer default: %LOCALAPPDATA%\auggie\bin\auggie.exe + if (isWin) { + const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"); + const winPath = path.join(localAppData, "auggie", "bin", "auggie.exe"); + if (fs.existsSync(winPath)) return winPath; + } + + // 3. Linux/macOS installer paths + const home = os.homedir(); + for (const candidate of [ + path.join(home, ".local", "share", "auggie", "bin", "auggie"), + path.join(home, ".auggie", "bin", "auggie"), + ]) { + if (fs.existsSync(candidate)) return candidate; + } + + // Fallback — rely on PATH + return isWin ? "auggie.cmd" : "auggie"; +} + +// ─── Multi-turn message → single prompt builder ─────────────────────────────── + +type OpenAIMsg = { role?: string; content?: unknown }; + +export function buildAuggiePrompt(messages: OpenAIMsg[]): string { + const lines: string[] = []; + for (const m of messages) { + const role = String(m.role || "user"); + let text = ""; + if (typeof m.content === "string") { + text = m.content; + } else if (Array.isArray(m.content)) { + for (const p of m.content) { + if (p && typeof p === "object" && (p as Record).type === "text") { + text += String((p as Record).text || ""); + } + } + } + if (!text.trim()) continue; + if (role === "system") { + lines.push(`[System]\n${text}`); + } else if (role === "assistant") { + lines.push(`[Assistant]\n${text}`); + } else { + lines.push(`[User]\n${text}`); + } + } + return lines.join("\n\n") || "(empty)"; +} + +function isEnoentLike(message: string): boolean { + return message.includes("ENOENT") || message.includes("not found"); +} + +export type AuggieCliVersionCheck = { ok: boolean; version?: string; error?: string }; + +/** + * Spawn `auggie --version` to confirm the local CLI is installed and runnable. + * Used by the provider "Test Connection" flow — auggie has no API key, so this + * is the only real signal we can give the operator before their first chat call. + */ +export function checkAuggieCliVersion(timeoutMs = 5000): Promise { + const bin = resolveAuggieBin(); + return new Promise((resolve) => { + let settled = false; + const settle = (result: AuggieCliVersionCheck) => { + if (settled) return; + settled = true; + resolve(result); + }; + + let child: ReturnType; + try { + // No `shell` option — fixed argv, no cmd.exe interpretation. + child = spawn(bin, ["--version"], { + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + settle({ ok: false, error: isEnoentLike(message) ? cliNotFoundMessage(bin) : message }); + return; + } + + const timer = setTimeout(() => { + if (!child.killed) child.kill("SIGKILL"); + settle({ ok: false, error: "Auggie CLI version check timed out" }); + }, timeoutMs); + + let stdout = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.on("error", (err: NodeJS.ErrnoException) => { + clearTimeout(timer); + const message = err?.message || String(err); + settle({ ok: false, error: isEnoentLike(message) ? cliNotFoundMessage(bin) : message }); + }); + child.on("close", (code) => { + clearTimeout(timer); + if (code === 0 && stdout.trim()) { + settle({ ok: true, version: stdout.trim().slice(0, 200) }); + } else { + settle({ ok: false, error: `Auggie CLI exited with code ${code}` }); + } + }); + }); +} + +function cliNotFoundMessage(bin: string): string { + return sanitizeErrorMessage( + `Auggie CLI not found: ${bin}. Install it and run "auggie login", or set AUGGIE_BIN to an absolute path.` + ); +} + +// ─── AuggieExecutor ───────────────────────────────────────────────────────── + +export class AuggieExecutor extends BaseExecutor { + constructor() { + super("auggie", { id: "auggie", baseUrl: "" }); + } + + buildUrl(): string { + return AUGGIE_URL; + } + + buildHeaders(): Record { + return {}; + } + + transformRequest(): unknown { + return null; + } + + /** No-op — auggie has no OmniRoute-managed credentials to refresh. */ + async refreshCredentials( + _credentials: ProviderCredentials + ): Promise | null> { + return null; + } + + async execute({ + model, + body, + stream, + signal, + log, + }: ExecuteInput): Promise<{ + response: Response; + url: string; + headers: Record; + transformedBody: unknown; + }> { + const b = (body ?? {}) as Record; + const messages: OpenAIMsg[] = Array.isArray(b.messages) ? (b.messages as OpenAIMsg[]) : []; + const promptText = buildAuggiePrompt(messages); + const auggieBin = resolveAuggieBin(); + const wantsStream = stream !== false; + + // Argument-injection defense: never forward an unvalidated model into the argv. + const modelResolution = resolveAuggieModel(model); + if (!modelResolution.ok) { + const response = wantsStream + ? buildAuggieSseError(modelResolution.error) + : errorResponse(400, modelResolution.error); + return { response, url: AUGGIE_URL, headers: {}, transformedBody: { error: true } }; + } + const safeModel = modelResolution.model; + + log?.info?.( + "AUGGIE", + `auggie --print → model=${safeModel}, bin=${auggieBin}, stream=${wantsStream}` + ); + + const response = wantsStream + ? this.runStreaming(auggieBin, safeModel, promptText, signal, log) + : await this.runNonStreaming(auggieBin, safeModel, promptText, signal, log); + + return { + response, + url: AUGGIE_URL, + headers: {}, + transformedBody: { model: safeModel, promptLength: promptText.length }, + }; + } + + private spawnAuggie(auggieBin: string, model: string, promptText: string) { + // No `shell` option: argv is passed directly to the OS loader, so no cmd.exe + // metacharacter interpretation is possible even on Windows. `model` is already + // allowlist-validated by resolveAuggieModel() before reaching here. + const child = spawn(auggieBin, buildAuggieArgs(model), { + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + try { + child.stdin.write(promptText); + child.stdin.end(); + } catch { + /* ignore write errors — 'error'/'close' handlers surface the failure */ + } + return child; + } + + private runStreaming( + auggieBin: string, + model: string, + promptText: string, + signal: AbortSignal | null | undefined, + log: ExecuteInput["log"] + ): Response { + const responseId = `chatcmpl-auggie-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + const sseStream = new ReadableStream({ + start(controller) { + const enc = new TextEncoder(); + const emit = (data: string) => controller.enqueue(enc.encode(data)); + let closed = false; + let roleEmitted = false; + let finished = false; + + const finish = () => { + if (finished) return; + finished = true; + if (!closed) { + closed = true; + try { + controller.close(); + } catch { + /* already closed */ + } + } + }; + + const emitDelta = (delta: string) => { + if (!delta) return; + if (!roleEmitted) { + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }, + ], + })}\n\n` + ); + roleEmitted = true; + } + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: delta }, finish_reason: null }], + })}\n\n` + ); + }; + + const emitError = (message: string) => { + emit(`data: ${JSON.stringify(buildErrorBody(502, message))}\n\n`); + emit("data: [DONE]\n\n"); + finish(); + }; + + const emitStop = () => { + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n` + ); + emit("data: [DONE]\n\n"); + finish(); + }; + + let child: ReturnType; + try { + // No `shell` option — argv goes straight to the OS loader (no cmd.exe + // interpretation). `model` is already allowlist-validated upstream. + child = spawn(auggieBin, buildAuggieArgs(model), { + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + emitError(isEnoentLike(message) ? cliNotFoundMessage(auggieBin) : sanitizeErrorMessage(message)); + return; + } + + try { + child.stdin.write(promptText); + child.stdin.end(); + } catch { + /* ignore — error/close handlers below surface failures */ + } + + if (signal) { + signal.addEventListener("abort", () => { + if (!child.killed) child.kill("SIGTERM"); + finish(); + }); + } + + child.on("error", (err: NodeJS.ErrnoException) => { + const message = err?.message || String(err); + emitError(isEnoentLike(message) ? cliNotFoundMessage(auggieBin) : sanitizeErrorMessage(message)); + }); + + let stderrTail = ""; + child.stdout?.on("data", (chunk: Buffer) => { + emitDelta(chunk.toString("utf8")); + }); + + child.stderr?.on("data", (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString("utf8")).slice(-2000); + log?.debug?.("AUGGIE", `stderr: ${chunk.toString("utf8").slice(0, 200)}`); + }); + + child.on("close", (code) => { + if (finished) return; + if (code !== 0) { + emitError( + sanitizeErrorMessage( + `Auggie CLI exited with code ${code}${stderrTail ? `: ${stderrTail}` : ""}` + ) + ); + return; + } + emitStop(); + }); + }, + cancel() { + // Stream cancelled by the consumer — nothing extra to clean up here; + // the abort-signal listener above (if provided) handles process kill. + }, + }); + + return new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } + + private runNonStreaming( + auggieBin: string, + model: string, + promptText: string, + signal: AbortSignal | null | undefined, + log: ExecuteInput["log"] + ): Promise { + return new Promise((resolve) => { + let child: ReturnType; + try { + child = this.spawnAuggie(auggieBin, model, promptText); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + resolve( + buildAuggieErrorResponse( + isEnoentLike(message) ? cliNotFoundMessage(auggieBin) : sanitizeErrorMessage(message) + ) + ); + return; + } + + let stdout = ""; + let stderrTail = ""; + let settled = false; + + const settle = (response: Response) => { + if (settled) return; + settled = true; + resolve(response); + }; + + if (signal) { + signal.addEventListener("abort", () => { + if (!child.killed) child.kill("SIGTERM"); + settle(buildAuggieErrorResponse(sanitizeErrorMessage("Auggie CLI request aborted"))); + }); + } + + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString("utf8")).slice(-2000); + log?.debug?.("AUGGIE", `stderr: ${chunk.toString("utf8").slice(0, 200)}`); + }); + + child.on("error", (err: NodeJS.ErrnoException) => { + const message = err?.message || String(err); + settle( + buildAuggieErrorResponse( + isEnoentLike(message) ? cliNotFoundMessage(auggieBin) : sanitizeErrorMessage(message) + ) + ); + }); + + child.on("close", (code) => { + if (code !== 0) { + settle( + buildAuggieErrorResponse( + sanitizeErrorMessage( + `Auggie CLI exited with code ${code}${stderrTail ? `: ${stderrTail}` : ""}` + ) + ) + ); + return; + } + settle(buildChatCompletionResponse(model, promptText, stdout)); + }); + }); + } +} + +function buildChatCompletionResponse(model: string, promptText: string, content: string): Response { + const trimmed = content.trim(); + const body = { + id: `chatcmpl-auggie-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: trimmed }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: Math.ceil(promptText.length / 4), + completion_tokens: Math.ceil(trimmed.length / 4), + total_tokens: Math.ceil((promptText.length + trimmed.length) / 4), + estimated: true, + }, + }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function buildAuggieErrorResponse(message: string): Response { + return errorResponse(502, message); +} + +/** + * Build a one-shot SSE error Response (single sanitized error event + [DONE]). + * Used for pre-spawn rejections on the streaming path (e.g. an invalid model) + * where no subprocess is ever started. + */ +function buildAuggieSseError(message: string): Response { + const enc = new TextEncoder(); + const sseStream = new ReadableStream({ + start(controller) { + controller.enqueue(enc.encode(`data: ${JSON.stringify(buildErrorBody(400, message))}\n\n`)); + controller.enqueue(enc.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index f522a13cf6..5984fde2ee 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -28,6 +28,7 @@ import { GitlabExecutor } from "./gitlab.ts"; import { NlpCloudExecutor } from "./nlpcloud.ts"; import { WindsurfExecutor } from "./windsurf.ts"; import { DevinCliExecutor } from "./devin-cli.ts"; +import { AuggieExecutor } from "./auggie.ts"; import { DeepSeekWebExecutor } from "./deepseek-web.ts"; import { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-refresh.ts"; import { AdaptaWebExecutor } from "./adapta-web.ts"; @@ -155,6 +156,7 @@ const executors = { cbcn: new CodeBuddyCnExecutor(), // Alias for codebuddy-cn "zenmux-free": new ZenmuxFreeExecutor(), zmf: new ZenmuxFreeExecutor(), // Alias for zenmux-free + auggie: new AuggieExecutor(), xai: new XaiExecutor(), }; @@ -201,6 +203,7 @@ export { GitlabExecutor } from "./gitlab.ts"; export { NlpCloudExecutor } from "./nlpcloud.ts"; export { WindsurfExecutor } from "./windsurf.ts"; export { DevinCliExecutor } from "./devin-cli.ts"; +export { AuggieExecutor } from "./auggie.ts"; export { CopilotWebExecutor } from "./copilot-web.ts"; export { CopilotM365WebExecutor } from "./copilot-m365-web.ts"; export { VeoAIFreeWebExecutor } from "./veoaifree-web.ts"; diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index faa4002767..867625ccb6 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -298,6 +298,23 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi } }, jules: validateJulesProvider, + // auggie is a fully local, credential-less CLI passthrough — there is no API + // key to check upstream. The only meaningful validation is confirming the + // `auggie` binary is installed and runnable on this machine. + auggie: async () => { + const { checkAuggieCliVersion } = await import( + "@omniroute/open-sse/executors/auggie.ts" + ); + const result = await checkAuggieCliVersion(); + if (!result.ok) { + return { + valid: false, + error: result.error || "Auggie CLI not found. Install it and run `auggie login`.", + unsupported: false, + }; + } + return { valid: true, error: null, unsupported: false, method: result.version }; + }, qoder: async ({ apiKey, providerSpecificData }: any) => { // Bifurcate validation: PAT tokens use Cosy auth against api1.qoder.sh; // regular API keys validate against dashscope (OpenAI-compatible endpoint). diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index bc1aa53973..36f3a535e4 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -33,6 +33,11 @@ export const FREE_APIKEY_PROVIDER_IDS = new Set([ // API key (Authorization: Bearer). Admit it through the same managed-provider // gate so POST /api/providers accepts the dual-auth shape. "codebuddy-cn", + // auggie is a fully local, credential-less CLI passthrough (auth handled by + // `auggie login` outside OmniRoute). Admitted here purely so POST /api/providers + // accepts an optional connection row for display/priority/testStatus tracking — + // no apiKey is ever required or sent upstream. + "auggie", ]); export function supportsApiKeyOnFreeProvider(providerId: unknown): boolean { diff --git a/src/shared/constants/providers/noauth.ts b/src/shared/constants/providers/noauth.ts index 1375f6cdc5..a33e0d08e4 100644 --- a/src/shared/constants/providers/noauth.ts +++ b/src/shared/constants/providers/noauth.ts @@ -100,4 +100,24 @@ export const NOAUTH_PROVIDERS = { text: "MiMoCode uses Xiaomi's public free AI endpoint with bootstrap-based JWT authentication. No signup needed. Rate limits apply.", }, }, + auggie: { + id: "auggie", + alias: "aug", + name: "Augment (Auggie CLI)", + icon: "terminal", + color: "#7C3AED", + textIcon: "AU", + website: "https://augmentcode.com", + noAuth: true, + hasFree: false, + serviceKinds: ["llm"], + isLocalCli: true, + freeNote: + "Local passthrough — runs the Augment CLI (`auggie`) on this machine. Auth is handled by `auggie login`, not OmniRoute.", + authHint: + "No API key stored by OmniRoute. Install the Auggie CLI and run `auggie login` on this machine, then OmniRoute spawns it locally for each request.", + notice: { + text: "Augment (Auggie CLI) requires the `auggie` binary installed and authenticated locally (`auggie login`). OmniRoute spawns it as a subprocess and never sees or stores your Augment credentials.", + }, + }, }; diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index f64cee4d49..b535763f8a 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -289,6 +289,29 @@ "stream": "https://api.airforce/v1/chat/completions" } }, + "auggie": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + } + }, + "url": { + "nonStream": "auggie://cli/stdio", + "stream": "auggie://cli/stdio" + } + }, "baichuan": { "format": "openai", "headers": { diff --git a/tests/unit/auggie-executor.test.ts b/tests/unit/auggie-executor.test.ts new file mode 100644 index 0000000000..c5418ac546 --- /dev/null +++ b/tests/unit/auggie-executor.test.ts @@ -0,0 +1,380 @@ +/** + * AuggieExecutor unit tests. + * + * Rather than mocking node:child_process (fragile under ESM without + * --experimental-vm-modules — see tests/unit/dns-config-generic.test.ts for the + * same tradeoff), these tests point `AUGGIE_BIN` at small real, disposable shell + * scripts that stand in for the `auggie` CLI. No live `auggie` binary is required + * or touched — CI never needs the real Augment CLI installed. + */ + +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 { AuggieExecutor, buildAuggiePrompt, resolveAuggieBin, resolveAuggieModel } = await import( + "@omniroute/open-sse/executors/auggie" +); + +const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-auggie-test-")); + +/** Write an executable shell script and return its absolute path. */ +function writeFakeBin(name: string, script: string): string { + const p = path.join(TMP_DIR, name); + fs.writeFileSync(p, `#!/bin/sh\n${script}\n`, { mode: 0o755 }); + return p; +} + + +async function readSseEvents(response: Response): Promise[]> { + const text = await response.text(); + const events: Record[] = []; + for (const line of text.split("\n")) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice("data: ".length).trim(); + if (payload === "[DONE]") continue; + events.push(JSON.parse(payload)); + } + return events; +} + +test.after(() => { + fs.rmSync(TMP_DIR, { recursive: true, force: true }); +}); + +// ─── buildAuggiePrompt ──────────────────────────────────────────────────── + +test("buildAuggiePrompt flattens system/user/assistant turns with role tags", () => { + const prompt = buildAuggiePrompt([ + { role: "system", content: "Be terse." }, + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + { role: "user", content: "how are you?" }, + ]); + assert.equal( + prompt, + "[System]\nBe terse.\n\n[User]\nhi\n\n[Assistant]\nhello\n\n[User]\nhow are you?" + ); +}); + +test("buildAuggiePrompt flattens array-shaped content blocks and skips empty turns", () => { + const prompt = buildAuggiePrompt([ + { role: "user", content: [{ type: "text", text: "part1 " }, { type: "text", text: "part2" }] }, + { role: "user", content: "" }, + { role: "user", content: [] }, + ]); + assert.equal(prompt, "[User]\npart1 part2"); +}); + +test("buildAuggiePrompt returns a placeholder for an empty conversation", () => { + assert.equal(buildAuggiePrompt([]), "(empty)"); +}); + +// ─── resolveAuggieBin ───────────────────────────────────────────────────── + +test("resolveAuggieBin honors AUGGIE_BIN env override", () => { + const prev = process.env.AUGGIE_BIN; + try { + process.env.AUGGIE_BIN = "/custom/path/to/auggie"; + assert.equal(resolveAuggieBin(), "/custom/path/to/auggie"); + } finally { + if (prev === undefined) delete process.env.AUGGIE_BIN; + else process.env.AUGGIE_BIN = prev; + } +}); + +// ─── execute(): ENOENT → CLI not found ───────────────────────────────────── + +test("execute() surfaces a sanitized 'CLI not found' error on ENOENT (streaming)", async () => { + const prevBin = process.env.AUGGIE_BIN; + process.env.AUGGIE_BIN = path.join(TMP_DIR, "does-not-exist-binary"); + try { + const executor = new AuggieExecutor(); + const { response } = await executor.execute({ + model: "claude-sonnet-4.6", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: true, + credentials: {} as never, + }); + assert.equal(response.headers.get("Content-Type"), "text/event-stream"); + const events = await readSseEvents(response); + const errorEvent = events.find((e) => (e as any).error); + assert.ok(errorEvent, "expected an error SSE event"); + const message = String((errorEvent as any).error.message); + // The configured bin path is intentionally included (actionable for the + // operator) — sanitizeErrorMessage only strips stack-trace-shaped source + // paths (`*.ts`/`*.js` + line:col), not arbitrary CLI binary paths. + assert.match(message, /Auggie CLI not found/); + assert.equal(message.includes("\n"), false, "message must not contain a stack trace"); + } finally { + if (prevBin === undefined) delete process.env.AUGGIE_BIN; + else process.env.AUGGIE_BIN = prevBin; + } +}); + +test("execute() surfaces a sanitized 'CLI not found' error on ENOENT (non-streaming)", async () => { + const prevBin = process.env.AUGGIE_BIN; + process.env.AUGGIE_BIN = path.join(TMP_DIR, "does-not-exist-binary-2"); + try { + const executor = new AuggieExecutor(); + const { response } = await executor.execute({ + model: "claude-sonnet-4.6", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: {} as never, + }); + assert.equal(response.headers.get("Content-Type"), "application/json"); + assert.equal(response.status, 502); + const body = await response.json(); + assert.match(String(body.error.message), /Auggie CLI not found/); + } finally { + if (prevBin === undefined) delete process.env.AUGGIE_BIN; + else process.env.AUGGIE_BIN = prevBin; + } +}); + +// ─── execute(): non-zero exit → sanitized error ──────────────────────────── + +test("execute() surfaces a sanitized error when the CLI exits non-zero (streaming)", async () => { + const bin = writeFakeBin("fake-auggie-fail.sh", 'echo "boom at /home/attacker/secret.ts:42" 1>&2\nexit 3'); + const prevBin = process.env.AUGGIE_BIN; + process.env.AUGGIE_BIN = bin; + try { + const executor = new AuggieExecutor(); + const { response } = await executor.execute({ + model: "claude-sonnet-4.6", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: true, + credentials: {} as never, + }); + const events = await readSseEvents(response); + const errorEvent = events.find((e) => (e as any).error); + assert.ok(errorEvent, "expected an error SSE event"); + const message = String((errorEvent as any).error.message); + assert.match(message, /exited with code 3/); + assert.equal(message.includes("/home/attacker/secret.ts"), false, "path must be sanitized"); + } finally { + if (prevBin === undefined) delete process.env.AUGGIE_BIN; + else process.env.AUGGIE_BIN = prevBin; + } +}); + +test("execute() surfaces a sanitized error when the CLI exits non-zero (non-streaming)", async () => { + const bin = writeFakeBin("fake-auggie-fail2.sh", "exit 1"); + const prevBin = process.env.AUGGIE_BIN; + process.env.AUGGIE_BIN = bin; + try { + const executor = new AuggieExecutor(); + const { response } = await executor.execute({ + model: "claude-sonnet-4.6", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: {} as never, + }); + assert.equal(response.status, 502); + const body = await response.json(); + assert.match(String(body.error.message), /exited with code 1/); + } finally { + if (prevBin === undefined) delete process.env.AUGGIE_BIN; + else process.env.AUGGIE_BIN = prevBin; + } +}); + +// ─── execute(): stream vs non-stream shape ───────────────────────────────── + +test("execute() with stream=true returns SSE deltas + [DONE]", async () => { + const bin = writeFakeBin("fake-auggie-echo.sh", 'printf "hello world"'); + const prevBin = process.env.AUGGIE_BIN; + process.env.AUGGIE_BIN = bin; + try { + const executor = new AuggieExecutor(); + const { response } = await executor.execute({ + model: "claude-sonnet-4.6", + body: { messages: [{ role: "user", content: "say hi" }] }, + stream: true, + credentials: {} as never, + }); + assert.equal(response.headers.get("Content-Type"), "text/event-stream"); + const events = await readSseEvents(response); + // First chunk announces the assistant role. + assert.equal((events[0] as any).choices[0].delta.role, "assistant"); + // Content is streamed as delta chunks that concatenate to the full text. + const contentDeltas = events + .map((e) => (e as any).choices?.[0]?.delta?.content) + .filter((c) => typeof c === "string"); + assert.equal(contentDeltas.join(""), "hello world"); + // Final chunk signals completion. + const last = events[events.length - 1] as any; + assert.equal(last.choices[0].finish_reason, "stop"); + } finally { + if (prevBin === undefined) delete process.env.AUGGIE_BIN; + else process.env.AUGGIE_BIN = prevBin; + } +}); + +test("execute() with stream=false returns a single chat.completion JSON body", async () => { + const bin = writeFakeBin("fake-auggie-echo2.sh", 'printf "hello world"'); + const prevBin = process.env.AUGGIE_BIN; + process.env.AUGGIE_BIN = bin; + try { + const executor = new AuggieExecutor(); + const { response } = await executor.execute({ + model: "claude-sonnet-4.6", + body: { messages: [{ role: "user", content: "say hi" }] }, + stream: false, + credentials: {} as never, + }); + assert.equal(response.headers.get("Content-Type"), "application/json"); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.object, "chat.completion"); + assert.equal(body.choices[0].message.role, "assistant"); + assert.equal(body.choices[0].message.content, "hello world"); + assert.equal(body.choices[0].finish_reason, "stop"); + assert.ok(body.usage); + } finally { + if (prevBin === undefined) delete process.env.AUGGIE_BIN; + else process.env.AUGGIE_BIN = prevBin; + } +}); + +// ─── resolveAuggieModel ───────────────────────────────────────────────────── + +test("resolveAuggieModel defaults to a real allowlisted model when unset", () => { + const result = resolveAuggieModel(undefined); + assert.equal(result.ok, true); + if (result.ok) assert.equal(typeof result.model, "string"); +}); + +test("resolveAuggieModel accepts a known registry model id verbatim", () => { + const result = resolveAuggieModel("claude-haiku-4.5"); + assert.deepEqual(result, { ok: true, model: "claude-haiku-4.5" }); +}); + +// ─── execute(): model allowlist (argument-injection defense) ────────────── + +test("execute() rejects a model not in the registry allowlist and never spawns", async () => { + const marker = path.join(TMP_DIR, "spawned-unknown-model.marker"); + const bin = writeFakeBin("fake-auggie-unknown.sh", `touch "${marker}"\nprintf "hi"`); + const prevBin = process.env.AUGGIE_BIN; + process.env.AUGGIE_BIN = bin; + try { + const executor = new AuggieExecutor(); + const { response } = await executor.execute({ + model: "totally-not-a-real-model", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: {} as never, + }); + assert.equal(response.status, 400); + const body = await response.json(); + assert.match(String(body.error.message), /Unknown Auggie model/); + assert.equal(fs.existsSync(marker), false, "subprocess must never be spawned"); + } finally { + if (prevBin === undefined) delete process.env.AUGGIE_BIN; + else process.env.AUGGIE_BIN = prevBin; + } +}); + +test("execute() rejects a model starting with '-' (flag smuggling) and never spawns (streaming)", async () => { + const marker = path.join(TMP_DIR, "spawned-flag-smuggle.marker"); + const bin = writeFakeBin("fake-auggie-flag.sh", `touch "${marker}"\nprintf "hi"`); + const prevBin = process.env.AUGGIE_BIN; + process.env.AUGGIE_BIN = bin; + try { + const executor = new AuggieExecutor(); + const { response } = await executor.execute({ + model: "-rf", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: true, + credentials: {} as never, + }); + assert.equal(response.headers.get("Content-Type"), "text/event-stream"); + const events = await readSseEvents(response); + const errorEvent = events.find((e) => (e as any).error); + assert.ok(errorEvent, "expected an error SSE event"); + assert.match(String((errorEvent as any).error.message), /must not start with "-"/); + assert.equal(fs.existsSync(marker), false, "subprocess must never be spawned"); + } finally { + if (prevBin === undefined) delete process.env.AUGGIE_BIN; + else process.env.AUGGIE_BIN = prevBin; + } +}); + +test("execute() spawns with a valid allowlisted model, no shell, and a '--' argv separator", async () => { + const argvFile = path.join(TMP_DIR, "captured-argv.txt"); + const bin = writeFakeBin( + "fake-auggie-argv.sh", + `for a in "$@"; do printf '%s\\n' "$a" >> "${argvFile}"; done\nprintf "ok"` + ); + const prevBin = process.env.AUGGIE_BIN; + process.env.AUGGIE_BIN = bin; + try { + const executor = new AuggieExecutor(); + const { response } = await executor.execute({ + model: "claude-opus-4.6", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: {} as never, + }); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.choices[0].message.content, "ok"); + const argv = fs.readFileSync(argvFile, "utf8").trim().split("\n"); + assert.deepEqual(argv, ["--print", "--quiet", "--model", "claude-opus-4.6", "--"]); + } finally { + if (prevBin === undefined) delete process.env.AUGGIE_BIN; + else process.env.AUGGIE_BIN = prevBin; + fs.rmSync(argvFile, { force: true }); + } +}); + +// ─── execute(): abort kills the subprocess promptly ──────────────────────── + +test("execute() aborts a long-running CLI process instead of hanging (streaming)", async () => { + // Sleeps far longer than the test timeout unless killed on abort. + const bin = writeFakeBin("fake-auggie-sleep.sh", "sleep 30"); + const prevBin = process.env.AUGGIE_BIN; + process.env.AUGGIE_BIN = bin; + try { + const executor = new AuggieExecutor(); + const controller = new AbortController(); + const { response } = await executor.execute({ + model: "claude-sonnet-4.6", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: true, + credentials: {} as never, + signal: controller.signal, + }); + // Give the child process a beat to spawn, then abort. + await new Promise((resolve) => setTimeout(resolve, 100)); + controller.abort(); + + // Reading the stream must resolve promptly (i.e. the stream closes) rather + // than hanging for the full 30s sleep. + const start = Date.now(); + await response.text(); + const elapsed = Date.now() - start; + assert.ok(elapsed < 5000, `expected abort to close the stream quickly, took ${elapsed}ms`); + } finally { + if (prevBin === undefined) delete process.env.AUGGIE_BIN; + else process.env.AUGGIE_BIN = prevBin; + } +}); + +// ─── refreshCredentials() is a no-op ──────────────────────────────────────── + +test("refreshCredentials() is a no-op (auggie has no OmniRoute-managed credentials)", async () => { + const executor = new AuggieExecutor(); + const result = await executor.refreshCredentials({} as never); + assert.equal(result, null); +}); + +test("buildUrl/buildHeaders/transformRequest match the CLI-passthrough shape", () => { + const executor = new AuggieExecutor(); + assert.equal(executor.buildUrl(), "auggie://cli/stdio"); + assert.deepEqual(executor.buildHeaders(), {}); + assert.equal(executor.transformRequest(), null); +});