mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
feat(providers): add Augment (Auggie CLI) local provider (#5972)
* 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 <m> --`) 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>
This commit is contained in:
committed by
GitHub
parent
ef7b4febee
commit
196375b8a9
@@ -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
|
||||
|
||||
|
||||
@@ -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<string, RegistryEntry> = {
|
||||
"bailian-coding-plan": bailian_coding_planProvider,
|
||||
gigachat: gigachatProvider,
|
||||
"devin-cli": devin_cliProvider,
|
||||
auggie: auggieProvider,
|
||||
chutes: chutesProvider,
|
||||
factory: factoryProvider,
|
||||
databricks: databricksProvider,
|
||||
|
||||
38
open-sse/config/providers/registry/auggie/index.ts
Normal file
38
open-sse/config/providers/registry/auggie/index.ts
Normal file
@@ -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 },
|
||||
],
|
||||
};
|
||||
569
open-sse/executors/auggie.ts
Normal file
569
open-sse/executors/auggie.ts
Normal file
@@ -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 <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<string> = 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<string, unknown>).type === "text") {
|
||||
text += String((p as Record<string, unknown>).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<AuggieCliVersionCheck> {
|
||||
const bin = resolveAuggieBin();
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (result: AuggieCliVersionCheck) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
let child: ReturnType<typeof spawn>;
|
||||
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<string, string> {
|
||||
return {};
|
||||
}
|
||||
|
||||
transformRequest(): unknown {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** No-op — auggie has no OmniRoute-managed credentials to refresh. */
|
||||
async refreshCredentials(
|
||||
_credentials: ProviderCredentials
|
||||
): Promise<Partial<ProviderCredentials> | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async execute({
|
||||
model,
|
||||
body,
|
||||
stream,
|
||||
signal,
|
||||
log,
|
||||
}: ExecuteInput): Promise<{
|
||||
response: Response;
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
transformedBody: unknown;
|
||||
}> {
|
||||
const b = (body ?? {}) as Record<string, unknown>;
|
||||
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<Uint8Array>({
|
||||
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<typeof spawn>;
|
||||
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<Response> {
|
||||
return new Promise((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
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<Uint8Array>({
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -289,6 +289,29 @@
|
||||
"stream": "https://api.airforce/v1/chat/completions"
|
||||
}
|
||||
},
|
||||
"auggie": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"nonStream": "auggie://cli/stdio",
|
||||
"stream": "auggie://cli/stdio"
|
||||
}
|
||||
},
|
||||
"baichuan": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
|
||||
380
tests/unit/auggie-executor.test.ts
Normal file
380
tests/unit/auggie-executor.test.ts
Normal file
@@ -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<Record<string, unknown>[]> {
|
||||
const text = await response.text();
|
||||
const events: Record<string, unknown>[] = [];
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user