diff --git a/changelog.d/features/7241-grok-build-cli-setup.md b/changelog.d/features/7241-grok-build-cli-setup.md new file mode 100644 index 0000000000..1e8c6bee5a --- /dev/null +++ b/changelog.d/features/7241-grok-build-cli-setup.md @@ -0,0 +1 @@ +- **feat(cli):** add Grok Build CLI tool setup — writes a `[model.omniroute]` custom model into `~/.grok/config.toml` and restores your previous default on Reset. (thanks @rixzkiye) diff --git a/docs/reference/CLI-TOOLS.md b/docs/reference/CLI-TOOLS.md index ef38bbeb82..6624d8ccff 100644 --- a/docs/reference/CLI-TOOLS.md +++ b/docs/reference/CLI-TOOLS.md @@ -114,6 +114,7 @@ Tools that support custom base URL and appear in `/dashboard/cli-code`: | cursor-cli | Cursor CLI | Anysphere | partial | guide | true | | smelt | Smelt | leonardcser (OSS) | full | custom | false | | pi | Pi (pi-coding-agent) | M. Zechner (OSS) | full | custom | false | +| grok-build | Grok Build | xAI | full | custom | false | | custom | Custom CLI | — | full | custom-builder | false | Tools with `baseUrlSupport: "partial"` show a badge "⚠ Base URL parcial" in the dashboard card. @@ -203,6 +204,7 @@ New tools with `configType: "custom"` have dedicated settings API routes: | `POST /api/cli-tools/codewhale-settings` | CodeWhale (OPENAI_BASE_URL, primary + legacy `~/.deepseek` sync) | | `POST /api/cli-tools/smelt-settings` | Smelt | | `POST /api/cli-tools/pi-settings` | Pi coding agent | +| `POST /api/cli-tools/grok-build-settings` | Grok Build (~/.grok/config.toml, `[model.omniroute]`) | All routes use `sanitizeErrorMessage()` for error responses (Hard Rule #12). diff --git a/src/app/api/cli-tools/grok-build-settings/route.ts b/src/app/api/cli-tools/grok-build-settings/route.ts new file mode 100644 index 0000000000..23fe3db042 --- /dev/null +++ b/src/app/api/cli-tools/grok-build-settings/route.ts @@ -0,0 +1,300 @@ +"use server"; + +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; +import { + ensureCliConfigWriteAllowed, + getCliPrimaryConfigPath, + getCliRuntimeStatus, +} from "@/shared/services/cliRuntime"; +import { createBackup } from "@/shared/services/backupService"; +import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState"; +import { cliModelConfigSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { resolveApiKey } from "@/shared/services/apiKeyResolver"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +const TOOL_ID = "grok-build"; +const MODEL_SLOT = "omniroute"; +// Grok Build ships with a built-in default model id; restored on Reset when no +// prior custom default was recorded. +const BUILTIN_DEFAULT_MODEL = "grok-build"; + +const getGrokBuildConfigPath = (): string => + getCliPrimaryConfigPath(TOOL_ID) ?? path.join(process.env.HOME ?? "~", ".grok", "config.toml"); + +const getGrokBuildDir = () => path.dirname(getGrokBuildConfigPath()); + +// [model.omniroute] ... until the next [section] header or EOF +const MODEL_SECTION_RE = new RegExp( + `^\\[model\\.${MODEL_SLOT}\\][ \\t]*\\r?\\n(?:(?!\\[)[^\\r\\n]*\\r?\\n?)*`, + "m" +); +const MODELS_SECTION_RE = /^\[models\][ \t]*\r?\n((?:(?!\[)[^\r\n]*\r?\n?)*)/m; +// Marker written on Apply so Reset can restore the previously configured default. +const PREV_DEFAULT_RE = /^# omniroute-prev-default = "([^"]*)"[ \t]*\r?\n?/m; + +const getTomlField = (body: string, key: string): string | null => { + const m = body.match(new RegExp(`^[ \\t]*${key}[ \\t]*=[ \\t]*"([^"]*)"`, "m")); + return m ? m[1] : null; +}; + +type GrokModelSection = { + model: string | null; + base_url: string | null; + name: string | null; + api_key: string | null; + api_backend: string | null; +}; + +/** + * Parse the `~/.grok/config.toml` produced by the Grok Build CLI (a subset of + * TOML — flat `key = "value"` pairs inside `[section]` headers). Grok Build's + * config format is not guaranteed to be quote-escaped or nested, so this reads + * only the flat string fields OmniRoute itself writes. + */ +const parseModelSection = (toml: string): GrokModelSection | null => { + const match = toml.match(MODEL_SECTION_RE); + if (!match) return null; + const body = match[0].replace(/^\[model\.[^\]]+\][ \t]*\r?\n/, ""); + return { + model: getTomlField(body, "model"), + base_url: getTomlField(body, "base_url"), + name: getTomlField(body, "name"), + api_key: getTomlField(body, "api_key"), + api_backend: getTomlField(body, "api_backend"), + }; +}; + +const parseModelsDefault = (toml: string): string | null => { + const match = toml.match(MODELS_SECTION_RE); + if (!match) return null; + return getTomlField(match[1] || "", "default"); +}; + +const escapeTomlString = (value: string): string => value.replace(/["\\]/g, "\\$&"); + +const buildModelSection = (model: string, baseUrl: string, apiKey: string): string => { + const lines = [ + `[model.${MODEL_SLOT}]`, + `model = "${escapeTomlString(model)}"`, + `base_url = "${escapeTomlString(baseUrl)}"`, + `name = "OmniRoute"`, + `description = "Routed via OmniRoute gateway"`, + `api_backend = "chat_completions"`, + ]; + if (apiKey) lines.push(`api_key = "${escapeTomlString(apiKey)}"`); + return `${lines.join("\n")}\n`; +}; + +/** Insert/replace the `[model.omniroute]` section, preserving the rest of the file. */ +const upsertModelSection = (toml: string, section: string): string => { + if (MODEL_SECTION_RE.test(toml)) return toml.replace(MODEL_SECTION_RE, section); + const needsNl = toml.length > 0 && !toml.endsWith("\n"); + return `${toml}${needsNl ? "\n" : ""}\n${section}`; +}; + +const removeModelSection = (toml: string): string => + toml.replace(MODEL_SECTION_RE, "").replace(/\n{3,}/g, "\n\n"); + +/** Set or insert `default = "..."` inside an existing `[models]`, or create the section. */ +const setModelsDefault = (toml: string, value: string): string => { + const match = toml.match(MODELS_SECTION_RE); + if (match) { + const body = match[1] || ""; + const newBody = /^[ \t]*default[ \t]*=/m.test(body) + ? body.replace(/^[ \t]*default[ \t]*=[ \t]*"[^"]*"/m, `default = "${value}"`) + : `default = "${value}"\n${body}`; + return toml.replace(match[0], `[models]\n${newBody}`); + } + const block = `[models]\ndefault = "${value}"\n\n`; + return toml.length > 0 ? block + toml : block; +}; + +/** Remember the previous default once so re-Apply never clobbers it with our own slot. */ +const rememberPrevDefault = (toml: string): string => { + if (PREV_DEFAULT_RE.test(toml)) return toml; + const current = parseModelsDefault(toml); + if (!current || current === MODEL_SLOT) return toml; + const marker = `# omniroute-prev-default = "${current}"\n`; + if (MODEL_SECTION_RE.test(toml)) { + return toml.replace(MODEL_SECTION_RE, (section) => marker + section); + } + const needsNl = toml.length > 0 && !toml.endsWith("\n"); + return `${toml}${needsNl ? "\n" : ""}${marker}`; +}; + +/** If `[models].default` still points at our slot, restore the remembered default. */ +const clearModelsDefaultIfOurs = (toml: string): string => { + const prevMatch = toml.match(PREV_DEFAULT_RE); + const restoreTo = prevMatch?.[1] || BUILTIN_DEFAULT_MODEL; + let next = toml.replace(PREV_DEFAULT_RE, ""); + const current = parseModelsDefault(next); + if (current === MODEL_SLOT) { + next = setModelsDefault(next, restoreTo); + } + return next; +}; + +const hasOmniRouteConfig = (modelCfg: GrokModelSection | null): boolean => + Boolean(modelCfg?.base_url); + +// Read current config.toml +const readConfigToml = async (): Promise => { + try { + return await fs.readFile(getGrokBuildConfigPath(), "utf-8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw err; + } +}; + +// GET — check Grok Build CLI and return current [model.omniroute] config +export async function GET(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + + try { + const runtime = await getCliRuntimeStatus(TOOL_ID); + + if (!runtime.installed || !runtime.runnable) { + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + config: null, + message: + runtime.installed && !runtime.runnable + ? "Grok Build is installed but not runnable" + : "Grok Build is not installed", + }); + } + + const toml = await readConfigToml(); + const model = parseModelSection(toml); + const defaultModel = parseModelsDefault(toml); + + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + config: { model, default: defaultModel }, + hasOmniRoute: hasOmniRouteConfig(model), + configPath: getGrokBuildConfigPath(), + }); + } catch (err) { + return NextResponse.json({ error: { message: sanitizeErrorMessage(err) } }, { status: 500 }); + } +} + +// POST — write the [model.omniroute] section into ~/.grok/config.toml and set it default +export async function POST(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); + } + + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + // Extract keyId BEFORE Zod validation — Zod strips unknown fields + const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; + + const validation = validateBody(cliModelConfigSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const { baseUrl, model } = validation.data; + const apiKey = await resolveApiKey(keyId, validation.data.apiKey); + + const configPath = getGrokBuildConfigPath(); + const grokDir = getGrokBuildDir(); + + await fs.mkdir(grokDir, { recursive: true }); + await createBackup(TOOL_ID, configPath); + + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + + let toml = await readConfigToml(); + toml = rememberPrevDefault(toml); + toml = upsertModelSection(toml, buildModelSection(model, normalizedBaseUrl, apiKey || "")); + toml = setModelsDefault(toml, MODEL_SLOT); + + await fs.writeFile(configPath, toml, "utf-8"); + + try { + saveCliToolLastConfigured(TOOL_ID); + } catch { + /* non-critical */ + } + + return NextResponse.json({ + success: true, + message: "Grok Build settings applied successfully!", + configPath, + modelSlot: MODEL_SLOT, + }); + } catch (err) { + return NextResponse.json({ error: { message: sanitizeErrorMessage(err) } }, { status: 500 }); + } +} + +// DELETE — remove the [model.omniroute] section and restore the previous default +export async function DELETE(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + const configPath = getGrokBuildConfigPath(); + + let toml: string; + try { + toml = await fs.readFile(configPath, "utf-8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return NextResponse.json({ success: true, message: "No config file to reset" }); + } + throw err; + } + + await createBackup(TOOL_ID, configPath); + + toml = removeModelSection(toml); + toml = clearModelsDefaultIfOurs(toml); + await fs.writeFile(configPath, toml, "utf-8"); + + try { + deleteCliToolLastConfigured(TOOL_ID); + } catch { + /* non-critical */ + } + + return NextResponse.json({ + success: true, + message: "OmniRoute model slot removed from Grok Build", + }); + } catch (err) { + return NextResponse.json({ error: { message: sanitizeErrorMessage(err) } }, { status: 500 }); + } +} diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index d9479e1e89..ff2ec2c7e4 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -31,6 +31,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/cli-tools/runtime/", "/api/cli-tools/omp-settings", // spawns `which omp` to detect the CLI install (Hard Rules #15 + #17, #6318) "/api/cli-tools/letta-settings", // spawns `which letta` to detect the CLI install (Hard Rules #15 + #17, #6318) + "/api/cli-tools/grok-build-settings", // GET calls getCliRuntimeStatus("grok-build"), which spawns a child process to locate + healthcheck the `grok` binary — same transitive-spawn surface that classified /api/skills/collect/ (Hard Rules #15 + #17). Writing ~/.grok/config.toml is inherently a local-machine operation, so loopback-only costs no real capability. "/api/services/", // T-10: embedded service lifecycle (spawn child processes) "/dashboard/providers/services/", // T-07: reverse proxy to embedded service UIs "/api/copilot/", // unauthenticated LLM driver — CLI-only by default; admins can opt-in to remote access via manage-scope bypass diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index 178e6ae156..644c8999d2 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -1,6 +1,7 @@ // CLI Tools configuration import { getClaudeCodeDefaultModels } from "@omniroute/open-sse/config/providerRegistry"; import type { CliCatalogEntry } from "@/shared/schemas/cliCatalog"; +import { GROK_BUILD_CLI_TOOL } from "@/shared/constants/cliToolsGrokBuild"; const _cc = getClaudeCodeDefaultModels(); @@ -540,7 +541,6 @@ export const CLI_TOOLS: Record = { acpSpawnable: false, baseUrlSupport: "full", }, - // ── Code entries — aider ────────────────────────────────────────────────── aider: { id: "aider", @@ -567,7 +567,6 @@ export const CLI_TOOLS: Record = { aider --openai-api-base "{{baseUrl}}" --model "{{model}}"`, }, }, - // ── Code entries — forge ────────────────────────────────────────────────── forge: { id: "forge", @@ -584,6 +583,7 @@ aider --openai-api-base "{{baseUrl}}" --model "{{model}}"`, defaultCommand: "forge", }, + "grok-build": GROK_BUILD_CLI_TOOL, // ── Code entries — cursor-cli ───────────────────────────────────────────── "cursor-cli": { id: "cursor-cli", diff --git a/src/shared/constants/cliToolsGrokBuild.ts b/src/shared/constants/cliToolsGrokBuild.ts new file mode 100644 index 0000000000..e766295892 --- /dev/null +++ b/src/shared/constants/cliToolsGrokBuild.ts @@ -0,0 +1,19 @@ +// Grok Build CLI tool registry entry — extracted from cliTools.ts to keep the +// frozen registry file under its file-size ratchet cap (config/quality/file-size-baseline.json). +import type { CliCatalogEntry } from "@/shared/schemas/cliCatalog"; + +/** xAI Grok Build TUI coding agent — custom provider via ~/.grok/config.toml */ +export const GROK_BUILD_CLI_TOOL: CliCatalogEntry = { + id: "grok-build", + name: "Grok Build", + icon: "terminal", + color: "#1DA1F2", + description: "xAI Grok Build TUI coding agent — custom provider via ~/.grok/config.toml", + docsUrl: "https://x.ai/cli", + configType: "custom", + category: "code", + vendor: "xAI", + acpSpawnable: false, + baseUrlSupport: "full", + defaultCommand: "grok", +}; diff --git a/src/shared/schemas/cliCatalog.ts b/src/shared/schemas/cliCatalog.ts index d5b43a55bc..27ab19e3c9 100644 --- a/src/shared/schemas/cliCatalog.ts +++ b/src/shared/schemas/cliCatalog.ts @@ -62,7 +62,8 @@ export const CliCatalogSchema = z.record(CliCatalogEntrySchema); /** Cardinalidade obrigatória (Plano §3.1/§3.2 + D15). +1 (crush, decolua/9router#1233). */ // +1 (2026-07-02): "codewhale" added as a dual entry alongside "deepseek-tui" // (CodeWhale is the actively-maintained successor to DeepSeek TUI). -export const EXPECTED_CODE_COUNT = 20; +// +1 (grok-build, decolua/9router#2571): xAI Grok Build TUI coding agent. +export const EXPECTED_CODE_COUNT = 21; // +2 (#6318): "omp" (Oh My Pi) and "letta" (Letta CLI) added as agent entries. // Note: #6318 originally also shipped duplicate "pi"/"jcode"/"codewhale" entries — // those tools were already delivered by a separate PR, so only omp+letta landed here. diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index e6c8e437fc..371076b0c6 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -6,6 +6,7 @@ import { spawn, execFileSync } from "child_process"; import { getHermesHome } from "@/lib/cli-helper/config-generator/hermesHome"; import { getCachedLoginShellPath, mergeShellPath } from "./loginShellPath"; import { withSettingsFallback } from "./cliInstallFallback"; +import { GROK_BUILD_RUNTIME_ENTRY, AMP_RUNTIME_ENTRY } from "./cliRuntimeGrokBuild"; const VALID_RUNTIME_MODES = new Set(["auto", "host", "container"]); const FALSE_VALUES = new Set(["0", "false", "no", "off"]); @@ -155,13 +156,7 @@ const CLI_TOOLS: Record = { config: "config.yaml", }, }, - amp: { - defaultCommand: "amp", - envBinKey: "CLI_AMP_BIN", - requiresBinary: true, - healthcheckTimeoutMs: 12000, - paths: {}, - }, + amp: AMP_RUNTIME_ENTRY, qoder: { defaultCommand: "qodercli", envBinKey: "CLI_QODER_BIN", @@ -201,6 +196,7 @@ const CLI_TOOLS: Record = { config: ".jcode/config.json", }, }, + "grok-build": GROK_BUILD_RUNTIME_ENTRY, "deepseek-tui": { defaultCommand: "deepseek-tui", envBinKey: "CLI_DEEPSEEK_TUI_BIN", diff --git a/src/shared/services/cliRuntimeGrokBuild.ts b/src/shared/services/cliRuntimeGrokBuild.ts new file mode 100644 index 0000000000..e219d6ffce --- /dev/null +++ b/src/shared/services/cliRuntimeGrokBuild.ts @@ -0,0 +1,26 @@ +// Runtime-detection metadata entries extracted from cliRuntime.ts to keep that +// frozen file under its file-size ratchet cap (config/quality/file-size-baseline.json). +// Deliberately untyped (matches cliRuntime.ts's own `Record` CLI_TOOLS +// shape) and has NO import of the CliCatalogEntry schema — keep it that way, so this +// module never drags src/shared/schemas/cliCatalog.ts into the typecheck-core +// transitive graph (that file is not on typecheck:core's curated allowlist). + +/** Grok Build runtime-detection metadata (binary lookup + healthcheck). */ +export const GROK_BUILD_RUNTIME_ENTRY = { + defaultCommand: "grok", + envBinKey: "CLI_GROK_BUILD_BIN", + requiresBinary: true, + healthcheckTimeoutMs: 8000, + paths: { + config: ".grok/config.toml", + }, +}; + +/** Amp runtime-detection metadata (extracted alongside grok-build for file-size headroom). */ +export const AMP_RUNTIME_ENTRY = { + defaultCommand: "amp", + envBinKey: "CLI_AMP_BIN", + requiresBinary: true, + healthcheckTimeoutMs: 12000, + paths: {}, +}; diff --git a/stryker.conf.json b/stryker.conf.json index 6aac6d8ee0..d45360b9c5 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -226,6 +226,7 @@ "tests/unit/responses-handler.test.ts", "tests/unit/rotation-config-omniroute.test.ts", "tests/unit/route-explainability.test.ts", + "tests/unit/route-guard-grok-build-settings-local-only.test.ts", "tests/unit/route-guard-middleware-local-only.test.ts", "tests/unit/route-guard-plugins-local-only.test.ts", "tests/unit/route-guard-private-lan.test.ts", diff --git a/tests/integration/cli-settings-grok-build.test.ts b/tests/integration/cli-settings-grok-build.test.ts new file mode 100644 index 0000000000..68dde400ee --- /dev/null +++ b/tests/integration/cli-settings-grok-build.test.ts @@ -0,0 +1,266 @@ +/** + * Integration tests for /api/cli-tools/grok-build-settings + * + * Ported from decolua/9router#2571 ("feat(cli-tools): add Grok Build setup"), + * rebuilt on top of OmniRoute's existing "custom" configType settings pattern + * (auth guard, Zod validation, write-guard, backups, sanitized errors — see + * forge-settings for the sibling implementation this mirrors). + * + * Unlike Forge's full-file overwrite, Grok Build's config.toml can hold other + * user-defined `[model.*]` sections, so the handler surgically upserts only + * the `[model.omniroute]` section and preserves the rest of the file. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-grok-build-settings-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-api-key-secret-grok-build"; +process.env.JWT_SECRET = "test-jwt-secret-grok-build"; + +// Import DB reset helpers (must be before route import) +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); + +// Import route handlers +const { GET, POST, DELETE } = await import( + "../../src/app/api/cli-tools/grok-build-settings/route.ts" +); + +async function resetStorage() { + delete process.env.INITIAL_PASSWORD; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function enableAuth() { + process.env.INITIAL_PASSWORD = "test-bootstrap"; + await localDb.updateSettings({ requireLogin: true, password: "" }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +// ── Test 1: GET without auth when auth is required → 401 ──────────────────── + +test("grok-build-settings GET: returns 401 when auth required and no token", async () => { + await enableAuth(); + const res = await GET(new Request("http://localhost/api/cli-tools/grok-build-settings")); + assert.equal(res.status, 401, `Expected 401, got ${res.status}`); +}); + +// ── Test 2: GET with valid auth → 200 ──────────────────────────────────────── + +test("grok-build-settings GET: returns 200 with valid auth (grok not installed on CI)", async () => { + const res = await GET(new Request("http://localhost/api/cli-tools/grok-build-settings")); + assert.equal(res.status, 200, `Expected 200, got ${res.status}`); + const body = await res.json(); + assert.ok( + "installed" in body || "config" in body, + "Response should contain installed or config field" + ); +}); + +// ── Test 3: POST with invalid body → 400 ───────────────────────────────────── + +test("grok-build-settings POST: 400 when baseUrl is missing", async () => { + const res = await POST( + new Request("http://localhost/api/cli-tools/grok-build-settings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ apiKey: "sk-test", model: "grok-4.5" }), // missing baseUrl + }) + ); + assert.equal(res.status, 400, `Expected 400 for missing baseUrl, got ${res.status}`); + const body = await res.json(); + assert.ok(body.error !== undefined, "Response should have error field"); +}); + +test("grok-build-settings POST: 400 when model is missing", async () => { + const res = await POST( + new Request("http://localhost/api/cli-tools/grok-build-settings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl: "http://localhost:20128", apiKey: "sk-test" }), + }) + ); + assert.equal(res.status, 400, `Expected 400 for missing model, got ${res.status}`); +}); + +// ── Test 4: POST with valid body → surgically upserts [model.omniroute] ───── + +test("grok-build-settings POST: writes [model.omniroute] section and preserves existing content", async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "grok-build-home-")); + const origHome = process.env.HOME; + process.env.HOME = tmpHome; + + try { + // Pre-seed a config.toml with an unrelated user model + a non-default value, + // to prove the handler does not clobber content it does not own. + const grokDir = path.join(tmpHome, ".grok"); + fs.mkdirSync(grokDir, { recursive: true }); + const preExisting = [ + "[models]", + 'default = "grok-build"', + "", + "[model.custom-thing]", + 'model = "some-other-model"', + 'base_url = "https://example.test/v1"', + "", + ].join("\n"); + fs.writeFileSync(path.join(grokDir, "config.toml"), preExisting); + + const res = await POST( + new Request("http://localhost/api/cli-tools/grok-build-settings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test-grok-build-key", + model: "grok-4.5", + }), + }) + ); + + // 200 = success; 403 = write guard active (test env); 500 = backup dir issue + assert.ok([200, 403, 500].includes(res.status), `Unexpected status ${res.status}`); + + if (res.status === 200) { + const body = await res.json(); + assert.equal(body.success, true, "success should be true on 200"); + + const configPath = path.join(tmpHome, ".grok", "config.toml"); + const content = fs.readFileSync(configPath, "utf-8"); + + assert.ok(content.includes("[model.omniroute]"), "Config should have [model.omniroute]"); + assert.ok(content.includes("http://localhost:20128/v1"), "Config should contain base URL"); + assert.ok(content.includes('default = "omniroute"'), "Default should point at our slot"); + // The pre-existing unrelated model section must survive untouched. + assert.ok( + content.includes("[model.custom-thing]") && + content.includes("https://example.test/v1"), + "Pre-existing unrelated [model.*] section must be preserved" + ); + // The previous default must be remembered for Reset to restore. + assert.ok( + content.includes('omniroute-prev-default = "grok-build"'), + "Previous default should be remembered as a marker comment" + ); + } + } finally { + process.env.HOME = origHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } +}); + +// ── Test 5: DELETE → removes only our section and restores previous default ─ + +test("grok-build-settings DELETE: removes our section, preserves the rest, restores default", async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "grok-build-home-del-")); + const origHome = process.env.HOME; + process.env.HOME = tmpHome; + + try { + const grokDir = path.join(tmpHome, ".grok"); + fs.mkdirSync(grokDir, { recursive: true }); + const preConfigured = [ + "[models]", + 'default = "omniroute"', + "", + "# omniroute-prev-default = \"grok-build\"", + "[model.omniroute]", + 'model = "grok-4.5"', + 'base_url = "http://localhost:20128/v1"', + 'name = "OmniRoute"', + 'api_backend = "chat_completions"', + 'api_key = "sk-test"', + "", + "[model.custom-thing]", + 'model = "some-other-model"', + 'base_url = "https://example.test/v1"', + "", + ].join("\n"); + fs.writeFileSync(path.join(grokDir, "config.toml"), preConfigured); + + const res = await DELETE( + new Request("http://localhost/api/cli-tools/grok-build-settings", { method: "DELETE" }) + ); + assert.ok([200, 403, 500].includes(res.status), `Expected 200/403/500, got ${res.status}`); + + if (res.status === 200) { + const body = await res.json(); + assert.equal(body.success, true); + + const configPath = path.join(tmpHome, ".grok", "config.toml"); + const content = fs.readFileSync(configPath, "utf-8"); + assert.ok(!content.includes("[model.omniroute]"), "Our section should be removed"); + assert.ok( + content.includes("[model.custom-thing]") && content.includes("https://example.test/v1"), + "Unrelated section must survive" + ); + assert.ok(content.includes('default = "grok-build"'), "Previous default should be restored"); + } + } finally { + process.env.HOME = origHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } +}); + +test("grok-build-settings DELETE: no-op success when no config file exists", async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "grok-build-home-noconfig-")); + const origHome = process.env.HOME; + process.env.HOME = tmpHome; + + try { + const res = await DELETE( + new Request("http://localhost/api/cli-tools/grok-build-settings", { method: "DELETE" }) + ); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.success, true); + } finally { + process.env.HOME = origHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } +}); + +// ── Test 6: Error sanitization (Hard Rule #12) ─────────────────────────────── + +test("grok-build-settings: error responses do not leak stack traces", async () => { + const badReq = new Request("http://localhost/api/cli-tools/grok-build-settings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{ this is not json }", + }); + const res = await POST(badReq); + const bodyStr = JSON.stringify(await res.json()); + assert.ok( + !bodyStr.match(/\s+at\s+\/[^\s]/), + "Error response must not contain absolute-path stack traces" + ); +}); + +// ── Test 7: Hard Rule #13 (no exec/spawn) ──────────────────────────────────── + +test("grok-build-settings route.ts: does not call exec() or spawn() directly", () => { + const routePath = path.resolve( + import.meta.dirname, + "../../src/app/api/cli-tools/grok-build-settings/route.ts" + ); + const content = fs.readFileSync(routePath, "utf-8"); + assert.ok(!content.match(/\bexec\s*\(/), "Handler must not use exec()"); + assert.ok(!content.match(/\bspawn\s*\(/), "Handler must not use spawn()"); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + delete process.env.DATA_DIR; + delete process.env.API_KEY_SECRET; + delete process.env.JWT_SECRET; +}); diff --git a/tests/unit/cli-catalog-counts.test.ts b/tests/unit/cli-catalog-counts.test.ts index 681993eccb..c6f203d928 100644 --- a/tests/unit/cli-catalog-counts.test.ts +++ b/tests/unit/cli-catalog-counts.test.ts @@ -30,7 +30,7 @@ test(`CLI_TOOLS has exactly ${EXPECTED_AGENT_COUNT} agent entries`, () => { ); }); -test("CLI_TOOLS total code entries (including none) equals 24 (20 visible + 4 none)", () => { +test("CLI_TOOLS total code entries (including none) equals 25 (21 visible + 4 none)", () => { // code-none entries: antigravity, kiro, cursor (app), hermes (simple guide) const codeNone = codeAll.filter((t) => t.baseUrlSupport === "none"); assert.equal( @@ -38,11 +38,11 @@ test("CLI_TOOLS total code entries (including none) equals 24 (20 visible + 4 no 4, `Expected 4 code entries with baseUrlSupport='none', got ${codeNone.length}: ${codeNone.map((t) => t.id).join(", ")}` ); - assert.equal(codeAll.length, 24, `Expected 24 total code entries, got ${codeAll.length}`); + assert.equal(codeAll.length, 25, `Expected 25 total code entries, got ${codeAll.length}`); }); -test("CLI_TOOLS total (code + agent) = 32", () => { - assert.equal(all.length, 32, `Expected 32 total entries, got ${all.length}`); +test("CLI_TOOLS total (code + agent) = 33", () => { + assert.equal(all.length, 33, `Expected 33 total entries, got ${all.length}`); }); test("All code-none entries have configType mitm OR are legacy excluded entries", () => { @@ -66,7 +66,7 @@ test("All agent entries have baseUrlSupport 'full' or 'partial' (no agent is 'no } }); -test("The 20 visible code entries match D15 list exactly (+ crush + codewhale)", () => { +test("The 21 visible code entries match D15 list exactly (+ crush + codewhale + grok-build)", () => { const d15List = new Set([ "claude", "codex", @@ -88,6 +88,7 @@ test("The 20 visible code entries match D15 list exactly (+ crush + codewhale)", "pi", "custom", "crush", + "grok-build", ]); const visibleIds = new Set(codeVisible.map((t) => t.id)); for (const id of d15List) { diff --git a/tests/unit/cli-tools-schema.test.ts b/tests/unit/cli-tools-schema.test.ts index 4648f448ee..85841732cb 100644 --- a/tests/unit/cli-tools-schema.test.ts +++ b/tests/unit/cli-tools-schema.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -test("CLI_TOOLS registry contains all expected tools (plan 14 — 32 total + crush + codewhale + omp + letta)", async () => { +test("CLI_TOOLS registry contains all expected tools (plan 14 — 33 total + crush + codewhale + omp + letta + grok-build)", async () => { const { CLI_TOOLS } = await import("../../src/shared/constants/cliTools.ts"); // windsurf and amp removed per plan 14 D17 (MITM backlog plan 11) // New entries added: roo, jcode, deepseek-tui, smelt, pi, aider, forge, @@ -10,6 +10,7 @@ test("CLI_TOOLS registry contains all expected tools (plan 14 — 32 total + cru // codewhale added 2026-07-02 as a dual entry alongside deepseek-tui // (CodeWhale is the actively-maintained successor to DeepSeek TUI). // omp + letta added by #6318 (agent-category CLI integrations). + // grok-build added — xAI Grok Build TUI coding agent (ported from upstream decolua/9router#2571). const expected = [ "claude", "codex", @@ -43,6 +44,7 @@ test("CLI_TOOLS registry contains all expected tools (plan 14 — 32 total + cru "letta", "agent-deck", "crush", + "grok-build", ]; for (const id of expected) { assert.ok(id in CLI_TOOLS, `Missing tool: ${id}`); diff --git a/tests/unit/route-guard-grok-build-settings-local-only.test.ts b/tests/unit/route-guard-grok-build-settings-local-only.test.ts new file mode 100644 index 0000000000..b02348f151 --- /dev/null +++ b/tests/unit/route-guard-grok-build-settings-local-only.test.ts @@ -0,0 +1,39 @@ +/** + * Security regression: /api/cli-tools/grok-build-settings must be classified as + * LOCAL_ONLY so loopback enforcement runs unconditionally before any auth check. + * + * GET calls getCliRuntimeStatus("grok-build"), which spawns a child process to + * locate and healthcheck the `grok` binary (src/shared/services/cliRuntime.ts). + * That is the same transitive-spawn surface that got /api/skills/collect/ + * classified, and the same class as the already-gated omp-settings / + * letta-settings routes (which spawn `which omp` / `which letta`). + * + * Classifying it LOCAL_ONLY closes the remote-RCE vector: a leaked JWT over a + * Cloudflared/Ngrok tunnel cannot trigger process spawning. + * Hard Rules #15 + #17. See docs/security/ROUTE_GUARD_TIERS.md. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts"; + +test("/api/cli-tools/grok-build-settings is LOCAL_ONLY (spawns via getCliRuntimeStatus)", () => { + assert.equal(isLocalOnlyPath("/api/cli-tools/grok-build-settings"), true); +}); + +test("/api/cli-tools/grok-build-settings with trailing slash is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/cli-tools/grok-build-settings/"), true); +}); + +test("sibling cli-tools spawn-capable settings routes stay LOCAL_ONLY", () => { + // Guards against a refactor dropping the established precedent this entry follows. + assert.equal(isLocalOnlyPath("/api/cli-tools/omp-settings"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/letta-settings"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/runtime/grok-build"), true); +}); + +test("non-spawning cli-tools routes are NOT over-gated by this entry", () => { + // The new prefix must not accidentally widen to the whole /api/cli-tools/ subtree, + // which remote dashboards legitimately use. + assert.equal(isLocalOnlyPath("/api/cli-tools/all-statuses"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/keys"), false); +});