diff --git a/docs/models.md b/docs/models.md index 791c33a..0fb43de 100644 --- a/docs/models.md +++ b/docs/models.md @@ -1,6 +1,6 @@ --- title: "Models" -description: "Choose Codex, Claude, or Pi for process and revalidate runs, and compare models under the same workload." +description: "Choose Codex, Claude, Pi, or Grok for process and revalidate runs, and compare models under the same workload." --- deepsec talks to LLMs through interchangeable agent backends: @@ -9,7 +9,8 @@ deepsec talks to LLMs through interchangeable agent backends: |-----------------------------|-----------------------|------------------------------| | `codex` (default) | `gpt-5.5` | `process`, `revalidate` | | `claude` | `claude-opus-4-8` | `process`, `revalidate` | -| `pi` | `zai/glm-5.2` | `process`, `revalidate` | +| `pi` | `zai/glm-5.2` | `process`, `revalidate` | +| `grok` | `grok-4.6` | `process`, `revalidate` | | `claude` (triage) | `claude-sonnet-4-6` | `triage` (Claude-only) | Interactive one-shot setup recommends five benchmark-backed combinations: @@ -40,7 +41,8 @@ npx deepsec init --yes --model-profile value --output jsonl ``` Direct OpenAI and Anthropic credentials automatically restrict profiles to a -compatible Codex or Claude harness; custom routes restrict them to Pi. +compatible Codex or Claude harness; custom routes restrict them to Pi. Grok +uses `XAI_API_KEY` or a prior `grok login` rather than a gateway route. The built-in backends work with Vercel AI Gateway through the linked workspace's OIDC credential. The model credential route is independent of the @@ -76,6 +78,12 @@ pnpm deepsec process --project-id my-app --agent pi # Pi with an AI SDK / AI Gateway style model id: pnpm deepsec process --project-id my-app --agent pi --model zai/glm-5.2 +# Grok Build CLI (local), default model: +pnpm deepsec process --project-id my-app --agent grok + +# Grok Build CLI, specific model: +pnpm deepsec process --project-id my-app --agent grok --model grok-4.6 + # Triage uses Claude; pass a cheaper model if you want: pnpm deepsec triage --project-id my-app --model claude-haiku-4-5 ``` @@ -105,6 +113,7 @@ The flag maps onto each backend's native dial: |----------|---------------------------------------------| | `codex` | model reasoning effort (`minimal`–`xhigh`) | | `pi` | thinking level (`minimal`–`xhigh`) | +| `grok` | `--reasoning-effort` (`minimal`–`xhigh`) | | `claude` | adaptive-thinking effort (`minimal` → `low`, `xhigh` → `max`) | It applies to the main investigation/revalidation runs only. @@ -171,6 +180,22 @@ Later `process`, `revalidate`, and Sandbox commands resolve the persisted route. Per-command `--ai-provider`, `--ai-base-url`, `--ai-api-key-env`, and repeatable `--ai-header name=value` remain available as Pi runtime overrides. +### Grok Build for local CLI runs + +Grok spawns the local [Grok Build](https://x.ai/cli) CLI (`grok`) headlessly +with the same deepsec prompt/schema as the other backends. Default model is +`grok-4.6`. Auth is `XAI_API_KEY` or a prior `grok login` (the binary must be +on `PATH`, or set `GROK_EXECUTABLE`): + +```bash +export XAI_API_KEY=xai-... +# or: grok login +pnpm deepsec process --project-id my-app --agent grok +``` + +Sandbox mode does not support `--agent grok` yet. Use a local `process` or +`revalidate` run instead. + ### `claude-sonnet-4-6` for `triage` Triage buckets findings into P0/P1/P2/skip without re-reading the code. diff --git a/packages/deepsec/src/__tests__/agent-defaults.test.ts b/packages/deepsec/src/__tests__/agent-defaults.test.ts index fdb6265..de9744a 100644 --- a/packages/deepsec/src/__tests__/agent-defaults.test.ts +++ b/packages/deepsec/src/__tests__/agent-defaults.test.ts @@ -9,6 +9,8 @@ describe("defaultModelForAgent", () => { expect(defaultModelForAgent("codex")).toBe("gpt-5.5"); expect(defaultModelForAgent("pi")).toBe("zai/glm-5.2"); expect(defaultModelForAgent("claude-agent-sdk")).toBe("claude-opus-4-8"); + expect(defaultModelForAgent("grok")).toBe("grok-4.6"); + expect(defaultModelForAgent("grok-build")).toBe("grok-4.6"); }); it("uses the model persisted for the configured harness", () => { diff --git a/packages/deepsec/src/__tests__/model-picker.test.ts b/packages/deepsec/src/__tests__/model-picker.test.ts index 9bcbdae..c5e3abc 100644 --- a/packages/deepsec/src/__tests__/model-picker.test.ts +++ b/packages/deepsec/src/__tests__/model-picker.test.ts @@ -69,7 +69,7 @@ describe("DeepSecBench model picker", () => { it("infers provider-prefixed OpenAI and Anthropic slugs before generic Pi slugs", () => { expect(inferModelHarness("openai/gpt-5.6-sol")).toBe("codex"); expect(inferModelHarness("anthropic/claude-opus-5")).toBe("claude"); - expect(inferModelHarness("xai/grok-4.5")).toBe("pi"); + expect(inferModelHarness("xai/grok-4.5")).toBe("grok"); }); it("uses the highest-scoring combo for each recommendation and normalizes price", () => { const choices = buildRecommendedModelChoices(results); diff --git a/packages/deepsec/src/__tests__/model-route.test.ts b/packages/deepsec/src/__tests__/model-route.test.ts index 12a5296..80c34ac 100644 --- a/packages/deepsec/src/__tests__/model-route.test.ts +++ b/packages/deepsec/src/__tests__/model-route.test.ts @@ -2,6 +2,26 @@ import { describe, expect, it, vi } from "vitest"; import { applyResolvedModelRoute, resolveModelRoute } from "../auth/model-route.js"; describe("resolveModelRoute", () => { + it("resolves Grok Build to an XAI route independent of the stored gateway config", async () => { + const resolved = await resolveModelRoute( + { mode: "gateway", provider: "vercel" }, + { agentType: "grok", env: { XAI_API_KEY: "xai-secret" } }, + ); + expect(resolved.route.provider).toBe("xai"); + expect(resolved.environment.XAI_API_KEY).toBe("xai-secret"); + expect(resolved.broker.host).toBe("api.x.ai"); + }); + + it("allows Grok Build without XAI_API_KEY (OAuth / grok login)", async () => { + const resolved = await resolveModelRoute( + { mode: "gateway", provider: "vercel" }, + { agentType: "grok", env: {} }, + ); + expect(resolved.route.provider).toBe("xai"); + expect(resolved.credential).toBe(""); + expect(resolved.environment.XAI_API_KEY).toBeUndefined(); + }); + it("refuses to resolve a local-subscription route to a brokered credential", async () => { await expect( resolveModelRoute({ mode: "local", provider: "local" }, { agentType: "codex", env: {} }), diff --git a/packages/deepsec/src/agent-defaults.ts b/packages/deepsec/src/agent-defaults.ts index 92bce87..6b873ae 100644 --- a/packages/deepsec/src/agent-defaults.ts +++ b/packages/deepsec/src/agent-defaults.ts @@ -14,6 +14,9 @@ export function defaultModelForAgent(agentType: string): string { return "gpt-5.5"; case "pi": return "zai/glm-5.2"; + case "grok": + case "grok-build": + return "grok-4.6"; default: return "claude-opus-4-8"; } diff --git a/packages/deepsec/src/auth/ensure-connected-workspace.ts b/packages/deepsec/src/auth/ensure-connected-workspace.ts index 2a9d24a..2d045d5 100644 --- a/packages/deepsec/src/auth/ensure-connected-workspace.ts +++ b/packages/deepsec/src/auth/ensure-connected-workspace.ts @@ -1,9 +1,10 @@ import { join } from "node:path"; import { getVercelOidcToken } from "@vercel/oidc"; import { updateEnvFile } from "../env-file.js"; -import { assertSandboxCredential } from "../preflight.js"; +import { assertAgentCredential, assertSandboxCredential } from "../preflight.js"; import { applyResolvedModelRoute, + isGrokAgent, type ModelRoute, type ModelRouteVerifier, type ResolvedModelRoute, @@ -155,6 +156,9 @@ export async function ensureConnectedWorkspace( const resolvedRoutes: ResolvedModelRoute[] = []; for (const agentType of options.agentTypes) { + if (isGrokAgent(agentType)) { + assertAgentCredential(agentType); + } const resolved = await (deps.resolveRoute ?? resolveModelRoute)(options.modelRoute, { agentType, env, @@ -175,6 +179,7 @@ export async function ensureConnectedWorkspace( ); if (!reuseModel) { for (const resolved of resolvedRoutes) { + if (resolved.route.provider === "xai" && !resolved.credential) continue; await (deps.verifyModelRoute ?? verifyModelRouteWithFetch)(resolved); } } diff --git a/packages/deepsec/src/auth/model-picker.ts b/packages/deepsec/src/auth/model-picker.ts index fc69e40..d0589ae 100644 --- a/packages/deepsec/src/auth/model-picker.ts +++ b/packages/deepsec/src/auth/model-picker.ts @@ -6,7 +6,7 @@ import type { ModelRoute } from "./model-route.js"; export const DEEPSEC_BENCHMARK_URL = "https://vercel.com/ai-gateway/leaderboards/deepsecbench/results.json"; -export type ModelHarness = "codex" | "claude" | "pi"; +export type ModelHarness = "codex" | "claude" | "pi" | "grok"; export interface BenchmarkResult { rank: number; @@ -108,7 +108,10 @@ function isBenchmarkResult(value: unknown): value is BenchmarkResult { typeof result.modelId === "string" && typeof result.score === "number" && typeof result.cost === "number" && - (result.harness === "codex" || result.harness === "claude" || result.harness === "pi") + (result.harness === "codex" || + result.harness === "claude" || + result.harness === "pi" || + result.harness === "grok") ); } @@ -136,7 +139,11 @@ function strongest(results: BenchmarkResult[], modelId: string): BenchmarkResult } function configuredModel(result: BenchmarkResult): string { - return result.harness === "pi" ? result.modelId : result.model; + if (result.harness === "pi") return result.modelId; + if (result.harness === "grok") { + return result.modelId.startsWith("xai/") ? result.modelId.slice("xai/".length) : result.model; + } + return result.model; } function thinkingLevel(reasoning: string): string | undefined { @@ -161,14 +168,21 @@ export function buildRecommendedModelChoices(results: BenchmarkResult[]): Recomm function canonicalHarness(value: string | undefined): ModelHarness | undefined { if (value === "claude-agent-sdk" || value === "claude") return "claude"; + if (value === "grok-build" || value === "grok") return "grok"; if (value === "codex" || value === "pi") return value; return undefined; } function compatibleHarness(route: ModelRoute, requested?: string): ModelHarness | undefined { - if (route.mode === "direct") return route.provider === "anthropic" ? "claude" : "codex"; + const requestedHarness = canonicalHarness(requested); + if (requestedHarness === "grok") return "grok"; + if (route.mode === "direct") { + if (route.provider === "anthropic") return "claude"; + if (route.provider === "xai" || route.provider === "grok") return "grok"; + return "codex"; + } if (route.mode === "custom") return "pi"; - return canonicalHarness(requested); + return requestedHarness; } export function parseModelProfile(value: string | undefined): ModelProfile | undefined { @@ -229,12 +243,14 @@ export async function resolveModelProfile(options: { export function inferModelHarness(slug: string): ModelHarness { if (/^(?:openai\/)?gpt-/i.test(slug)) return "codex"; if (/^(?:anthropic\/)?claude-/i.test(slug)) return "claude"; + if (/^(?:xai\/)?grok-/i.test(slug)) return "grok"; if (slug.includes("/")) return "pi"; return "pi"; } function modelForHarness(slug: string, harness: ModelHarness): string { if (harness === "codex" && slug.startsWith("openai/")) return slug.slice("openai/".length); + if (harness === "grok" && slug.startsWith("xai/")) return slug.slice("xai/".length); if (harness === "claude" && slug.startsWith("anthropic/")) { return slug.slice("anthropic/".length); } @@ -244,6 +260,7 @@ function modelForHarness(slug: string, harness: ModelHarness): string { function displayHarness(harness: ModelHarness): string { if (harness === "claude") return "Claude"; if (harness === "codex") return "Codex"; + if (harness === "grok") return "Grok Build"; return "Pi"; } @@ -261,9 +278,23 @@ export async function promptForModelSelection(options: { const benchmark = await fetchBenchmarkResults(options.fetchImpl); const requiredHarness = compatibleHarness(options.route, options.agent); const recommendations = buildRecommendedModelChoices(benchmark.results); - const choices = recommendations.filter( - (choice) => !requiredHarness || choice.agent === requiredHarness, - ); + const choices = recommendations + .map((choice) => { + if ( + requiredHarness === "grok" && + choice.agent !== "grok" && + /^(?:xai\/)?grok-/i.test(choice.modelId) + ) { + return { + ...choice, + agent: "grok" as const, + harness: "grok" as const, + configuredModel: modelForHarness(choice.modelId, "grok"), + }; + } + return choice; + }) + .filter((choice) => !requiredHarness || choice.agent === requiredHarness); const prompt = createInterface({ input: process.stdin, output: process.stdout }); try { if (choices.length < recommendations.length) { @@ -300,10 +331,12 @@ export async function promptForModelSelection(options: { let agent = requiredHarness ?? inferModelHarness(slug); if (!requiredHarness) { const harnessAnswer = ( - await prompt.question(`Agent harness (codex, claude, pi) [${agent}]: `) + await prompt.question(`Agent harness (codex, claude, pi, grok) [${agent}]: `) ).trim(); const selectedHarness = canonicalHarness(harnessAnswer || agent); - if (!selectedHarness) throw new Error("Agent harness must be codex, claude, or pi"); + if (!selectedHarness) { + throw new Error("Agent harness must be codex, claude, pi, or grok"); + } agent = selectedHarness; } const level = (await prompt.question("Thinking level [medium]: ")).trim() || "medium"; diff --git a/packages/deepsec/src/auth/model-route.ts b/packages/deepsec/src/auth/model-route.ts index 741e0c2..0d8515e 100644 --- a/packages/deepsec/src/auth/model-route.ts +++ b/packages/deepsec/src/auth/model-route.ts @@ -48,6 +48,10 @@ const DEFAULTS: Record = { env: "OPENAI_API_KEY", baseUrl: "https://api.openai.com/v1", }, + xai: { + env: "XAI_API_KEY", + baseUrl: "https://api.x.ai/v1", + }, }; function checkedUrl(value: string, label: string): URL { @@ -62,10 +66,15 @@ function checkedUrl(value: string, label: string): URL { return url; } +export function isGrokAgent(agentType: string): boolean { + return agentType === "grok" || agentType === "grok-build"; +} + export function modelRouteCompatibilityError( route: ModelRoute, agentType: string, ): string | undefined { + if (isGrokAgent(agentType)) return undefined; if (route.mode === "custom" && agentType !== "pi") { return `Custom model routes require --agent pi (received ${agentType})`; } @@ -78,6 +87,30 @@ export function modelRouteCompatibilityError( return undefined; } +function resolveGrokModelRoute(env: NodeJS.ProcessEnv): ResolvedModelRoute { + const credentialEnv = "XAI_API_KEY"; + const credential = env[credentialEnv] ?? ""; + return { + route: { + mode: "direct", + provider: "xai", + apiKeyEnv: credentialEnv, + baseUrl: "https://api.x.ai/v1", + }, + credentialEnv, + credential, + environment: credential ? { XAI_API_KEY: credential } : {}, + broker: { + host: "api.x.ai", + placeholderEnv: credentialEnv, + header: { + name: "authorization", + value: credential ? `Bearer ${credential}` : "", + }, + }, + }; +} + function assertCompatible(route: ModelRoute, agentType: string): void { const error = modelRouteCompatibilityError(route, agentType); if (error) throw new Error(error); @@ -96,6 +129,9 @@ export async function resolveModelRoute( options: ResolveModelRouteOptions, ): Promise { const env = options.env ?? process.env; + if (isGrokAgent(options.agentType)) { + return resolveGrokModelRoute(env); + } assertCompatible(route, options.agentType); if (route.mode === "local") { @@ -251,6 +287,8 @@ export async function verifyModelRouteWithFetch( route: ResolvedModelRoute, fetchImpl: typeof fetch = fetch, ): Promise { + if (route.route.provider === "xai" && !route.credential) return; + const endpoint = modelsEndpoint(route); const headers: Record = { [route.broker.header.name]: route.broker.header.value, diff --git a/packages/deepsec/src/cli.ts b/packages/deepsec/src/cli.ts index 1920dae..6ddc534 100755 --- a/packages/deepsec/src/cli.ts +++ b/packages/deepsec/src/cli.ts @@ -92,7 +92,7 @@ program .option("--scaffold-only", "Only create files; do not install, connect, scan, or process") .option("--skip-install", "Require dependencies to exist instead of running pnpm/npm install") .option("--package-manager ", "Installer to use: pnpm or npm", parsePackageManager) - .option("--agent ", "AI agent: codex, claude, or pi") + .option("--agent ", "AI agent: codex, claude, pi, or grok") .option("--model ", "Model for repository analysis and processing") .option("--model-profile ", "Benchmark profile: best, value, or budget") .option("--thinking-level ", "Reasoning effort: minimal, low, medium, high, or xhigh") @@ -187,7 +187,7 @@ program .option("--status", "Show resumable phase status without running setup") .option("--skip-install", "Require dependencies to exist instead of running pnpm/npm install") .option("--package-manager ", "Installer to use: pnpm or npm", parsePackageManager) - .option("--agent ", "AI agent: codex, claude, or pi") + .option("--agent ", "AI agent: codex, claude, pi, or grok") .option("--model ", "Model for repository analysis and processing") .option("--model-profile ", "Benchmark profile: best, value, or budget") .option("--thinking-level ", "Reasoning effort: minimal, low, medium, high, or xhigh") @@ -291,7 +291,7 @@ program .option("--run-id ", "Resume a specific processing run") .option( "--agent ", - "Agent plugin type: codex, claude, or pi (default: defaultAgent in deepsec.config.ts, else codex)", + "Agent plugin type: codex, claude, pi, or grok (default: defaultAgent in deepsec.config.ts, else codex)", ) .option( "--model ", @@ -370,7 +370,7 @@ program .option("--run-id ", "Resume a specific revalidation run") .option( "--agent ", - "Agent plugin type: codex, claude, or pi (default: defaultAgent in deepsec.config.ts, else codex)", + "Agent plugin type: codex, claude, pi, or grok (default: defaultAgent in deepsec.config.ts, else codex)", ) .option( "--model ", @@ -482,7 +482,7 @@ program .option("--require-owner", "Drop findings that have no ownership data (no assignee, no teams)") .option( "--only-agent ", - "Only export findings produced by this agent backend (e.g. codex, claude, pi)", + "Only export findings produced by this agent backend (e.g. codex, claude, pi, grok)", ) .option( "--only-marker ", diff --git a/packages/deepsec/src/commands/sandbox-process.ts b/packages/deepsec/src/commands/sandbox-process.ts index 44ae962..dff319b 100644 --- a/packages/deepsec/src/commands/sandbox-process.ts +++ b/packages/deepsec/src/commands/sandbox-process.ts @@ -148,6 +148,18 @@ export async function sandboxCommand(subcommand: string, opts: SandboxOpts) { const projectId = resolveProjectId(opts.projectId); const config = buildConfig(subcommand as SandboxSubcommand, projectId, opts); + if (config.agentType === "grok" || config.agentType === "grok-build") { + console.error( + `Sandbox mode does not support --agent grok yet.\n` + + `\n` + + ` Use a local process instead:\n` + + ` pnpm deepsec process --project-id ${projectId} --agent grok\n` + + `\n` + + ` Or pick a sandbox-capable harness: --agent codex | claude | pi`, + ); + process.exit(1); + } + if (!config.aiApiKeyEnv && !config.aiBaseUrl) { const resolved = await applyConfiguredModelRoute(config.agentType ?? "codex"); config.brokeredModelCredential = resolved?.broker; diff --git a/packages/deepsec/src/preflight.ts b/packages/deepsec/src/preflight.ts index 2f5f5e2..c1aefbc 100644 --- a/packages/deepsec/src/preflight.ts +++ b/packages/deepsec/src/preflight.ts @@ -190,11 +190,26 @@ function hasLocalPiAgent(): boolean { return existsSync(join(piHome, "auth.json")); } +function hasLocalGrokAgent(): boolean { + if (process.env.XAI_API_KEY) return true; + const homes = [process.env.GROK_HOME, join(homedir(), ".grok")].filter( + (p): p is string => typeof p === "string" && p.length > 0, + ); + for (const home of homes) { + if (existsSync(join(home, "auth.json"))) return true; + } + return false; +} + +function isGrok(agentType: string | undefined): boolean { + return agentType === "grok" || agentType === "grok-build"; +} + // Built-in backends we know how to credential-check. Agents registered // via plugins (deepsec.config.ts → plugins: [{ agents: [...] }]) handle // their own credential resolution, so we skip the check for anything // other than these. -const KNOWN_BACKENDS = new Set(["claude-agent-sdk", "codex", "pi"]); +const KNOWN_BACKENDS = new Set(["claude-agent-sdk", "codex", "pi", "grok"]); /** * Verify the orchestrator has an AI credential the chosen agent can use. @@ -274,6 +289,19 @@ export function assertAgentCredential( ); } + if (isGrok(agentType)) { + if (process.env.XAI_API_KEY) return; + if (!options.inSandbox && hasLocalGrokAgent()) return; + throw new Error( + `Missing AI credentials for --agent grok.\n` + + `\n` + + ` Option A: export XAI_API_KEY=xai-… (from https://console.x.ai)\n` + + ` Option B: run \`grok login\` once on this machine\n` + + ` Ensure the \`grok\` CLI is on PATH (or set GROK_EXECUTABLE).\n` + + ` Setup: ${SETUP_DOC_URL}`, + ); + } + if (anthropic || anthropicApi) return; if (!options.inSandbox && hasLocalClaudeAgent()) return; const displayAgent = diff --git a/packages/deepsec/src/resolve-agent-type.ts b/packages/deepsec/src/resolve-agent-type.ts index 3932a5b..3c6f6bf 100644 --- a/packages/deepsec/src/resolve-agent-type.ts +++ b/packages/deepsec/src/resolve-agent-type.ts @@ -10,5 +10,7 @@ import { getConfig } from "@deepsec/core"; */ export function resolveAgentType(provided: string | undefined): string { const resolved = provided ?? getConfig()?.defaultAgent ?? "codex"; - return resolved === "claude" ? "claude-agent-sdk" : resolved; + if (resolved === "claude") return "claude-agent-sdk"; + if (resolved === "grok-build") return "grok"; + return resolved; } diff --git a/packages/deepsec/src/setup/options.ts b/packages/deepsec/src/setup/options.ts index 3a9e146..3a3fc62 100644 --- a/packages/deepsec/src/setup/options.ts +++ b/packages/deepsec/src/setup/options.ts @@ -22,7 +22,9 @@ export function modelRouteFromCli(options: ModelRouteCliOptions): ModelRoute { ? "anthropic" : options.agent === "codex" ? "openai" - : undefined); + : options.agent === "grok" || options.agent === "grok-build" + ? "xai" + : undefined); if (!provider) throw new Error(`--model-auth ${mode} requires --ai-provider`); if (mode === "direct") { return { diff --git a/packages/processor/src/__tests__/grok-build.test.ts b/packages/processor/src/__tests__/grok-build.test.ts new file mode 100644 index 0000000..ce25e4e --- /dev/null +++ b/packages/processor/src/__tests__/grok-build.test.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildGrokEnv, makeIsolatedGrokHome, parseGrokStdout } from "../agents/grok-build.js"; +import { createDefaultAgentRegistry } from "../index.js"; + +const homes: string[] = []; + +afterEach(() => { + for (const home of homes.splice(0)) { + try { + fs.rmSync(home, { recursive: true, force: true }); + } catch { + // ignore + } + } +}); + +describe("Grok Build agent", () => { + it("registers as type 'grok' in the default agent registry", () => { + const registry = createDefaultAgentRegistry(); + expect(registry.types()).toContain("grok"); + expect(registry.get("grok")?.type).toBe("grok"); + }); + + it("buildGrokEnv allowlists only safe vars and injects GROK_HOME", () => { + const prev = { ...process.env }; + try { + process.env.PATH = "/usr/bin"; + process.env.HOME = "/Users/test"; + process.env.XAI_API_KEY = "xai-test-key"; + process.env.GITHUB_TOKEN = "should-not-leak"; + process.env.AWS_SECRET_ACCESS_KEY = "should-not-leak"; + process.env.LC_ALL = "en_US.UTF-8"; + + const env = buildGrokEnv("/tmp/fake-grok-home"); + expect(env.GROK_HOME).toBe("/tmp/fake-grok-home"); + expect(env.GROK_DISABLE_AUTOUPDATER).toBe("1"); + expect(env.XAI_API_KEY).toBe("xai-test-key"); + expect(env.PATH).toBe("/usr/bin"); + expect(env.LC_ALL).toBe("en_US.UTF-8"); + expect(env.GITHUB_TOKEN).toBeUndefined(); + expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined(); + } finally { + process.env = prev; + } + }); + + it("parseGrokStdout recovers a banner + nested JSON object", () => { + const raw = parseGrokStdout( + 'note: starting\n{"text":"{\\"ok\\":true}","usage":{"input_tokens":12},"sessionId":"abc","num_turns":1}\n', + ); + expect(raw.text).toBe('{"ok":true}'); + expect(raw.sessionId).toBe("abc"); + expect(raw.usage?.input_tokens).toBe(12); + }); + + it("makeIsolatedGrokHome creates a config and mirrors auth when available", () => { + const seed = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-grok-seed-")); + homes.push(seed); + fs.writeFileSync(path.join(seed, "auth.json"), JSON.stringify({ token: "test" }), { + mode: 0o600, + }); + const prev = process.env.GROK_HOME; + process.env.GROK_HOME = seed; + try { + const home = makeIsolatedGrokHome(); + homes.push(home); + expect(fs.existsSync(path.join(home, "config.toml"))).toBe(true); + expect(fs.existsSync(path.join(home, "auth.json"))).toBe(true); + expect(fs.existsSync(path.join(home, "skills"))).toBe(false); + } finally { + if (prev === undefined) delete process.env.GROK_HOME; + else process.env.GROK_HOME = prev; + } + }); +}); diff --git a/packages/processor/src/__tests__/registry.test.ts b/packages/processor/src/__tests__/registry.test.ts index 2f9e06c..6f24521 100644 --- a/packages/processor/src/__tests__/registry.test.ts +++ b/packages/processor/src/__tests__/registry.test.ts @@ -57,6 +57,7 @@ describe("createDefaultAgentRegistry", () => { expect(registry.get("claude-agent-sdk")).toBeDefined(); expect(registry.get("codex")).toBeDefined(); expect(registry.get("pi")).toBeDefined(); - expect(registry.types().sort()).toEqual(["claude-agent-sdk", "codex", "pi"]); + expect(registry.get("grok")).toBeDefined(); + expect(registry.types().sort()).toEqual(["claude-agent-sdk", "codex", "grok", "pi"]); }); }); diff --git a/packages/processor/src/agents/grok-build.ts b/packages/processor/src/agents/grok-build.ts new file mode 100644 index 0000000..f176120 --- /dev/null +++ b/packages/processor/src/agents/grok-build.ts @@ -0,0 +1,859 @@ +import { spawn } from "node:child_process"; +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { RefusalReport } from "@deepsec/core"; +import { + backoff, + buildInvestigateJsonRepairPrompt, + buildInvestigatePrompt, + buildRevalidateJsonRepairPrompt, + buildRevalidatePrompt, + classifyQuotaError, + formatJsonRepairFailureDebugText, + isTransientError, + jsonRepairFailureError, + MAX_ATTEMPTS, + type ParsedInvestigateResults, + parseInvestigateResults, + parseRefusalReport, + parseRevalidateVerdicts, + QuotaExhaustedError, + REFUSAL_FOLLOWUP_PROMPT, + runInvestigateFieldRepairLoop, + runRevalidateIdRepairLoop, + writeParseFailureDebug, +} from "./shared.js"; +import type { + AgentPlugin, + AgentProgress, + BatchMeta, + InvestigateOutput, + InvestigateParams, + InvestigateResult, + RevalidateOutput, + RevalidateParams, + RevalidateRawResponse, + RevalidateVerdict, + SetupTaskParams, +} from "./types.js"; + +const DEFAULT_MODEL = "grok-4.6"; +const DEFAULT_THINKING_LEVEL = "xhigh"; + +const INVESTIGATE_TOOLS = "read_file,grep,list_dir,run_terminal_cmd"; +const SETUP_TOOLS = "read_file,grep,list_dir"; +const TOOLLESS = "read_file"; + +const GROK_ENV_ALLOWLIST = new Set([ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "TERM", + "TZ", + "LANG", + "LANGUAGE", + "LC_ALL", + "LC_CTYPE", + "LC_MESSAGES", + "LC_COLLATE", + "LC_NUMERIC", + "LC_TIME", + "TMPDIR", + "TMP", + "TEMP", + "PWD", + "GROK_HOME", + "GROK_DISABLE_AUTOUPDATER", + "GROK_SANDBOX", + "RUST_LOG", + "RUST_BACKTRACE", +]); + +interface GrokJsonResult { + text?: string; + stopReason?: string; + sessionId?: string; + requestId?: string; + num_turns?: number; + total_cost_usd?: number; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + reasoning_tokens?: number; + total_tokens?: number; + }; + modelUsage?: Record; + type?: string; + message?: string; +} + +interface GrokRunResult { + resultText: string; + meta: Partial; + raw: GrokJsonResult; +} + +interface GrokRunOptions { + prompt: string; + projectRoot: string; + model: string; + maxTurns: number; + tools: string; + thinkingLevel: string; + resumeSessionId?: string; + signal?: AbortSignal; + onProgress?: (progress: AgentProgress) => void; + grokHome?: string; + keepHome?: boolean; +} + +function resolveThinkingLevel(config: Record): string { + const level = config.thinkingLevel ?? config.reasoningEffort; + if (typeof level === "string" && level.length > 0) return level; + return DEFAULT_THINKING_LEVEL; +} + +function resolveGrokBinary(): string { + if (process.env.GROK_EXECUTABLE) return process.env.GROK_EXECUTABLE; + const candidates = [ + path.join(os.homedir(), ".local", "bin", "grok"), + path.join(os.homedir(), ".grok", "bin", "grok"), + "/opt/homebrew/bin/grok", + "/usr/local/bin/grok", + ]; + for (const c of candidates) { + try { + fs.accessSync(c, fs.constants.X_OK); + return c; + } catch { + // try next + } + } + return "grok"; +} + +export function makeIsolatedGrokHome(): string { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-grok-home-")); + fs.writeFileSync( + path.join(home, "config.toml"), + ["[ui]", 'permission_mode = "dontAsk"', "", "[cli]", "auto_update = false", ""].join("\n"), + { mode: 0o600 }, + ); + + const userHomes = [process.env.GROK_HOME, path.join(os.homedir(), ".grok")].filter( + (p): p is string => typeof p === "string" && p.length > 0, + ); + + for (const userHome of userHomes) { + const auth = path.join(userHome, "auth.json"); + if (!fs.existsSync(auth)) continue; + const dst = path.join(home, "auth.json"); + try { + fs.symlinkSync(auth, dst); + } catch { + fs.copyFileSync(auth, dst); + fs.chmodSync(dst, 0o600); + } + break; + } + + return home; +} + +export function buildGrokEnv(grokHome: string): Record { + const env: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (typeof v !== "string") continue; + if (GROK_ENV_ALLOWLIST.has(k) || k.startsWith("LC_")) { + env[k] = v; + } + } + env.GROK_HOME = grokHome; + env.GROK_DISABLE_AUTOUPDATER = "1"; + for (const k of ["XAI_API_KEY", "XAI_API_BASE_URL"]) { + const v = process.env[k]; + if (typeof v === "string") env[k] = v; + } + return env; +} + +function sandboxProfile(): string { + if (process.env.DEEPSEC_INSIDE_SANDBOX === "1") return "off"; + return process.env.DEEPSEC_GROK_SANDBOX ?? "read-only"; +} + +function extractJsonObject(text: string): string | undefined { + const start = text.indexOf("{"); + if (start < 0) return undefined; + let depth = 0; + let inString = false; + let escaped = false; + for (let i = start; i < text.length; i++) { + const ch = text[i]; + if (inString) { + if (escaped) { + escaped = false; + } else if (ch === "\\") { + escaped = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) return text.slice(start, i + 1); + } + } + return undefined; +} + +export function parseGrokStdout(stdout: string): GrokJsonResult { + const trimmed = stdout.trim(); + if (!trimmed) throw new Error("Grok produced empty stdout"); + try { + return JSON.parse(trimmed) as GrokJsonResult; + } catch { + const slice = extractJsonObject(trimmed); + if (!slice) throw new Error(`Grok stdout was not JSON: ${trimmed.slice(0, 200)}`); + return JSON.parse(slice) as GrokJsonResult; + } +} + +function metaFromGrokJson(raw: GrokJsonResult): Partial { + const meta: Partial = { + agentSessionId: raw.sessionId, + numTurns: raw.num_turns, + }; + if (typeof raw.total_cost_usd === "number") { + meta.costUsd = raw.total_cost_usd; + } + if (raw.usage) { + meta.usage = { + inputTokens: raw.usage.input_tokens ?? 0, + outputTokens: raw.usage.output_tokens ?? 0, + cacheReadInputTokens: raw.usage.cache_read_input_tokens ?? 0, + cacheCreationInputTokens: raw.usage.cache_creation_input_tokens ?? 0, + }; + } + return meta; +} + +async function runGrokHeadless(opts: GrokRunOptions): Promise { + const bin = resolveGrokBinary(); + const grokHome = opts.grokHome ?? makeIsolatedGrokHome(); + const ownHome = opts.grokHome === undefined; + const sandbox = sandboxProfile(); + + const args = [ + "-p", + opts.prompt, + "--cwd", + opts.projectRoot, + "--output-format", + "json", + "--permission-mode", + "dontAsk", + "--tools", + opts.tools, + "--max-turns", + String(opts.maxTurns), + "-m", + opts.model, + "--reasoning-effort", + opts.thinkingLevel, + "--sandbox", + sandbox, + "--no-subagents", + "--disable-web-search", + "--no-memory", + "--verbatim", + "--disallowed-tools", + "search_replace,write,image_gen,image_edit,image_to_video,reference_to_video,Agent", + ]; + + if (opts.resumeSessionId) { + args.push("--resume", opts.resumeSessionId); + } + + const env = buildGrokEnv(grokHome); + let promptFile: string | undefined; + if (Buffer.byteLength(opts.prompt, "utf8") > 80_000) { + promptFile = path.join( + os.tmpdir(), + `deepsec-grok-prompt-${crypto.randomBytes(8).toString("hex")}.txt`, + ); + fs.writeFileSync(promptFile, opts.prompt, { mode: 0o600 }); + const pIdx = args.indexOf("-p"); + if (pIdx >= 0) { + args.splice(pIdx, 2, "--prompt-file", promptFile); + } + } + + opts.onProgress?.({ + type: "started", + message: `Running Grok Build (${opts.model}, effort=${opts.thinkingLevel})`, + }); + + try { + const { stdout, stderr, code } = await spawnCollect({ + bin, + args, + env, + cwd: opts.projectRoot, + signal: opts.signal, + }); + + if (code !== 0) { + const errText = (stderr || stdout || `exit ${code}`).trim(); + const quota = classifyQuotaError(errText); + if (quota) throw new QuotaExhaustedError(quota, errText); + + let structuredMsg: string | undefined; + try { + const errObj = parseGrokStdout(stdout); + if (errObj.type === "error" || errObj.message) { + structuredMsg = errObj.message ?? errText; + } + } catch { + // ignore + } + const msg = structuredMsg ?? errText; + const q = classifyQuotaError(msg); + if (q) throw new QuotaExhaustedError(q, msg); + throw new Error(`Grok exited ${code}: ${msg.slice(0, 500)}`); + } + + const raw = parseGrokStdout(stdout); + if (raw.type === "error") { + const msg = raw.message ?? "unknown Grok error"; + const q = classifyQuotaError(msg); + if (q) throw new QuotaExhaustedError(q, msg); + throw new Error(`Grok error: ${msg}`); + } + + const resultText = String(raw.text ?? "").trim(); + if (!resultText) { + throw new Error( + `Grok produced no result text (stopReason=${raw.stopReason ?? "?"}, turns=${raw.num_turns ?? "?"}).`, + ); + } + + return { + resultText, + meta: metaFromGrokJson(raw), + raw, + }; + } finally { + if (promptFile) { + try { + fs.unlinkSync(promptFile); + } catch { + // ignore + } + } + if (ownHome && !opts.keepHome) { + cleanupHome(grokHome); + } + } +} + +function spawnCollect(params: { + bin: string; + args: string[]; + env: Record; + cwd: string; + signal?: AbortSignal; +}): Promise<{ stdout: string; stderr: string; code: number | null }> { + return new Promise((resolve, reject) => { + if (params.signal?.aborted) { + reject(new Error("Aborted before Grok spawn")); + return; + } + + const child = spawn(params.bin, params.args, { + cwd: params.cwd, + env: params.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let killTimer: ReturnType | undefined; + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + }); + + const onAbort = () => { + child.kill("SIGTERM"); + killTimer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // ignore + } + }, 2_000); + killTimer.unref?.(); + }; + params.signal?.addEventListener("abort", onAbort, { once: true }); + + const finish = (fn: () => void) => { + if (killTimer) clearTimeout(killTimer); + params.signal?.removeEventListener("abort", onAbort); + fn(); + }; + + child.on("error", (err) => { + finish(() => { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + reject( + new Error( + `Grok Build CLI not found (${params.bin}). Install Grok Build and ensure \`grok\` is on PATH, or set GROK_EXECUTABLE.`, + ), + ); + return; + } + reject(err); + }); + }); + + child.on("close", (code) => { + finish(() => resolve({ stdout, stderr, code })); + }); + }); +} + +async function runToollessFollowUp(params: { + sessionId: string | undefined; + grokHome: string; + projectRoot: string; + model: string; + thinkingLevel: string; + prompt: string; + signal?: AbortSignal; +}): Promise { + if (!params.sessionId) return undefined; + try { + const run = await runGrokHeadless({ + prompt: params.prompt, + projectRoot: params.projectRoot, + model: params.model, + maxTurns: 1, + tools: TOOLLESS, + thinkingLevel: "low", + resumeSessionId: params.sessionId, + signal: params.signal, + grokHome: params.grokHome, + keepHome: true, + }); + return run.resultText; + } catch { + return undefined; + } +} + +export async function runGrokSetupTask(params: SetupTaskParams): Promise { + const model = (params.config.model as string) ?? DEFAULT_MODEL; + const thinkingLevel = resolveThinkingLevel(params.config); + params.onProgress?.({ + type: "started", + message: `Understanding repository with Grok Build (${model})`, + }); + const run = await runGrokHeadless({ + prompt: params.prompt, + projectRoot: params.projectRoot, + model, + maxTurns: (params.config.maxTurns as number) ?? 40, + tools: SETUP_TOOLS, + thinkingLevel, + signal: params.signal, + onProgress: params.onProgress, + }); + if (!run.resultText.trim()) throw new Error("Grok produced no setup result"); + params.onProgress?.({ type: "complete", message: "Repository setup analysis complete" }); + return run.resultText.trim(); +} + +export class GrokBuildAgentPlugin implements AgentPlugin { + type = "grok"; + + async *investigate(params: InvestigateParams): AsyncGenerator { + const { batch, projectRoot, promptTemplate, projectInfo, config, signal, projectId } = params; + const model = (config.model as string) ?? DEFAULT_MODEL; + const maxTurns = (config.maxTurns as number) ?? 150; + const thinkingLevel = resolveThinkingLevel(config); + const prompt = buildInvestigatePrompt({ promptTemplate, projectInfo, batch }); + const startTime = Date.now(); + + let resultText = ""; + let lastError = ""; + let sdkMeta: Partial = {}; + let sessionId: string | undefined; + let grokHome: string | undefined; + let turnCount = 0; + + yield { + type: "started", + message: `Investigating ${batch.length} file(s) with Grok Build (${model})`, + }; + + try { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + if (attempt > 1) { + yield { + type: "thinking", + message: `Retrying Grok batch after transient error (attempt ${attempt}/${MAX_ATTEMPTS}): ${lastError.slice(0, 200)}`, + }; + resultText = ""; + lastError = ""; + sdkMeta = {}; + sessionId = undefined; + cleanupHome(grokHome); + grokHome = undefined; + } + + try { + grokHome = makeIsolatedGrokHome(); + const run = await runGrokHeadless({ + prompt, + projectRoot, + model, + maxTurns, + tools: INVESTIGATE_TOOLS, + thinkingLevel, + signal, + grokHome, + keepHome: true, + }); + resultText = run.resultText; + sdkMeta = run.meta; + sessionId = run.raw.sessionId; + turnCount = run.raw.num_turns ?? 0; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + if (err instanceof QuotaExhaustedError) throw err; + yield { type: "error", message: `Grok error: ${lastError.slice(0, 300)}` }; + } + + if (resultText) break; + const quotaSource = classifyQuotaError(lastError); + if (quotaSource) throw new QuotaExhaustedError(quotaSource, lastError); + if (attempt >= MAX_ATTEMPTS || !isTransientError(lastError)) break; + await backoff(attempt); + } + + if (!resultText) { + throw new Error( + `Grok Build produced no investigation result after ${MAX_ATTEMPTS} attempt(s). ` + + `Last error: ${lastError || "(none captured)"}.`, + ); + } + + const durationMs = Date.now() - startTime; + let parsed: ParsedInvestigateResults; + try { + parsed = parseInvestigateResults(resultText, batch); + } catch (err) { + yield { + type: "thinking", + message: "Grok returned non-JSON investigation output; requesting JSON-only repair", + }; + const repairText = await runToollessFollowUp({ + sessionId, + grokHome: grokHome!, + projectRoot, + model, + thinkingLevel, + prompt: buildInvestigateJsonRepairPrompt(batch), + signal, + }); + if (repairText === undefined) { + writeParseFailureDebug({ + projectId, + phase: "investigate", + agentType: this.type, + resultText, + error: err, + batch, + }); + throw err; + } + try { + parsed = parseInvestigateResults(repairText, batch); + resultText = repairText; + yield { type: "thinking", message: "Grok JSON repair succeeded" }; + } catch (repairErr) { + const combinedError = jsonRepairFailureError(err, repairErr); + writeParseFailureDebug({ + projectId, + phase: "investigate", + agentType: this.type, + resultText: formatJsonRepairFailureDebugText(resultText, repairText), + error: combinedError, + batch, + }); + throw combinedError; + } + } + + let results: InvestigateResult[] = parsed.results; + if (parsed.invalid.length > 0) { + const fieldRepair = yield* runInvestigateFieldRepairLoop({ + results, + invalid: parsed.invalid, + batch, + followUp: (p) => + runToollessFollowUp({ + sessionId, + grokHome: grokHome!, + projectRoot, + model, + thinkingLevel, + prompt: p, + signal, + }), + agentLabel: "Grok", + agentType: this.type, + projectId, + }); + results = fieldRepair.results; + } + + let refusal: RefusalReport | undefined; + const refusalRaw = await runToollessFollowUp({ + sessionId, + grokHome: grokHome!, + projectRoot, + model, + thinkingLevel, + prompt: REFUSAL_FOLLOWUP_PROMPT, + signal, + }); + if (refusalRaw) refusal = parseRefusalReport(refusalRaw); + if (refusal?.refused) { + yield { + type: "thinking", + message: `Refusal detected: ${refusal.reason ?? "see raw"}`, + }; + } + + const costStr = sdkMeta.costUsd != null ? ` $${sdkMeta.costUsd.toFixed(3)}` : ""; + const tokensStr = sdkMeta.usage + ? ` ${sdkMeta.usage.inputTokens + sdkMeta.usage.outputTokens} tokens` + : ""; + yield { + type: "complete", + message: `Investigation complete (${(durationMs / 1000).toFixed(1)}s, ${turnCount} turns${costStr}${tokensStr}${refusal?.refused ? " refusal" : ""})`, + }; + + return { + results, + meta: { + durationMs, + ...sdkMeta, + refusal, + }, + }; + } finally { + cleanupHome(grokHome); + } + } + + async *revalidate(params: RevalidateParams): AsyncGenerator { + const { + batch, + projectRoot, + projectInfo, + config, + force = false, + onlyFindingIds, + signal, + projectId, + } = params; + const model = (config.model as string) ?? DEFAULT_MODEL; + const maxTurns = (config.maxTurns as number) ?? 150; + const thinkingLevel = resolveThinkingLevel(config); + + const { prompt, totalFindings, expected } = buildRevalidatePrompt({ + batch, + projectRoot, + projectInfo, + force, + onlyFindingIds: onlyFindingIds ? new Set(onlyFindingIds) : undefined, + }); + + yield { + type: "started", + message: `Revalidating ${totalFindings} finding(s) across ${batch.length} file(s) with Grok Build (${model})`, + }; + + const startTime = Date.now(); + let resultText = ""; + let lastError = ""; + let sdkMeta: Partial = {}; + let sessionId: string | undefined; + let grokHome: string | undefined; + const rawResponses: RevalidateRawResponse[] = []; + + try { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + if (attempt > 1) { + yield { + type: "thinking", + message: `Retrying Grok revalidation after transient error (attempt ${attempt}/${MAX_ATTEMPTS}): ${lastError.slice(0, 200)}`, + }; + resultText = ""; + lastError = ""; + sdkMeta = {}; + sessionId = undefined; + cleanupHome(grokHome); + grokHome = undefined; + } + + try { + grokHome = makeIsolatedGrokHome(); + const run = await runGrokHeadless({ + prompt, + projectRoot, + model, + maxTurns, + tools: INVESTIGATE_TOOLS, + thinkingLevel, + signal, + grokHome, + keepHome: true, + }); + resultText = run.resultText; + sdkMeta = run.meta; + sessionId = run.raw.sessionId; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + if (err instanceof QuotaExhaustedError) throw err; + yield { type: "error", message: `Grok error: ${lastError.slice(0, 300)}` }; + } + + if (resultText) break; + const quotaSource = classifyQuotaError(lastError); + if (quotaSource) throw new QuotaExhaustedError(quotaSource, lastError); + if (attempt >= MAX_ATTEMPTS || !isTransientError(lastError)) break; + await backoff(attempt); + } + + if (!resultText) { + throw new Error( + `Grok Build produced no revalidation result after ${MAX_ATTEMPTS} attempt(s). ` + + `Last error: ${lastError || "(none captured)"}.`, + ); + } + + let verdicts: RevalidateVerdict[]; + try { + verdicts = parseRevalidateVerdicts(resultText); + } catch (err) { + yield { + type: "thinking", + message: "Grok returned non-JSON revalidation output; requesting JSON-only repair", + }; + const repairPrompt = buildRevalidateJsonRepairPrompt(expected); + const repairText = await runToollessFollowUp({ + sessionId, + grokHome: grokHome!, + projectRoot, + model, + thinkingLevel, + prompt: repairPrompt, + signal, + }); + if (repairText === undefined) { + writeParseFailureDebug({ + projectId, + phase: "revalidate", + agentType: this.type, + resultText, + error: err, + batch, + }); + throw err; + } + rawResponses.push({ kind: "json-repair", prompt: repairPrompt, rawText: repairText }); + try { + verdicts = parseRevalidateVerdicts(repairText); + resultText = repairText; + yield { type: "thinking", message: "Grok revalidation JSON repair succeeded" }; + } catch (repairErr) { + const combinedError = jsonRepairFailureError(err, repairErr); + writeParseFailureDebug({ + projectId, + phase: "revalidate", + agentType: this.type, + resultText: formatJsonRepairFailureDebugText(resultText, repairText), + error: combinedError, + batch, + }); + throw combinedError; + } + } + + const idRepair = yield* runRevalidateIdRepairLoop({ + expected, + verdicts, + initialRawText: resultText, + followUp: async (p) => + runToollessFollowUp({ + sessionId, + grokHome: grokHome!, + projectRoot, + model, + thinkingLevel, + prompt: p, + signal, + }), + agentLabel: "Grok", + }); + + const durationMs = Date.now() - startTime; + const costStr = sdkMeta.costUsd != null ? ` $${sdkMeta.costUsd.toFixed(3)}` : ""; + yield { + type: "complete", + message: `Revalidation complete (${(durationMs / 1000).toFixed(1)}s, ${idRepair.verdicts.length} verdicts${costStr})`, + }; + + return { + verdicts: idRepair.verdicts, + meta: { + durationMs, + ...sdkMeta, + }, + rawResponses: [...rawResponses, ...idRepair.rawResponses], + repairAttempts: idRepair.repairAttempts, + }; + } finally { + cleanupHome(grokHome); + } + } +} + +function cleanupHome(home: string | undefined): void { + if (!home) return; + try { + fs.rmSync(home, { recursive: true, force: true }); + } catch { + // ignore + } +} diff --git a/packages/processor/src/index.ts b/packages/processor/src/index.ts index 09bd392..966be78 100644 --- a/packages/processor/src/index.ts +++ b/packages/processor/src/index.ts @@ -22,6 +22,7 @@ import { import { noiseScore, readTechJson } from "@deepsec/scanner"; import { ClaudeAgentSdkPlugin } from "./agents/claude-agent-sdk.js"; import { CodexAgentSdkPlugin } from "./agents/codex-sdk.js"; +import { GrokBuildAgentPlugin } from "./agents/grok-build.js"; import { PiAgentPlugin } from "./agents/pi-sdk.js"; import { AgentRegistry } from "./agents/registry.js"; import { QuotaExhaustedError, type QuotaSource } from "./agents/shared.js"; @@ -45,6 +46,7 @@ import { export { ClaudeAgentSdkPlugin } from "./agents/claude-agent-sdk.js"; export { CodexAgentSdkPlugin } from "./agents/codex-sdk.js"; +export { GrokBuildAgentPlugin } from "./agents/grok-build.js"; export { PiAgentPlugin } from "./agents/pi-sdk.js"; export { AgentRegistry } from "./agents/registry.js"; export { @@ -90,6 +92,7 @@ export function createDefaultAgentRegistry(): AgentRegistry { registry.register(new ClaudeAgentSdkPlugin()); registry.register(new CodexAgentSdkPlugin()); registry.register(new PiAgentPlugin()); + registry.register(new GrokBuildAgentPlugin()); // Plugins can contribute additional backends via `agents: []` in their // DeepsecPlugin export. The shape is validated by AgentRegistry at use. for (const a of getRegistry().agents as AgentPlugin[]) { diff --git a/packages/processor/src/setup-agent.ts b/packages/processor/src/setup-agent.ts index 30f8dd5..0ef809e 100644 --- a/packages/processor/src/setup-agent.ts +++ b/packages/processor/src/setup-agent.ts @@ -1,5 +1,6 @@ import { runClaudeSetupTask } from "./agents/claude-agent-sdk.js"; import { runCodexSetupTask } from "./agents/codex-sdk.js"; +import { runGrokSetupTask } from "./agents/grok-build.js"; import { runPiSetupTask } from "./agents/pi-sdk.js"; import type { SetupTaskParams } from "./agents/types.js"; @@ -17,9 +18,12 @@ export async function runSetupTask(params: RunSetupTaskParams): Promise return runClaudeSetupTask(params); case "pi": return runPiSetupTask(params); + case "grok": + case "grok-build": + return runGrokSetupTask(params); default: throw new Error( - `Agent "${params.agentType}" does not support automated setup. Use codex, claude, or pi.`, + `Agent "${params.agentType}" does not support automated setup. Use codex, claude, pi, or grok.`, ); } }