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 <rizkiyemubarok05@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2571
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-15 02:04:55 -03:00
parent 2c62333b0b
commit e4f9dfc2f7
7 changed files with 633 additions and 0 deletions

View File

@@ -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).

View File

@@ -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<string> => {
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 });
}
}

View File

@@ -31,6 +31,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
"/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

View File

@@ -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",

View File

@@ -201,6 +201,15 @@ const CLI_TOOLS: Record<string, any> = {
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",

View File

@@ -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;
});

View File

@@ -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);
});