mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(cli): add Grok Build CLI tool setup (~/.grok/config.toml) (#7241)
* 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 * chore(changelog): fragment for #7241 * fix(cli): shrink cliTools.ts/cliRuntime.ts under the file-size ratchet + fix stale catalog counts The grok-build registry/runtime entries pushed cliTools.ts (916->932) and cliRuntime.ts (1128->1137) past their frozen file-size caps. Extract the grok-build entries into cliToolsGrokBuild.ts (registry, typed) and cliRuntimeGrokBuild.ts (runtime metadata, deliberately untyped/no cliCatalog import so it doesn't drag that schema file into the typecheck:core curated allowlist's transitive graph). The amp runtime entry rides along in the same runtime file for the extra headroom needed to clear cliRuntime.ts's cap with zero slack. Also update the two catalog-cardinality canaries (cli-tools-schema.test.ts, cli-catalog-counts.test.ts) and EXPECTED_CODE_COUNT to include grok-build: 20->21 visible code entries, 24->25 total code entries, 32->33 grand total. Fixes CI reds on #7241 surviving a release/v3.8.49 merge: Fast Quality Gates (check:file-size) and Unit Tests fast-path (1/4, 2/4). * test(stryker): register grok-build route-guard test in tap.testFiles check:mutation-test-coverage --strict flagged tests/unit/route-guard-grok-build-settings-local-only.test.ts as a covering unit test for src/server/authz/routeGuard.ts missing from stryker.conf.json's tap.testFiles allowlist (only became reachable once the Fast Quality Gates job got past the file-size fix earlier in this branch). --------- Co-authored-by: rixzkiye <rizkiyemubarok05@gmail.com>
This commit is contained in:
committed by
GitHub
parent
3cd04afe62
commit
624aba2498
1
changelog.d/features/7241-grok-build-cli-setup.md
Normal file
1
changelog.d/features/7241-grok-build-cli-setup.md
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, CliCatalogEntry> = {
|
||||
acpSpawnable: false,
|
||||
baseUrlSupport: "full",
|
||||
},
|
||||
|
||||
// ── Code entries — aider ──────────────────────────────────────────────────
|
||||
aider: {
|
||||
id: "aider",
|
||||
@@ -567,7 +567,6 @@ export const CLI_TOOLS: Record<string, CliCatalogEntry> = {
|
||||
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",
|
||||
|
||||
19
src/shared/constants/cliToolsGrokBuild.ts
Normal file
19
src/shared/constants/cliToolsGrokBuild.ts
Normal file
@@ -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",
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, any> = {
|
||||
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<string, any> = {
|
||||
config: ".jcode/config.json",
|
||||
},
|
||||
},
|
||||
"grok-build": GROK_BUILD_RUNTIME_ENTRY,
|
||||
"deepseek-tui": {
|
||||
defaultCommand: "deepseek-tui",
|
||||
envBinKey: "CLI_DEEPSEEK_TUI_BIN",
|
||||
|
||||
26
src/shared/services/cliRuntimeGrokBuild.ts
Normal file
26
src/shared/services/cliRuntimeGrokBuild.ts
Normal file
@@ -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<string, any>` 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: {},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
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;
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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