mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 12:42:21 +03:00
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:
@@ -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).
|
||||
|
||||
|
||||
300
src/app/api/cli-tools/grok-build-settings/route.ts
Normal file
300
src/app/api/cli-tools/grok-build-settings/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
266
tests/integration/cli-settings-grok-build.test.ts
Normal file
266
tests/integration/cli-settings-grok-build.test.ts
Normal 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;
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user