From e4f9dfc2f74a6837c94a54b8c8c99783de99b23e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 15 Jul 2026 02:04:55 -0300 Subject: [PATCH] feat(cli): add Grok Build CLI tool setup (~/.grok/config.toml) Registers xAI's Grok Build TUI coding agent as a configurable CLI tool in /dashboard/cli-code, so OmniRoute can write itself in as a custom model provider in ~/.grok/config.toml. Mechanism: Grok Build reads a TOML config that can hold several user-defined [model.*] sections plus a [models].default pointer. Unlike the sibling Forge handler (which owns its whole config file and can full-replace it), this one surgically upserts ONLY the [model.omniroute] section and rewrites [models].default, leaving every other section byte-intact. Apply records the previous default in an `# omniroute-prev-default` marker comment so Reset can restore the user's original default instead of guessing. Built on OmniRoute's existing CLI-tools infrastructure rather than replaying the upstream shape: getCliRuntimeStatus() for detection (no ad-hoc `which grok` exec), Zod validation via cliModelConfigSchema, the write guard, createBackup(), the cliToolState DB module, and sanitizeErrorMessage() for every error path (Hard Rule #12). Security: GET reaches getCliRuntimeStatus(), which spawns a child process to locate and healthcheck the `grok` binary. That is the same transitive-spawn surface that classified /api/skills/collect/, so the route is registered in LOCAL_ONLY_API_PREFIXES and loopback-enforced before any auth check (Hard Rules #15 + #17). Writing a local CLI's config file is inherently a local-machine operation, so this costs no real capability. Co-authored-by: rixzkiye Inspired-by: https://github.com/decolua/9router/pull/2571 --- docs/reference/CLI-TOOLS.md | 2 + .../cli-tools/grok-build-settings/route.ts | 300 ++++++++++++++++++ src/server/authz/routeGuard.ts | 1 + src/shared/constants/cliTools.ts | 16 + src/shared/services/cliRuntime.ts | 9 + .../cli-settings-grok-build.test.ts | 266 ++++++++++++++++ ...ard-grok-build-settings-local-only.test.ts | 39 +++ 7 files changed, 633 insertions(+) create mode 100644 src/app/api/cli-tools/grok-build-settings/route.ts create mode 100644 tests/integration/cli-settings-grok-build.test.ts create mode 100644 tests/unit/route-guard-grok-build-settings-local-only.test.ts 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..172a9597f5 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -584,6 +584,22 @@ aider --openai-api-base "{{baseUrl}}" --model "{{model}}"`, defaultCommand: "forge", }, + // ── Code entries — grok-build ───────────────────────────────────────────── + "grok-build": { + 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", + }, + // ── Code entries — cursor-cli ───────────────────────────────────────────── "cursor-cli": { id: "cursor-cli", diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index c7e778a9a0..84fd0d34c8 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -201,6 +201,15 @@ const CLI_TOOLS: Record = { config: ".jcode/config.json", }, }, + "grok-build": { + defaultCommand: "grok", + envBinKey: "CLI_GROK_BUILD_BIN", + requiresBinary: true, + healthcheckTimeoutMs: 8000, + paths: { + config: ".grok/config.toml", + }, + }, "deepseek-tui": { defaultCommand: "deepseek-tui", envBinKey: "CLI_DEEPSEEK_TUI_BIN", 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/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); +});