diff --git a/.changeset/grok-build-turn-driver.md b/.changeset/grok-build-turn-driver.md new file mode 100644 index 000000000000..9acff386def9 --- /dev/null +++ b/.changeset/grok-build-turn-driver.md @@ -0,0 +1,5 @@ +--- +'@ai-sdk/harness-grok-build': major +--- + +feat(harness-grok-build): implement bridge turn driver and doStart \ No newline at end of file diff --git a/content/docs/03-ai-sdk-harnesses/05-harness-adapters.mdx b/content/docs/03-ai-sdk-harnesses/05-harness-adapters.mdx index de55a1d85e62..9ec42d49e41a 100644 --- a/content/docs/03-ai-sdk-harnesses/05-harness-adapters.mdx +++ b/content/docs/03-ai-sdk-harnesses/05-harness-adapters.mdx @@ -17,6 +17,7 @@ The AI SDK includes the following harness adapters: - [Claude Code](/providers/ai-sdk-harnesses/claude-code) (`@ai-sdk/harness-claude-code`) - [Codex](/providers/ai-sdk-harnesses/codex) (`@ai-sdk/harness-codex`) - [Pi](/providers/ai-sdk-harnesses/pi) (`@ai-sdk/harness-pi`) +- [Grok Build](/providers/ai-sdk-harnesses/grok-build) (`@ai-sdk/harness-grok-build`) ### Coming Soon @@ -33,3 +34,4 @@ The AI SDK includes the following harness adapters: | [Claude Code](/providers/ai-sdk-harnesses/claude-code) | Sandbox bridge | | | | | [Codex](/providers/ai-sdk-harnesses/codex) | Sandbox bridge | | | | | [Pi](/providers/ai-sdk-harnesses/pi) | Host process | | | | +| [Grok Build](/providers/ai-sdk-harnesses/grok-build) | Sandbox bridge | | | | diff --git a/content/docs/03-ai-sdk-harnesses/index.mdx b/content/docs/03-ai-sdk-harnesses/index.mdx index ebc9392b167d..59f816365f3b 100644 --- a/content/docs/03-ai-sdk-harnesses/index.mdx +++ b/content/docs/03-ai-sdk-harnesses/index.mdx @@ -6,7 +6,7 @@ description: Use established agent harnesses through the AI SDK. # AI SDK Harnesses The harness section covers the AI SDK harness abstraction: a uniform API for -running established agent harnesses such as Claude Code, Codex, and Pi. +running established agent harnesses such as Claude Code, Codex, Pi, and Grok Build. + Harness packages are **experimental**. Expect breaking changes between + releases as this early API gets further refined. + + +## Setup + + + + + + + + + + + + + + + + +The adapter installs the `grok` CLI inside the sandbox when the first session +starts. This requires network egress for the bootstrap install. + +## Import + +```ts +import { grokBuild, createGrokBuild } from '@ai-sdk/harness-grok-build'; +``` + +`grokBuild` is equivalent to `createGrokBuild()` with its default configuration. + +## Basic Usage + +```ts +import { HarnessAgent } from '@ai-sdk/harness/agent'; +import { grokBuild } from '@ai-sdk/harness-grok-build'; +import { createVercelSandbox } from '@ai-sdk/sandbox-vercel'; + +const agent = new HarnessAgent({ + harness: grokBuild, + sandbox: createVercelSandbox({ + runtime: 'node24', + ports: [4000], + }), +}); + +const session = await agent.createSession(); + +let exitCode = 0; +try { + const result = await agent.stream({ + session, + prompt: 'In one sentence, what is the capital of France?', + }); + + for await (const part of result.stream) { + if (part.type === 'text-delta') { + process.stdout.write(part.text); + } + } +} catch (err) { + exitCode = 1; + console.error(err); +} finally { + await session.destroy(); + process.exit(exitCode); +} +``` + +To use this agent, ensure environment variables include `VERCEL_OIDC_TOKEN` for +Vercel Sandbox, and one of the variables listed under [authentication](#authentication) +for Grok. + +## Adapter Settings + +Use `createGrokBuild()` to configure the runtime: + +```ts +const harness = createGrokBuild({ + model: 'grok-code-fast-1', +}); +``` + +Settings: + +- `auth`: xAI or AI Gateway authentication settings. +- `model`: Grok model id. If omitted, the adapter uses its pinned default. +- `port`: bridge port override. +- `startupTimeoutMs`: maximum time to wait for the bridge to start. + +## Authentication + +By default, authentication is resolved from the host environment and forwarded +to the sandbox bridge. The adapter checks for AI Gateway and xAI credentials. + +Supported environment variables: + +- `XAI_API_KEY` (direct) +- `AI_GATEWAY_API_KEY` (AI Gateway) +- `VERCEL_OIDC_TOKEN` (AI Gateway) + +The CLI maps these internally to `GROK_MODELS_BASE_URL` / `GROK_CODE_XAI_API_KEY`. + +You can also pass explicit auth settings: + +```ts +const harness = createGrokBuild({ + auth: { + xai: { + apiKey: process.env.XAI_API_KEY, + }, + }, +}); +``` + +## Sandbox + +Grok Build requires a network sandbox with at least one exposed TCP port, +e.g. `@ai-sdk/sandbox-vercel`: + +```ts +const sandbox = createVercelSandbox({ + runtime: 'node24', + ports: [4000], +}); +``` + +## Known limitations + +The grok CLI's `--output-format streaming-json` surface is narrow: + +- Streams reasoning and text only — no tool-call, tool-result, or file-change + events, and no token usage. +- Allow-all permission mode only. The CLI runs with `--always-approve` and + executes tools itself; use `permissionMode: 'allow-all'`. +- No compaction. + +## Related + +- [HarnessAgent](/docs/ai-sdk-harnesses/harness-agent) +- [Harness tools](/docs/ai-sdk-harnesses/tools) +- [Harness adapters](/docs/ai-sdk-harnesses/harness-adapters) diff --git a/content/providers/02-ai-sdk-harnesses/index.mdx b/content/providers/02-ai-sdk-harnesses/index.mdx index bb85e24925d8..0b372d3fc855 100644 --- a/content/providers/02-ai-sdk-harnesses/index.mdx +++ b/content/providers/02-ai-sdk-harnesses/index.mdx @@ -26,6 +26,11 @@ and response primitives. description: 'Use Pi through the AI SDK harness abstraction.', href: '/providers/ai-sdk-harnesses/pi', }, + { + title: 'Grok Build', + description: 'Use Grok Build through the AI SDK harness abstraction.', + href: '/providers/ai-sdk-harnesses/grok-build', + }, ]} /> diff --git a/examples/ai-functions/package.json b/examples/ai-functions/package.json index 97f2a111323e..3ffc3ec810a5 100644 --- a/examples/ai-functions/package.json +++ b/examples/ai-functions/package.json @@ -30,6 +30,7 @@ "@ai-sdk/harness": "workspace:*", "@ai-sdk/harness-claude-code": "workspace:*", "@ai-sdk/harness-codex": "workspace:*", + "@ai-sdk/harness-grok-build": "workspace:*", "@ai-sdk/harness-pi": "workspace:*", "@ai-sdk/huggingface": "workspace:*", "@ai-sdk/hume": "workspace:*", diff --git a/examples/ai-functions/src/harness-agent/grok-build/generate-text.ts b/examples/ai-functions/src/harness-agent/grok-build/generate-text.ts new file mode 100644 index 000000000000..f7697535b6b4 --- /dev/null +++ b/examples/ai-functions/src/harness-agent/grok-build/generate-text.ts @@ -0,0 +1,38 @@ +import { HarnessAgent } from '@ai-sdk/harness/agent'; +import { grokBuild } from '@ai-sdk/harness-grok-build'; +import { createVercelSandbox } from '@ai-sdk/sandbox-vercel'; +import { run } from '../../lib/run'; + +// End-to-end smoke test against the Vercel Sandbox (required: grok-build is +// bridge-backed and needs a port, which the local just-bash sandbox cannot +// expose). Requires Vercel Sandbox credentials (OIDC token / `vercel` auth) +// and XAI_API_KEY (or AI Gateway env) in examples/ai-functions/.env. +run(async () => { + const sandbox = createVercelSandbox({ + runtime: 'node24', + ports: [4000], + timeout: 10 * 60 * 1000, + }); + const agent = new HarnessAgent({ + harness: grokBuild, + sandbox, + }); + + let exitCode = 0; + const session = await agent.createSession(); + try { + const result = await agent.generate({ + session, + prompt: 'In one sentence, what is the capital of France?', + }); + console.log('text:', result.text); + console.log('finishReason:', result.finishReason); + console.log('usage:', result.usage); + } catch (err) { + exitCode = 1; + console.error('[example] failed:', err); + } finally { + await session.destroy(); + process.exit(exitCode); + } +}); diff --git a/examples/ai-functions/src/harness-agent/grok-build/multi-turn.ts b/examples/ai-functions/src/harness-agent/grok-build/multi-turn.ts new file mode 100644 index 000000000000..15b5012ab6c4 --- /dev/null +++ b/examples/ai-functions/src/harness-agent/grok-build/multi-turn.ts @@ -0,0 +1,41 @@ +import { HarnessAgent } from '@ai-sdk/harness/agent'; +import { grokBuild } from '@ai-sdk/harness-grok-build'; +import { printFullStream } from '../../lib/print-full-stream'; +import { run } from '../../lib/run'; +import { createVercelSandbox } from '@ai-sdk/sandbox-vercel'; + +run(async () => { + const sandbox = createVercelSandbox({ + runtime: 'node24', + ports: [4000], + timeout: 10 * 60 * 1000, + }); + const agent = new HarnessAgent({ + harness: grokBuild, + sandbox, + }); + + let exitCode = 0; + const session = await agent.createSession(); + try { + console.log('--- turn 1 ---'); + const first = await agent.stream({ + session, + prompt: 'My name is Felix. Remember it.', + }); + await printFullStream({ result: first }); + + console.log('--- turn 2 ---'); + const second = await agent.stream({ + session, + prompt: 'What is my name? Answer in one word.', + }); + await printFullStream({ result: second }); + } catch (err) { + exitCode = 1; + console.error('[example] failed:', err); + } finally { + await session.destroy(); + process.exit(exitCode); + } +}); diff --git a/examples/ai-functions/src/harness-agent/grok-build/resume.ts b/examples/ai-functions/src/harness-agent/grok-build/resume.ts new file mode 100644 index 000000000000..11bef0d77a28 --- /dev/null +++ b/examples/ai-functions/src/harness-agent/grok-build/resume.ts @@ -0,0 +1,61 @@ +/* + * Cross-process resume smoke test for the Grok Build harness. + * + * Within a single Node process this simulates the REST-server flow: turn 1 + * runs, the session is stopped, the agent reference is dropped, and a fresh + * `HarnessAgent` instance picks the conversation back up using the persisted + * `HarnessAgentResumeSessionState`. The resume payload carries the grok CLI + * session id, which feeds `--resume` on the second turn so the model + * remembers the name from turn 1. + */ +import { + HarnessAgent, + type HarnessAgentResumeSessionState, +} from '@ai-sdk/harness/agent'; +import { grokBuild } from '@ai-sdk/harness-grok-build'; +import { createVercelSandbox } from '@ai-sdk/sandbox-vercel'; +import { printFullStream } from '../../lib/print-full-stream'; +import { run } from '../../lib/run'; + +run(async () => { + const sandbox = createVercelSandbox({ + runtime: 'node24', + ports: [4000], + timeout: 10 * 60 * 1000, + }); + + // Turn 1: introduce the name. + let sessionId: string; + let resumeState: HarnessAgentResumeSessionState; + { + const agent = new HarnessAgent({ harness: grokBuild, sandbox }); + const session = await agent.createSession(); + sessionId = session.sessionId; + console.log('--- turn 1 ---'); + const result = await agent.stream({ + session, + prompt: 'My name is Maya. Remember it.', + }); + await printFullStream({ result }); + resumeState = await session.stop(); + console.log('[stopped] resume state:', JSON.stringify(resumeState)); + } + + // Turn 2: brand-new agent instance, only the persisted state survives. + { + const agent = new HarnessAgent({ harness: grokBuild, sandbox }); + const session = await agent.createSession({ + sessionId, + resumeFrom: resumeState, + }); + console.log('--- turn 2 (resumed) ---'); + const result = await agent.stream({ + session, + prompt: 'What is my name? Answer in one word.', + }); + await printFullStream({ result }); + await session.destroy(); + } + + process.exit(0); +}); diff --git a/examples/ai-functions/src/harness-agent/grok-build/stream-text.ts b/examples/ai-functions/src/harness-agent/grok-build/stream-text.ts new file mode 100644 index 000000000000..e446a96666b4 --- /dev/null +++ b/examples/ai-functions/src/harness-agent/grok-build/stream-text.ts @@ -0,0 +1,37 @@ +import { HarnessAgent } from '@ai-sdk/harness/agent'; +import { grokBuild } from '@ai-sdk/harness-grok-build'; +import { printFullStream } from '../../lib/print-full-stream'; +import { run } from '../../lib/run'; +import { createVercelSandbox } from '@ai-sdk/sandbox-vercel'; + +run(async () => { + const sandbox = createVercelSandbox({ + runtime: 'node24', + ports: [4000], + timeout: 10 * 60 * 1000, + }); + const agent = new HarnessAgent({ + harness: grokBuild, + sandbox, + }); + + let exitCode = 0; + const session = await agent.createSession(); + try { + const result = await agent.stream({ + session, + prompt: 'Recite the first sentence of "A Tale of Two Cities".', + }); + + await printFullStream({ result }); + + console.log('finishReason:', await result.finishReason); + console.log('usage:', await result.usage); + } catch (err) { + exitCode = 1; + console.error('[example] failed:', err); + } finally { + await session.destroy(); + process.exit(exitCode); + } +}); diff --git a/examples/ai-functions/src/harness-agent/grok-build/with-provided-sandbox.ts b/examples/ai-functions/src/harness-agent/grok-build/with-provided-sandbox.ts new file mode 100644 index 000000000000..2a77ee94cbde --- /dev/null +++ b/examples/ai-functions/src/harness-agent/grok-build/with-provided-sandbox.ts @@ -0,0 +1,40 @@ +import { HarnessAgent } from '@ai-sdk/harness/agent'; +import { grokBuild } from '@ai-sdk/harness-grok-build'; +import { createVercelSandbox } from '@ai-sdk/sandbox-vercel'; +import { Sandbox } from '@vercel/sandbox'; +import { printFullStream } from '../../lib/print-full-stream'; +import { run } from '../../lib/run'; + +run(async () => { + const sandbox = await Sandbox.create({ + runtime: 'node24', + ports: [4000], + timeout: 10 * 60 * 1000, + }); + + const agent = new HarnessAgent({ + harness: grokBuild, + sandbox: createVercelSandbox({ sandbox }), + }); + + let exitCode = 0; + const session = await agent.createSession(); + try { + const result = await agent.stream({ + session, + prompt: 'In one sentence, what is the capital of France?', + }); + + await printFullStream({ result }); + + console.log('finishReason:', await result.finishReason); + console.log('usage:', await result.usage); + } catch (err) { + exitCode = 1; + console.error('[example] failed:', err); + } finally { + await session.destroy(); + await sandbox.stop().catch(() => {}); + process.exit(exitCode); + } +}); diff --git a/examples/harness-e2e-next/agent/harness/grok-build/ai-sdk-coding-agent.ts b/examples/harness-e2e-next/agent/harness/grok-build/ai-sdk-coding-agent.ts new file mode 100644 index 000000000000..17088d4ab740 --- /dev/null +++ b/examples/harness-e2e-next/agent/harness/grok-build/ai-sdk-coding-agent.ts @@ -0,0 +1,52 @@ +import { HarnessAgent } from '@ai-sdk/harness/agent'; +import { grokBuild } from '@ai-sdk/harness-grok-build'; +import { createVercelSandbox } from '@ai-sdk/sandbox-vercel'; +import type { InferUITools, UIMessage } from 'ai'; + +// Default sandbox resources won't allow for a full parallel build of all packages. +// Not worth bumping all demo sandboxes' resources for just this, we can easily +// work around this by guiding the harness. +const instructions = ` +Building all packages at once (e.g. running \`pnpm build\` or \`pnpm build:packages\`) +will exceed sandbox memory. When asked to do this, use the corresponding +\`pnpm exec turbo\` call directly with a lower \`--concurrency=4\` flag. +`; + +export const aiSdkCodingGrokBuildHarnessAgent = new HarnessAgent({ + harness: grokBuild, + instructions, + sandbox: createVercelSandbox({ + runtime: 'node24', + ports: [4000], + }), + onSandboxSession: async ({ session, sessionWorkDir, abortSignal }) => { + const result = await session.run({ + command: + 'test -d .git || git clone --depth 1 https://github.com/vercel/ai.git .', + workingDirectory: sessionWorkDir, + abortSignal, + }); + if (result.exitCode !== 0) { + throw new Error( + `Failed to clone vercel/ai (exit ${result.exitCode}): ${result.stderr}`, + ); + } + + const installResult = await session.run({ + command: 'test -d node_modules || pnpm install', + workingDirectory: sessionWorkDir, + abortSignal, + }); + if (installResult.exitCode !== 0) { + throw new Error( + `Failed to install dependencies (exit ${installResult.exitCode}): ${installResult.stderr}`, + ); + } + }, +}); + +export type AiSdkCodingGrokBuildHarnessAgentMessage = UIMessage< + unknown, + never, + InferUITools +>; diff --git a/examples/harness-e2e-next/agent/harness/grok-build/basic-agent.ts b/examples/harness-e2e-next/agent/harness/grok-build/basic-agent.ts new file mode 100644 index 000000000000..b0eed3a1658d --- /dev/null +++ b/examples/harness-e2e-next/agent/harness/grok-build/basic-agent.ts @@ -0,0 +1,28 @@ +import { + HarnessAgent, + createFileReporter, + createTraceTreeReporter, +} from '@ai-sdk/harness/agent'; +import { grokBuild } from '@ai-sdk/harness-grok-build'; +import { createVercelSandbox } from '@ai-sdk/sandbox-vercel'; +import type { InferUITools, UIMessage } from 'ai'; + +export const grokBuildHarnessAgent = new HarnessAgent({ + harness: grokBuild, + sandbox: createVercelSandbox({ + runtime: 'node24', + ports: [4000], + }), + telemetry: { + integrations: [ + createTraceTreeReporter(), + createFileReporter({ dir: '.harness-observability/grok-build/basic' }), + ], + }, +}); + +export type GrokBuildHarnessAgentMessage = UIMessage< + unknown, + never, + InferUITools +>; diff --git a/examples/harness-e2e-next/app/api/harness/grok-build/ai-sdk-coding/route.ts b/examples/harness-e2e-next/app/api/harness/grok-build/ai-sdk-coding/route.ts new file mode 100644 index 000000000000..d3b43cd12816 --- /dev/null +++ b/examples/harness-e2e-next/app/api/harness/grok-build/ai-sdk-coding/route.ts @@ -0,0 +1,41 @@ +import { aiSdkCodingGrokBuildHarnessAgent } from '@/agent/harness/grok-build/ai-sdk-coding-agent'; +import { + detachAndPersist, + resumeOrCreateSession, +} from '@/util/harness-resume-store'; +import { + convertToModelMessages, + createUIMessageStreamResponse, + toUIMessageStream, + type UIMessage, +} from 'ai'; + +export async function POST(request: Request) { + const body: { + id?: string; + messages: UIMessage[]; + } = await request.json(); + + if (!body.id) { + return new Response('Missing chat id', { status: 400 }); + } + const chatId = body.id; + const messages = await convertToModelMessages(body.messages); + + const session = await resumeOrCreateSession( + aiSdkCodingGrokBuildHarnessAgent, + chatId, + ); + + const result = await aiSdkCodingGrokBuildHarnessAgent.stream({ + session, + messages, + }); + + return createUIMessageStreamResponse({ + stream: toUIMessageStream({ + stream: result.stream, + onFinish: () => detachAndPersist(chatId, session), + }), + }); +} diff --git a/examples/harness-e2e-next/app/api/harness/grok-build/basic-with-stop/route.ts b/examples/harness-e2e-next/app/api/harness/grok-build/basic-with-stop/route.ts new file mode 100644 index 000000000000..09ab4d475fbb --- /dev/null +++ b/examples/harness-e2e-next/app/api/harness/grok-build/basic-with-stop/route.ts @@ -0,0 +1,37 @@ +import { grokBuildHarnessAgent } from '@/agent/harness/grok-build/basic-agent'; +import { + resumeOrCreateSession, + stopAndPersist, +} from '@/util/harness-resume-store'; +import { + convertToModelMessages, + createUIMessageStreamResponse, + toUIMessageStream, + type UIMessage, +} from 'ai'; + +export async function POST(request: Request) { + const body: { + id?: string; + messages: UIMessage[]; + } = await request.json(); + + if (!body.id) { + return new Response('Missing chat id', { status: 400 }); + } + const chatId = body.id; + const messages = await convertToModelMessages(body.messages); + + const session = await resumeOrCreateSession(grokBuildHarnessAgent, chatId); + + const result = await grokBuildHarnessAgent.stream({ session, messages }); + + return createUIMessageStreamResponse({ + stream: toUIMessageStream({ + stream: result.stream, + // Stop the session at the end of the turn so the next request resumes + // from the persisted snapshot rather than attaching to a parked bridge. + onFinish: () => stopAndPersist(chatId, session), + }), + }); +} diff --git a/examples/harness-e2e-next/app/api/harness/grok-build/basic/route.ts b/examples/harness-e2e-next/app/api/harness/grok-build/basic/route.ts new file mode 100644 index 000000000000..41f299c49278 --- /dev/null +++ b/examples/harness-e2e-next/app/api/harness/grok-build/basic/route.ts @@ -0,0 +1,35 @@ +import { grokBuildHarnessAgent } from '@/agent/harness/grok-build/basic-agent'; +import { + detachAndPersist, + resumeOrCreateSession, +} from '@/util/harness-resume-store'; +import { + convertToModelMessages, + createUIMessageStreamResponse, + toUIMessageStream, + type UIMessage, +} from 'ai'; + +export async function POST(request: Request) { + const body: { + id?: string; + messages: UIMessage[]; + } = await request.json(); + + if (!body.id) { + return new Response('Missing chat id', { status: 400 }); + } + const chatId = body.id; + const messages = await convertToModelMessages(body.messages); + + const session = await resumeOrCreateSession(grokBuildHarnessAgent, chatId); + + const result = await grokBuildHarnessAgent.stream({ session, messages }); + + return createUIMessageStreamResponse({ + stream: toUIMessageStream({ + stream: result.stream, + onFinish: () => detachAndPersist(chatId, session), + }), + }); +} diff --git a/examples/harness-e2e-next/app/harness/grok-build/ai-sdk-coding/page.tsx b/examples/harness-e2e-next/app/harness/grok-build/ai-sdk-coding/page.tsx new file mode 100644 index 000000000000..0a110f1f759c --- /dev/null +++ b/examples/harness-e2e-next/app/harness/grok-build/ai-sdk-coding/page.tsx @@ -0,0 +1,19 @@ +import ChatIdProvider from '@/components/chat-id-provider'; +import GrokBuildHarnessChat from '@/components/grok-build-harness-chat'; + +export const metadata = { + title: 'Grok Build — AI SDK Checkout', +}; + +const STORAGE_KEY = 'harness-grok-build-ai-sdk-coding-chat-id'; + +export default function HarnessGrokBuildAiSdkCodingPage() { + return ( + + + + ); +} diff --git a/examples/harness-e2e-next/app/harness/grok-build/basic-with-stop/page.tsx b/examples/harness-e2e-next/app/harness/grok-build/basic-with-stop/page.tsx new file mode 100644 index 000000000000..70cf9f511e3e --- /dev/null +++ b/examples/harness-e2e-next/app/harness/grok-build/basic-with-stop/page.tsx @@ -0,0 +1,19 @@ +import ChatIdProvider from '@/components/chat-id-provider'; +import GrokBuildHarnessChat from '@/components/grok-build-harness-chat'; + +export const metadata = { + title: 'Grok Build — Basic (with stop)', +}; + +const STORAGE_KEY = 'harness-grok-build-basic-with-stop-chat-id'; + +export default function HarnessGrokBuildBasicWithStopPage() { + return ( + + + + ); +} diff --git a/examples/harness-e2e-next/app/harness/grok-build/basic/page.tsx b/examples/harness-e2e-next/app/harness/grok-build/basic/page.tsx new file mode 100644 index 000000000000..0e818523cb9d --- /dev/null +++ b/examples/harness-e2e-next/app/harness/grok-build/basic/page.tsx @@ -0,0 +1,19 @@ +import ChatIdProvider from '@/components/chat-id-provider'; +import GrokBuildHarnessChat from '@/components/grok-build-harness-chat'; + +export const metadata = { + title: 'Grok Build — Basic', +}; + +const STORAGE_KEY = 'harness-grok-build-basic-chat-id'; + +export default function HarnessGrokBuildPage() { + return ( + + + + ); +} diff --git a/examples/harness-e2e-next/app/page.tsx b/examples/harness-e2e-next/app/page.tsx index fd7324c9058f..80c33b1aa230 100644 --- a/examples/harness-e2e-next/app/page.tsx +++ b/examples/harness-e2e-next/app/page.tsx @@ -42,6 +42,11 @@ const HARNESSES = [ 'weather-approval', ], }, + { + slug: 'grok-build', + label: 'Grok Build', + variants: ['basic', 'basic-with-stop', 'ai-sdk-coding'], + }, ] as const; const VARIANT_LABELS: Record = Object.fromEntries( diff --git a/examples/harness-e2e-next/components/grok-build-harness-chat.tsx b/examples/harness-e2e-next/components/grok-build-harness-chat.tsx new file mode 100644 index 000000000000..d61efb77a059 --- /dev/null +++ b/examples/harness-e2e-next/components/grok-build-harness-chat.tsx @@ -0,0 +1,142 @@ +'use client'; + +import type { GrokBuildHarnessAgentMessage } from '@/agent/harness/grok-build/basic-agent'; +import { Response } from '@/components/ai-elements/response'; +import { useChatId } from '@/components/chat-id-provider'; +import ChatInput from '@/components/chat-input'; +import DynamicToolView from '@/components/tool/dynamic-tool-view'; +import HarnessBashToolView from '@/components/tool/harness-bash-tool-view'; +import HarnessToolView from '@/components/tool/harness-tool-view'; +import { useChat } from '@ai-sdk/react'; +import { DefaultChatTransport } from 'ai'; + +export default function GrokBuildHarnessChat({ + apiRoute, + exampleLabel, +}: { + apiRoute: string; + exampleLabel: string; +}) { + const { chatId, resetChatId } = useChatId(); + const { error, status, sendMessage, messages, regenerate } = + useChat({ + id: chatId, + transport: new DefaultChatTransport({ + api: apiRoute, + }), + }); + + return ( +
+

Grok Build — {exampleLabel}

+

+ chat id: {chatId} + +

+ + {messages.map(message => ( +
+ {message.role === 'user' ? 'You: ' : 'AI: '} + {message.parts.map((part, index) => { + switch (part.type) { + case 'text': { + return ( + + {part.text} + + ); + } + case 'reasoning': { + return ( + + {part.text} + + ); + } + case 'file': + case 'reasoning-file': { + if (part.mediaType.startsWith('image/')) { + return ( + // eslint-disable-next-line @next/next/no-img-element + Generated image + ); + } + return null; + } + case 'tool-bash': { + return ; + } + case 'dynamic-tool': { + if (part.toolName === 'fileChange') { + if (typeof part.input !== 'object' || part.input === null) { + return null; + } + return ( + + ); + } + return ; + } + } + })} +
+ ))} + + {status === 'submitted' && ( +
+ )} + + {error && ( +
+
+ {error.message || String(error)} +
+ +
+ )} + +
+ + sendMessage({ text })} + /> +
+ ); +} diff --git a/examples/harness-e2e-next/package.json b/examples/harness-e2e-next/package.json index d18ffc262242..15148291ff19 100644 --- a/examples/harness-e2e-next/package.json +++ b/examples/harness-e2e-next/package.json @@ -12,6 +12,7 @@ "@ai-sdk/harness": "workspace:*", "@ai-sdk/harness-claude-code": "workspace:*", "@ai-sdk/harness-codex": "workspace:*", + "@ai-sdk/harness-grok-build": "workspace:*", "@ai-sdk/harness-pi": "workspace:*", "@ai-sdk/provider-utils": "workspace:*", "@ai-sdk/react": "workspace:*", diff --git a/examples/harness-e2e-tui/agents/grok-build/ai-sdk-coding-agent.ts b/examples/harness-e2e-tui/agents/grok-build/ai-sdk-coding-agent.ts new file mode 100644 index 000000000000..17088d4ab740 --- /dev/null +++ b/examples/harness-e2e-tui/agents/grok-build/ai-sdk-coding-agent.ts @@ -0,0 +1,52 @@ +import { HarnessAgent } from '@ai-sdk/harness/agent'; +import { grokBuild } from '@ai-sdk/harness-grok-build'; +import { createVercelSandbox } from '@ai-sdk/sandbox-vercel'; +import type { InferUITools, UIMessage } from 'ai'; + +// Default sandbox resources won't allow for a full parallel build of all packages. +// Not worth bumping all demo sandboxes' resources for just this, we can easily +// work around this by guiding the harness. +const instructions = ` +Building all packages at once (e.g. running \`pnpm build\` or \`pnpm build:packages\`) +will exceed sandbox memory. When asked to do this, use the corresponding +\`pnpm exec turbo\` call directly with a lower \`--concurrency=4\` flag. +`; + +export const aiSdkCodingGrokBuildHarnessAgent = new HarnessAgent({ + harness: grokBuild, + instructions, + sandbox: createVercelSandbox({ + runtime: 'node24', + ports: [4000], + }), + onSandboxSession: async ({ session, sessionWorkDir, abortSignal }) => { + const result = await session.run({ + command: + 'test -d .git || git clone --depth 1 https://github.com/vercel/ai.git .', + workingDirectory: sessionWorkDir, + abortSignal, + }); + if (result.exitCode !== 0) { + throw new Error( + `Failed to clone vercel/ai (exit ${result.exitCode}): ${result.stderr}`, + ); + } + + const installResult = await session.run({ + command: 'test -d node_modules || pnpm install', + workingDirectory: sessionWorkDir, + abortSignal, + }); + if (installResult.exitCode !== 0) { + throw new Error( + `Failed to install dependencies (exit ${installResult.exitCode}): ${installResult.stderr}`, + ); + } + }, +}); + +export type AiSdkCodingGrokBuildHarnessAgentMessage = UIMessage< + unknown, + never, + InferUITools +>; diff --git a/examples/harness-e2e-tui/agents/grok-build/basic-agent.ts b/examples/harness-e2e-tui/agents/grok-build/basic-agent.ts new file mode 100644 index 000000000000..b0eed3a1658d --- /dev/null +++ b/examples/harness-e2e-tui/agents/grok-build/basic-agent.ts @@ -0,0 +1,28 @@ +import { + HarnessAgent, + createFileReporter, + createTraceTreeReporter, +} from '@ai-sdk/harness/agent'; +import { grokBuild } from '@ai-sdk/harness-grok-build'; +import { createVercelSandbox } from '@ai-sdk/sandbox-vercel'; +import type { InferUITools, UIMessage } from 'ai'; + +export const grokBuildHarnessAgent = new HarnessAgent({ + harness: grokBuild, + sandbox: createVercelSandbox({ + runtime: 'node24', + ports: [4000], + }), + telemetry: { + integrations: [ + createTraceTreeReporter(), + createFileReporter({ dir: '.harness-observability/grok-build/basic' }), + ], + }, +}); + +export type GrokBuildHarnessAgentMessage = UIMessage< + unknown, + never, + InferUITools +>; diff --git a/examples/harness-e2e-tui/harness/grok-build/ai-sdk-coding.ts b/examples/harness-e2e-tui/harness/grok-build/ai-sdk-coding.ts new file mode 100644 index 000000000000..9603d5c35ba8 --- /dev/null +++ b/examples/harness-e2e-tui/harness/grok-build/ai-sdk-coding.ts @@ -0,0 +1,8 @@ +import { aiSdkCodingGrokBuildHarnessAgent } from '../../agents/grok-build/ai-sdk-coding-agent'; +import { runTUI } from '../../lib/run-tui'; + +await runTUI({ + agent: aiSdkCodingGrokBuildHarnessAgent, + entrypointUrl: import.meta.url, + title: 'Grok Build — AI SDK Coding', +}); diff --git a/examples/harness-e2e-tui/harness/grok-build/basic.ts b/examples/harness-e2e-tui/harness/grok-build/basic.ts new file mode 100644 index 000000000000..e301101f74d1 --- /dev/null +++ b/examples/harness-e2e-tui/harness/grok-build/basic.ts @@ -0,0 +1,8 @@ +import { grokBuildHarnessAgent } from '../../agents/grok-build/basic-agent'; +import { runTUI } from '../../lib/run-tui'; + +await runTUI({ + agent: grokBuildHarnessAgent, + entrypointUrl: import.meta.url, + title: 'Grok Build — Basic', +}); diff --git a/examples/harness-e2e-tui/package.json b/examples/harness-e2e-tui/package.json index a2b6343fc300..b7588632b184 100644 --- a/examples/harness-e2e-tui/package.json +++ b/examples/harness-e2e-tui/package.json @@ -10,6 +10,7 @@ "@ai-sdk/harness": "workspace:*", "@ai-sdk/harness-claude-code": "workspace:*", "@ai-sdk/harness-codex": "workspace:*", + "@ai-sdk/harness-grok-build": "workspace:*", "@ai-sdk/harness-pi": "workspace:*", "@ai-sdk/sandbox-vercel": "workspace:*", "@ai-sdk/tui": "workspace:*", diff --git a/packages/harness-grok-build/CHANGELOG.md b/packages/harness-grok-build/CHANGELOG.md new file mode 100644 index 000000000000..3ba038dbf213 --- /dev/null +++ b/packages/harness-grok-build/CHANGELOG.md @@ -0,0 +1 @@ +# @ai-sdk/harness-grok-build diff --git a/packages/harness-grok-build/README.md b/packages/harness-grok-build/README.md new file mode 100644 index 000000000000..d167ccb68311 --- /dev/null +++ b/packages/harness-grok-build/README.md @@ -0,0 +1,62 @@ +# AI SDK - Grok Build Harness + +`HarnessV1` adapter backed by the `grok` CLI (`@xai-official/grok`). The adapter ships a bridge process that runs inside a sandbox and talks to the host over a WebSocket on a sandbox-proxied loopback port. + +## Setup + +```bash +npm i @ai-sdk/harness-grok-build @ai-sdk/harness @ai-sdk/sandbox-vercel +``` + +The bridge installs the `grok` CLI inside the sandbox the first time the session starts. This requires network egress for the bootstrap install. + +## Usage + +```ts +import { HarnessAgent } from '@ai-sdk/harness/agent'; +import { grokBuild } from '@ai-sdk/harness-grok-build'; +import { createVercelSandbox } from '@ai-sdk/sandbox-vercel'; + +const agent = new HarnessAgent({ + harness: grokBuild, + sandbox: createVercelSandbox({ + runtime: 'node24', + ports: [4000], + }), +}); + +const session = await agent.createSession(); + +try { + const result = await agent.generate({ + session, + prompt: 'In one sentence, what is the capital of France?', + }); + console.log(result.text); +} finally { + await session.destroy(); +} +``` + +The adapter requires a `HarnessV1SandboxProvider` whose handles expose at least one TCP port — `@ai-sdk/sandbox-vercel` is the supported choice today. + +## Authentication + +Authentication is resolved from the host environment and forwarded to the sandbox bridge: + +- Direct: `XAI_API_KEY`. +- AI Gateway: `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN`. + +The CLI maps these internally to `GROK_MODELS_BASE_URL` / `GROK_CODE_XAI_API_KEY`. + +## Limitations + +The grok CLI's `--output-format streaming-json` surface is narrow: + +- Streams reasoning and text only — no tool-call, tool-result, or file-change events, and no token usage. +- Allow-all permission mode only (`supportsBuiltinToolApprovals: false`); the CLI runs with `--always-approve` and executes tools itself. +- No compaction. + +## Related + +See the [AI SDK harness docs](https://ai-sdk.dev/docs/ai-sdk-harnesses) for sessions, tools, UI, and terminal usage. diff --git a/packages/harness-grok-build/package.json b/packages/harness-grok-build/package.json new file mode 100644 index 000000000000..e4c3902ad723 --- /dev/null +++ b/packages/harness-grok-build/package.json @@ -0,0 +1,74 @@ +{ + "name": "@ai-sdk/harness-grok-build", + "version": "1.0.0-beta.0", + "type": "module", + "license": "Apache-2.0", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "source": "./src/index.ts", + "files": [ + "dist/**/*", + "src", + "!src/**/*.test.ts", + "!src/**/*.test-d.ts", + "!src/**/__snapshots__", + "!src/**/__fixtures__", + "CHANGELOG.md", + "README.md" + ], + "scripts": { + "build": "pnpm clean && tsup --tsconfig tsconfig.build.json && pnpm copy-bridge-assets", + "build:watch": "pnpm clean && tsup --watch", + "clean": "del-cli dist *.tsbuildinfo", + "copy-bridge-assets": "node -e \"import('node:fs/promises').then(async fs => { await fs.copyFile('src/bridge/package.json', 'dist/bridge/package.json'); await fs.copyFile('src/bridge/pnpm-lock.yaml', 'dist/bridge/pnpm-lock.yaml'); })\"", + "type-check": "tsc --build", + "test": "pnpm test:node", + "test:watch": "vitest --config vitest.node.config.js", + "test:node": "vitest --config vitest.node.config.js --run" + }, + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "dependencies": { + "@ai-sdk/harness": "workspace:*", + "@ai-sdk/provider-utils": "workspace:*", + "ws": "^8.21.0", + "zod": "3.25.76" + }, + "devDependencies": { + "@xai-official/grok": "0.2.51", + "@types/node": "22.19.19", + "@types/ws": "^8.5.13", + "@vercel/ai-tsconfig": "workspace:*", + "tsup": "^8.5.1", + "typescript": "5.8.3" + }, + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "homepage": "https://ai-sdk.dev/docs", + "repository": { + "type": "git", + "url": "https://github.com/vercel/ai", + "directory": "packages/harness-grok-build" + }, + "bugs": { + "url": "https://github.com/vercel/ai/issues" + }, + "keywords": [ + "ai", + "harness", + "grok", + "grok-build" + ] +} diff --git a/packages/harness-grok-build/src/__fixtures__/README.md b/packages/harness-grok-build/src/__fixtures__/README.md new file mode 100644 index 000000000000..b9c23d2121ee --- /dev/null +++ b/packages/harness-grok-build/src/__fixtures__/README.md @@ -0,0 +1,33 @@ +# Grok Build CLI fixtures + +## `streaming-json-basic.jsonl` (REAL capture — source of truth) + +Real output of: + + grok -p "Create a file hello.txt containing the text hi, then read it back." \ + -m grok-build-0.1 --output-format streaming-json --always-approve + +captured against `@xai-official/grok` v0.2.53 (direct xAI API, `XAI_API_KEY`) on +2026-06-18. Home-directory path in the assistant text was redacted to +`/Users/USER`. + +### Actual schema (flat, newline-delimited JSON) +This mode is lean. Only three event types appear: + +- `{"type":"thought","data":""}` — reasoning/thinking text delta +- `{"type":"text","data":""}` — assistant message text delta +- `{"type":"end","stopReason":"EndTurn","sessionId":"","requestId":""}` — terminal + +### What this mode does NOT include (important) +- **No tool-call / tool-result events** — even though the agent created and read + the file, `streaming-json` does not surface tool invocations. +- **No file-change events.** +- **No token usage.** + +Full tool/file/usage fidelity requires the `grok agent` ACP (JSON-RPC stdio) +surface instead — that's a planned follow-up (see the harness-grok-build plan). +The v1 adapter maps only thought→reasoning, text→text, end→finish. + +### stopReason values +Observed: `EndTurn`. Others (e.g. max-tokens, cancellation) are unconfirmed — +map defensively. diff --git a/packages/harness-grok-build/src/__fixtures__/streaming-json-basic.jsonl b/packages/harness-grok-build/src/__fixtures__/streaming-json-basic.jsonl new file mode 100644 index 000000000000..1fd21bc869bf --- /dev/null +++ b/packages/harness-grok-build/src/__fixtures__/streaming-json-basic.jsonl @@ -0,0 +1,140 @@ +{"type":"thought","data":"The"} +{"type":"thought","data":" user"} +{"type":"thought","data":" wants"} +{"type":"thought","data":" me"} +{"type":"thought","data":" to"} +{"type":"thought","data":":\n"} +{"type":"thought","data":"1"} +{"type":"thought","data":"."} +{"type":"thought","data":" Create"} +{"type":"thought","data":" a"} +{"type":"thought","data":" file"} +{"type":"thought","data":" hello"} +{"type":"thought","data":".txt"} +{"type":"thought","data":" containing"} +{"type":"thought","data":" the"} +{"type":"thought","data":" text"} +{"type":"thought","data":" \""} +{"type":"thought","data":"hi"} +{"type":"thought","data":"\""} +{"type":"thought","data":"The"} +{"type":"thought","data":" write"} +{"type":"thought","data":" succeeded"} +{"type":"thought","data":" but"} +{"type":"thought","data":" the"} +{"type":"thought","data":" read"} +{"type":"thought","data":" failed"} +{"type":"thought","data":"."} +{"type":"thought","data":" The"} +{"type":"thought","data":" file"} +{"type":"thought","data":" path"} +{"type":"thought","data":" might"} +{"type":"thought","data":" be"} +{"type":"thought","data":" different"} +{"type":"thought","data":"."} +{"type":"thought","data":" The"} +{"type":"thought","data":" user"} +{"type":"thought","data":"_info"} +{"type":"thought","data":" says"} +{"type":"thought","data":" \""} +{"type":"thought","data":"Prefer"} +{"type":"thought","data":" using"} +{"type":"thought","data":" relative"} +{"type":"thought","data":" paths"} +{"type":"thought","data":" over"} +{"type":"thought","data":" absolute"} +{"type":"thought","data":" paths"} +{"type":"thought","data":" as"} +{"type":"thought","data":" tool"} +{"type":"thought","data":" call"} +{"type":"thought","data":" args"} +{"type":"thought","data":" when"} +{"type":"thought","data":" possible"} +{"type":"thought","data":".\"\n"} +{"type":"thought","data":"The"} +{"type":"thought","data":" file"} +{"type":"thought","data":" hello"} +{"type":"thought","data":".txt"} +{"type":"thought","data":" exists"} +{"type":"thought","data":" in"} +{"type":"thought","data":" the"} +{"type":"thought","data":" directory"} +{"type":"thought","data":" listing"} +{"type":"thought","data":"."} +{"type":"thought","data":" The"} +{"type":"thought","data":" previous"} +{"type":"thought","data":" read"} +{"type":"thought","data":"_file"} +{"type":"thought","data":" with"} +{"type":"thought","data":" absolute"} +{"type":"thought","data":" path"} +{"type":"thought","data":" failed"} +{"type":"thought","data":" for"} +{"type":"thought","data":" some"} +{"type":"thought","data":" reason"} +{"type":"thought","data":"."} +{"type":"thought","data":" I"} +{"type":"thought","data":" should"} +{"type":"thought","data":" try"} +{"type":"thought","data":" reading"} +{"type":"thought","data":" it"} +{"type":"thought","data":" again"} +{"type":"thought","data":","} +{"type":"thought","data":" preferably"} +{"type":"thought","data":" with"} +{"type":"thought","data":" relative"} +{"type":"thought","data":" path"} +{"type":"thought","data":" \""} +{"type":"thought","data":"hello"} +{"type":"thought","data":".txt"} +{"type":"thought","data":"\".\n"} +{"type":"thought","data":"Now"} +{"type":"thought","data":" it"} +{"type":"thought","data":" worked"} +{"type":"thought","data":"."} +{"type":"thought","data":" The"} +{"type":"thought","data":" file"} +{"type":"thought","data":" hello"} +{"type":"thought","data":".txt"} +{"type":"thought","data":" was"} +{"type":"thought","data":" created"} +{"type":"thought","data":" with"} +{"type":"thought","data":" content"} +{"type":"thought","data":" \""} +{"type":"thought","data":"hi"} +{"type":"thought","data":"\""} +{"type":"thought","data":" and"} +{"type":"thought","data":" read"} +{"type":"thought","data":" back"} +{"type":"thought","data":" successfully"} +{"type":"thought","data":".\n"} +{"type":"text","data":"Done"} +{"type":"text","data":"."} +{"type":"text","data":" Created"} +{"type":"text","data":" ["} +{"type":"text","data":"hello"} +{"type":"text","data":".txt"} +{"type":"text","data":"](/"} +{"type":"text","data":"Users"} +{"type":"text","data":"/m"} +{"type":"text","data":"lek"} +{"type":"text","data":"hi"} +{"type":"text","data":"/g"} +{"type":"text","data":"rok"} +{"type":"text","data":"-c"} +{"type":"text","data":"apture"} +{"type":"text","data":"/hello"} +{"type":"text","data":".txt"} +{"type":"text","data":")"} +{"type":"text","data":" with"} +{"type":"text","data":" content"} +{"type":"text","data":" `"} +{"type":"text","data":"hi"} +{"type":"text","data":"`,"} +{"type":"text","data":" and"} +{"type":"text","data":" read"} +{"type":"text","data":" it"} +{"type":"text","data":" back"} +{"type":"text","data":" successfully"} +{"type":"text","data":"."} +{"type":"end","stopReason":"EndTurn","sessionId":"019edb94-5baf-7d21-a522-0bd8a0eeb623","requestId":"88c192b4-e774-4a3c-8e76-4eb9f816cdee"} diff --git a/packages/harness-grok-build/src/bridge/grok-build-path.test.ts b/packages/harness-grok-build/src/bridge/grok-build-path.test.ts new file mode 100644 index 000000000000..027687a93b71 --- /dev/null +++ b/packages/harness-grok-build/src/bridge/grok-build-path.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { prependGrokBuildBinToPath } from './grok-build-path'; + +describe('prependGrokBuildBinToPath', () => { + it('prepends the bridge node_modules bin directory', () => { + const env = { PATH: '/usr/bin:/bin' }; + + prependGrokBuildBinToPath({ + bootstrapDir: '/tmp/harness/grok-build', + env, + }); + + expect(env.PATH).toBe( + '/tmp/harness/grok-build/node_modules/.bin:/usr/bin:/bin', + ); + }); + + it('keeps a usable system path fallback when PATH is absent', () => { + const env: { PATH?: string } = {}; + + prependGrokBuildBinToPath({ + bootstrapDir: '/tmp/harness/grok-build', + env, + }); + + expect(env.PATH).toContain('/tmp/harness/grok-build/node_modules/.bin:'); + expect(env.PATH).toContain('/usr/bin'); + }); +}); diff --git a/packages/harness-grok-build/src/bridge/grok-build-path.ts b/packages/harness-grok-build/src/bridge/grok-build-path.ts new file mode 100644 index 000000000000..62287826196d --- /dev/null +++ b/packages/harness-grok-build/src/bridge/grok-build-path.ts @@ -0,0 +1,18 @@ +import path from 'node:path'; + +const fallbackPath = + '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'; + +// Prepend the bootstrap's node_modules/.bin to PATH so the installed `grok` wins. +export function prependGrokBuildBinToPath({ + bootstrapDir, + env, +}: { + bootstrapDir: string; + env: NodeJS.ProcessEnv; +}): void { + env.PATH = [ + path.join(bootstrapDir, 'node_modules', '.bin'), + env.PATH || fallbackPath, + ].join(path.delimiter); +} diff --git a/packages/harness-grok-build/src/bridge/index.ts b/packages/harness-grok-build/src/bridge/index.ts new file mode 100644 index 000000000000..bd76876c7dd0 --- /dev/null +++ b/packages/harness-grok-build/src/bridge/index.ts @@ -0,0 +1,189 @@ +// Grok-specific turn driver for the shared @ai-sdk/harness/bridge runtime. +// Spawns a `grok -p ... --output-format streaming-json --always-approve` child +// per turn; tools run inside grok, so there's no host tool dispatch here. + +import { + runBridge, + type BridgeEvent, + type BridgeTurn, +} from '@ai-sdk/harness/bridge'; +import { spawn } from 'node:child_process'; +import { argv, env as procEnv, stdout } from 'node:process'; +import { createInterface } from 'node:readline'; +import type { StartMessage } from '../grok-build-bridge-protocol'; +import { createStreamMapState, mapStreamLine } from '../grok-build-stream-map'; +import { prependGrokBuildBinToPath } from './grok-build-path'; + +const DEFAULT_GROK_MODEL = 'grok-build-0.1'; + +const args = parseArgs(argv.slice(2)); +if (!args.workdir) { + emitFatal('Missing --workdir argument.'); +} +if (!args.bridgeStateDir) { + emitFatal('Missing --bridge-state-dir argument.'); +} +const workdir: string = args.workdir; +const bridgeStateDir: string = args.bridgeStateDir; +const bootstrapDir: string = args.bootstrapDir ?? workdir; + +// Make the bootstrap-installed `grok` binary resolve ahead of any system copy +// by prepending its node_modules/.bin to PATH. Spawning the bare `grok` name +// (rather than an absolute path) then picks it up. Mirrors the OpenCode bridge. +prependGrokBuildBinToPath({ bootstrapDir, env: procEnv }); + +// The latest grok CLI session id, learned from the terminal `end` event's +// `sessionId`. Returned to the host on detach so a future process could resume +// the grok thread via `-r/--resume`. +const sessionState: { id: string | undefined } = { id: undefined }; + +await runBridge({ + bridgeType: 'grok-build', + bridgeStateDir, + onStart: runTurn, + onDetach: () => (sessionState.id ? { sessionId: sessionState.id } : {}), +}); + +async function runTurn(start: StartMessage, turn: BridgeTurn): Promise { + const emit = (event: BridgeEvent) => turn.emit(event); + + const cliArgs = [ + '-p', + start.prompt, + '-m', + start.model ?? DEFAULT_GROK_MODEL, + '--output-format', + 'streaming-json', + // REQUIRED in headless mode: without it the CLI blocks on tool approval. + // Tools therefore execute inside grok; no host dispatch happens here. + '--always-approve', + '--cwd', + workdir, + ]; + // Resume the prior CLI thread in this workdir instead of starting fresh. + if (start.continue) cliArgs.push('-c'); + + const child = spawn('grok', cliArgs, { + cwd: workdir, + env: procEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const childStdout = child.stdout; + const childStderr = child.stderr; + if (!childStdout || !childStderr) { + throw new Error('grok child process did not expose stdout/stderr pipes.'); + } + + // Wire host abort to killing the child. + const onAbort = () => { + try { + child.kill('SIGTERM'); + } catch {} + }; + if (turn.abortSignal.aborted) { + onAbort(); + } else { + turn.abortSignal.addEventListener('abort', onAbort, { once: true }); + } + + // Per-turn stream-map state: each line of grok's streaming-json stdout maps + // to zero or more HarnessV1StreamPart events. + const state = createStreamMapState(); + + const rl = createInterface({ input: childStdout, crlfDelay: Infinity }); + rl.on('line', line => { + const trimmed = line.trim(); + if (trimmed.length === 0) return; + // Capture the grok session id from the terminal `end` event before mapping + // (mapStreamLine does not surface it). + captureSessionId(trimmed); + for (const part of mapStreamLine(trimmed, state)) { + emit(part as BridgeEvent); + } + }); + + // Forward stderr to this process's stderr so a CLI failure is inspectable + // from the host's bridge-stderr forwarding. + const stderrChunks: string[] = []; + childStderr.setEncoding('utf8'); + childStderr.on('data', (chunk: string) => { + stderrChunks.push(chunk); + process.stderr.write(chunk); + }); + + await new Promise((resolve, reject) => { + child.on('error', err => { + emit({ type: 'error', error: serialiseError(err) }); + reject(err); + }); + child.on('close', code => { + turn.abortSignal.removeEventListener('abort', onAbort); + // Aborted: treat as a clean wind-down (host already settles the turn). + if (turn.abortSignal.aborted) { + resolve(); + return; + } + if (code === 0) { + resolve(); + return; + } + const tail = stderrChunks.join('').trim().slice(-2000); + const err = new Error( + `grok CLI exited with code ${code}${tail ? `:\n${tail}` : ''}`, + ); + emit({ type: 'error', error: serialiseError(err) }); + reject(err); + }); + }); + + void turn.pendingUserMessages; // accepted but unused: each turn is a fresh CLI invocation. +} + +function captureSessionId(line: string): void { + try { + const msg = JSON.parse(line) as Record; + if ( + msg?.type === 'end' && + typeof msg.sessionId === 'string' && + msg.sessionId.length > 0 + ) { + sessionState.id = msg.sessionId; + } + } catch { + // Non-JSON / partial line — ignore. The stream-map handles malformed input. + } +} + +function parseArgs(rawArgs: string[]): { + workdir?: string; + bridgeStateDir?: string; + bootstrapDir?: string; +} { + const out: { + workdir?: string; + bridgeStateDir?: string; + bootstrapDir?: string; + } = {}; + for (let i = 0; i < rawArgs.length; i++) { + if (rawArgs[i] === '--workdir' && i + 1 < rawArgs.length) { + out.workdir = rawArgs[++i]; + } else if (rawArgs[i] === '--bridge-state-dir' && i + 1 < rawArgs.length) { + out.bridgeStateDir = rawArgs[++i]; + } else if (rawArgs[i] === '--bootstrap-dir' && i + 1 < rawArgs.length) { + out.bootstrapDir = rawArgs[++i]; + } + } + return out; +} + +function serialiseError(err: unknown): unknown { + if (err instanceof Error) { + return { name: err.name, message: err.message, stack: err.stack }; + } + return err; +} + +function emitFatal(message: string): never { + stdout.write(JSON.stringify({ type: 'bridge-fatal', message }) + '\n'); + process.exit(1); +} diff --git a/packages/harness-grok-build/src/bridge/package.json b/packages/harness-grok-build/src/bridge/package.json new file mode 100644 index 000000000000..d47b6d2397bc --- /dev/null +++ b/packages/harness-grok-build/src/bridge/package.json @@ -0,0 +1,11 @@ +{ + "name": "harness-grok-build-bridge", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@xai-official/grok": "0.2.51", + "ws": "8.21.0", + "zod": "3.25.76" + } +} diff --git a/packages/harness-grok-build/src/bridge/pnpm-lock.yaml b/packages/harness-grok-build/src/bridge/pnpm-lock.yaml new file mode 100644 index 000000000000..7fe70027a50e --- /dev/null +++ b/packages/harness-grok-build/src/bridge/pnpm-lock.yaml @@ -0,0 +1,113 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@xai-official/grok': + specifier: 0.2.51 + version: 0.2.51 + ws: + specifier: 8.21.0 + version: 8.21.0 + zod: + specifier: 3.25.76 + version: 3.25.76 + +packages: + + '@iarna/toml@3.0.0': + resolution: {integrity: sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==} + + '@xai-official/grok-darwin-arm64@0.2.51': + resolution: {integrity: sha512-HKkXN+1ui1P4SqRJNIWgjMZZEP47+1H+utNxD0R/cUPHOZd7oP7si/fNJMk8BVwTxkDtNuOtoIdHxt1TinwgmA==} + cpu: [arm64] + os: [darwin] + + '@xai-official/grok-darwin-x64@0.2.51': + resolution: {integrity: sha512-JAQ/VLnbkvhgvFMsmesX2HaCLcp+hEu88koLTG344rSJJ2VSCv5SZGYS6zTPlvBV5E54v/fq9RdMSahM2HLt5A==} + cpu: [x64] + os: [darwin] + + '@xai-official/grok-linux-arm64@0.2.51': + resolution: {integrity: sha512-pnAizhZolOYu9swOx7STTanzfWRm5vyW6jkh4TsQd0SjRDj8UsNgkRdhXS72Sk6dxfQd1/0BAROl8ogs1+/NYQ==} + cpu: [arm64] + os: [linux] + + '@xai-official/grok-linux-x64@0.2.51': + resolution: {integrity: sha512-TmJPsUETGgfxKyDg3ra7mmcdWIBIRgKDJ1NKPOj7RzkaskLv3QXiX7n8oXkxcLAoXsz9RRvsSCUMaDYFHDgrOQ==} + cpu: [x64] + os: [linux] + + '@xai-official/grok-win32-arm64@0.2.51': + resolution: {integrity: sha512-VTNoLVgtQAvvIx03fzqb51jga1czD7tjtl2l53DaeY23MakSr4VZp/2XTX+39J9rM2GnqwZ2xTL0oH48eoHtmQ==} + cpu: [arm64] + os: [win32] + + '@xai-official/grok-win32-x64@0.2.51': + resolution: {integrity: sha512-qp0krbn1GHuV+mlHes9v13NlmgAaI53QE8MpqiXr74Jyn4aho+vHbRAJbKTQsHcpoOeqAunl0zNbtRVyLxoVGw==} + cpu: [x64] + os: [win32] + + '@xai-official/grok@0.2.51': + resolution: {integrity: sha512-HZp/7PljrHeT/bKwzZ+fwOlKi9Q42O+kB9G5LA2Pv3xTLa1Sr2eIIqIMjb8e0cOC/qVsFZkEe/wNCqU+R/bnoA==} + engines: {node: '>=20'} + cpu: [arm64, x64] + os: [darwin, linux, win32] + hasBin: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@iarna/toml@3.0.0': {} + + '@xai-official/grok-darwin-arm64@0.2.51': + optional: true + + '@xai-official/grok-darwin-x64@0.2.51': + optional: true + + '@xai-official/grok-linux-arm64@0.2.51': + optional: true + + '@xai-official/grok-linux-x64@0.2.51': + optional: true + + '@xai-official/grok-win32-arm64@0.2.51': + optional: true + + '@xai-official/grok-win32-x64@0.2.51': + optional: true + + '@xai-official/grok@0.2.51': + dependencies: + '@iarna/toml': 3.0.0 + optionalDependencies: + '@xai-official/grok-darwin-arm64': 0.2.51 + '@xai-official/grok-darwin-x64': 0.2.51 + '@xai-official/grok-linux-arm64': 0.2.51 + '@xai-official/grok-linux-x64': 0.2.51 + '@xai-official/grok-win32-arm64': 0.2.51 + '@xai-official/grok-win32-x64': 0.2.51 + + ws@8.21.0: {} + + zod@3.25.76: {} diff --git a/packages/harness-grok-build/src/grok-build-auth.test.ts b/packages/harness-grok-build/src/grok-build-auth.test.ts new file mode 100644 index 000000000000..b0b6ee7b23e9 --- /dev/null +++ b/packages/harness-grok-build/src/grok-build-auth.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { resolveGrokBuildEnv, toGrokCliEnv } from './grok-build-auth'; + +describe('resolveGrokBuildEnv', () => { + it('uses explicit xai api key', () => { + const env = resolveGrokBuildEnv({ xai: { apiKey: 'sk-explicit' } }, {}); + expect(env.XAI_API_KEY).toBe('sk-explicit'); + }); + + it('falls back to XAI_API_KEY from process env', () => { + const env = resolveGrokBuildEnv(undefined, { XAI_API_KEY: 'sk-env' }); + expect(env.XAI_API_KEY).toBe('sk-env'); + }); + + it('prefers gateway auth from env over direct xai', () => { + const env = resolveGrokBuildEnv(undefined, { + AI_GATEWAY_API_KEY: 'gw-key', + XAI_API_KEY: 'sk-env', + }); + expect(env.AI_GATEWAY_API_KEY).toBe('gw-key'); + }); + + it('pins to explicit gateway when given', () => { + const env = resolveGrokBuildEnv( + { gateway: { apiKey: 'gw-explicit' } }, + { XAI_API_KEY: 'sk-env' }, + ); + expect(env.AI_GATEWAY_API_KEY).toBe('gw-explicit'); + }); + + it('passes through a custom base url', () => { + const env = resolveGrokBuildEnv( + { xai: { apiKey: 'k', baseUrl: 'https://x' } }, + {}, + ); + expect(env.XAI_BASE_URL).toBe('https://x'); + }); +}); + +describe('toGrokCliEnv', () => { + it('maps direct xai auth to XAI_API_KEY', () => { + const resolved = resolveGrokBuildEnv({ xai: { apiKey: 'sk-direct' } }, {}); + const cliEnv = toGrokCliEnv(resolved); + expect(cliEnv.XAI_API_KEY).toBe('sk-direct'); + expect(cliEnv.GROK_CODE_XAI_API_KEY).toBeUndefined(); + expect(cliEnv.GROK_MODELS_BASE_URL).toBeUndefined(); + }); + + it('forwards a direct custom base url', () => { + const cliEnv = toGrokCliEnv( + resolveGrokBuildEnv({ xai: { apiKey: 'k', baseUrl: 'https://x' } }, {}), + ); + expect(cliEnv.XAI_BASE_URL).toBe('https://x'); + }); + + it('maps gateway auth to GROK_CODE_XAI_API_KEY + GROK_MODELS_BASE_URL', () => { + const resolved = resolveGrokBuildEnv( + { gateway: { apiKey: 'gw-key', baseUrl: 'https://gateway.example/v1' } }, + {}, + ); + const cliEnv = toGrokCliEnv(resolved); + expect(cliEnv.GROK_CODE_XAI_API_KEY).toBe('gw-key'); + expect(cliEnv.GROK_MODELS_BASE_URL).toBe('https://gateway.example/v1'); + // The direct xAI var must not leak when routing through the gateway. + expect(cliEnv.XAI_API_KEY).toBeUndefined(); + }); + + it('appends /v1 to the gateway base url when missing', () => { + const resolved = resolveGrokBuildEnv(undefined, { + AI_GATEWAY_API_KEY: 'gw-key', + }); + const cliEnv = toGrokCliEnv(resolved); + expect(cliEnv.GROK_MODELS_BASE_URL).toBe('https://ai-gateway.vercel.sh/v1'); + }); +}); diff --git a/packages/harness-grok-build/src/grok-build-auth.ts b/packages/harness-grok-build/src/grok-build-auth.ts new file mode 100644 index 000000000000..d07e2008d08a --- /dev/null +++ b/packages/harness-grok-build/src/grok-build-auth.ts @@ -0,0 +1,93 @@ +import { getAiGatewayAuthFromEnv } from '@ai-sdk/harness/utils'; + +export type GrokBuildAuthOptions = { + readonly xai?: { + readonly apiKey?: string; + readonly baseUrl?: string; + }; + readonly gateway?: { + readonly apiKey?: string; + readonly baseUrl?: string; + }; +}; + +/** + * Resolve the environment-variable blob the bridge needs to authenticate the + * `grok` CLI (directly with xAI, or via the Vercel AI Gateway). Precedence: + * 1. Explicit `auth.xai` — pin to direct xAI auth. + * 2. Explicit `auth.gateway` — pin to gateway-routed auth. + * 3. Auto-detect: gateway first (`AI_GATEWAY_API_KEY` / `VERCEL_OIDC_TOKEN`), + * then direct (`XAI_API_KEY`). + */ +export function resolveGrokBuildEnv( + auth: GrokBuildAuthOptions | undefined, + processEnv: Record = process.env, +): Record { + if (auth?.xai) { + return pickXai(auth.xai, processEnv); + } + + const gatewayAuthFromEnv = getAiGatewayAuthFromEnv({ env: processEnv }); + if (auth?.gateway) { + return pickGateway(auth.gateway, gatewayAuthFromEnv); + } + if (gatewayAuthFromEnv.apiKey) { + return pickGateway({}, gatewayAuthFromEnv); + } + + return pickXai({}, processEnv); +} + +// Translate the resolved auth blob into the env vars the grok CLI reads. +export function toGrokCliEnv( + resolved: Record, +): Record { + const isGateway = resolved.AI_GATEWAY_API_KEY != null; + if (isGateway) { + const env: Record = {}; + const key = resolved.AI_GATEWAY_API_KEY; + const baseUrl = resolved.AI_GATEWAY_BASE_URL ?? resolved.XAI_BASE_URL; + if (key) env.GROK_CODE_XAI_API_KEY = key; + // grok's GROK_MODELS_BASE_URL must point at the gateway's `/v1` endpoint. + if (baseUrl) env.GROK_MODELS_BASE_URL = toGatewayV1BaseUrl(baseUrl); + return env; + } + const env: Record = {}; + if (resolved.XAI_API_KEY) env.XAI_API_KEY = resolved.XAI_API_KEY; + if (resolved.XAI_BASE_URL) env.XAI_BASE_URL = resolved.XAI_BASE_URL; + return env; +} + +function toGatewayV1BaseUrl(baseUrl: string): string { + const trimmed = baseUrl.replace(/\/+$/, ''); + return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`; +} + +function pickXai( + explicit: NonNullable, + processEnv: Record, +): Record { + const env: Record = {}; + const apiKey = explicit.apiKey ?? processEnv.XAI_API_KEY; + if (apiKey) env.XAI_API_KEY = apiKey; + const baseUrl = explicit.baseUrl ?? processEnv.XAI_BASE_URL; + if (baseUrl) env.XAI_BASE_URL = baseUrl; + return env; +} + +function pickGateway( + explicit: NonNullable, + gatewayAuthFromEnv: ReturnType, +): Record { + const apiKey = explicit.apiKey ?? gatewayAuthFromEnv.apiKey; + const baseUrl = explicit.baseUrl ?? gatewayAuthFromEnv.baseUrl; + const env: Record = {}; + if (apiKey) { + env.AI_GATEWAY_API_KEY = apiKey; + env.XAI_API_KEY = apiKey; + } + // Always forward the gateway base URL (mirrors claude-code-auth). + env.AI_GATEWAY_BASE_URL = baseUrl; + env.XAI_BASE_URL = baseUrl; + return env; +} diff --git a/packages/harness-grok-build/src/grok-build-bridge-protocol.test.ts b/packages/harness-grok-build/src/grok-build-bridge-protocol.test.ts new file mode 100644 index 000000000000..344b512270a2 --- /dev/null +++ b/packages/harness-grok-build/src/grok-build-bridge-protocol.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { + inboundMessageSchema, + startMessageSchema, +} from './grok-build-bridge-protocol'; + +describe('grok-build bridge protocol', () => { + it('accepts a minimal start message', () => { + const parsed = startMessageSchema.parse({ type: 'start', prompt: 'hi' }); + expect(parsed.type).toBe('start'); + }); + + it('accepts grok-specific fields', () => { + const parsed = startMessageSchema.parse({ + type: 'start', + prompt: 'hi', + model: 'grok-build-0.1', + continue: true, + }); + expect(parsed.model).toBe('grok-build-0.1'); + expect(parsed.continue).toBe(true); + }); + + it('discriminates start within the inbound union', () => { + const parsed = inboundMessageSchema.parse({ type: 'start', prompt: 'hi' }); + expect(parsed.type).toBe('start'); + }); +}); diff --git a/packages/harness-grok-build/src/grok-build-bridge-protocol.ts b/packages/harness-grok-build/src/grok-build-bridge-protocol.ts new file mode 100644 index 000000000000..85f42e287710 --- /dev/null +++ b/packages/harness-grok-build/src/grok-build-bridge-protocol.ts @@ -0,0 +1,27 @@ +import { + harnessV1BridgeInboundCommandSchemas, + harnessV1BridgeOutboundMessageSchema, + harnessV1BridgeReadySchema, + harnessV1BridgeStartBaseSchema, +} from '@ai-sdk/harness'; +import { z } from 'zod/v4'; + +// Bridge wire protocol. Everything but the grok-specific `start` payload comes from @ai-sdk/harness. +export const outboundMessageSchema = harnessV1BridgeOutboundMessageSchema; +export type OutboundMessage = z.infer; + +export const startMessageSchema = harnessV1BridgeStartBaseSchema.extend({ + model: z.string().optional(), + // Resume the prior CLI thread instead of a fresh session. + continue: z.boolean().optional(), +}); +export type StartMessage = z.infer; + +export const inboundMessageSchema = z.discriminatedUnion('type', [ + startMessageSchema, + ...harnessV1BridgeInboundCommandSchemas, +]); +export type InboundMessage = z.infer; + +export const bridgeReadySchema = harnessV1BridgeReadySchema; +export type BridgeReady = z.infer; diff --git a/packages/harness-grok-build/src/grok-build-harness.test.ts b/packages/harness-grok-build/src/grok-build-harness.test.ts new file mode 100644 index 000000000000..f1e76163bb7c --- /dev/null +++ b/packages/harness-grok-build/src/grok-build-harness.test.ts @@ -0,0 +1,442 @@ +import { + HarnessCapabilityUnsupportedError, + type HarnessV1NetworkSandboxSession, +} from '@ai-sdk/harness'; +import type * as HarnessUtils from '@ai-sdk/harness/utils'; +import type * as NodeFsPromises from 'node:fs/promises'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const sentMessages: Array> = []; +const openCalls: Array<{ resume?: boolean } | undefined> = []; + +vi.mock('@ai-sdk/harness/utils', async importOriginal => { + const actual = await importOriginal(); + class FakeSandboxChannel { + async open(opts?: { resume?: boolean }): Promise { + openCalls.push(opts); + } + on(): () => void { + return () => {}; + } + onClose(): void {} + send(msg: Record): void { + sentMessages.push(msg); + } + beginClose(): void {} + isClosed(): boolean { + return false; + } + close(): void {} + async suspend(): Promise { + return 0; + } + } + return { ...actual, SandboxChannel: FakeSandboxChannel }; +}); + +vi.mock('node:fs/promises', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + readFile: vi.fn(async (input: unknown, ...rest: unknown[]) => { + const p = String(input); + if (p.endsWith('/bridge/index.mjs')) return '// mock bridge\n'; + if (p.endsWith('/bridge/package.json')) return '{"name":"mock"}'; + if (p.endsWith('/bridge/pnpm-lock.yaml')) return 'lockfileVersion: 9\n'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (actual.readFile as any)(input, ...rest); + }), + }; +}); + +// eslint-disable-next-line import/first +import { + createGrokBuild, + GROK_BUILD_BUILTIN_TOOLS, + toCommonName, +} from './grok-build-harness'; + +function textStream(text: string): ReadableStream { + return new ReadableStream({ + start(controller) { + if (text.length > 0) { + controller.enqueue(new TextEncoder().encode(text)); + } + controller.close(); + }, + }); +} + +function fakeSandbox({ + spawnCalls, + runs, +}: { + spawnCalls: Array<{ command: string; env: Record }>; + runs: string[]; +}): HarnessV1NetworkSandboxSession { + const session = { + run: async ({ command }: { command: string }) => { + runs.push(command); + return { exitCode: 0, stdout: '', stderr: '' }; + }, + readTextFile: async () => null, + writeTextFile: async () => {}, + spawn: async ({ + command, + env, + }: { + command: string; + env: Record; + }) => { + spawnCalls.push({ command, env }); + return { + stdout: textStream('{"type":"bridge-ready","port":4319}\n'), + stderr: textStream(''), + kill: async () => {}, + wait: async () => ({ exitCode: 0 }), + }; + }, + }; + return { + id: 'test-sandbox', + defaultWorkingDirectory: '/vercel/sandbox', + restricted: () => session, + ports: [4319], + async getPortUrl() { + return 'ws://127.0.0.1:4319'; + }, + async stop() {}, + ...session, + } as unknown as HarnessV1NetworkSandboxSession; +} + +describe('grok-build builtin tools', () => { + it('exposes common tool names', () => { + expect(Object.keys(GROK_BUILD_BUILTIN_TOOLS)).toEqual( + expect.arrayContaining(['read', 'write', 'edit', 'bash']), + ); + }); + + it('maps a native name to its common name', () => { + expect(toCommonName('Read')).toBe('read'); + }); + + it('passes through unknown native names unchanged', () => { + expect(toCommonName('SomeGrokSpecificTool')).toBe('SomeGrokSpecificTool'); + }); +}); + +describe('grok-build bootstrap', () => { + it('produces a bootstrap recipe under the adapter-owned dir', async () => { + const harness = createGrokBuild(); + const bootstrap = await harness.getBootstrap!(); + expect(bootstrap.harnessId).toBe('grok-build'); + expect(bootstrap.bootstrapDir).toBe('/tmp/harness/grok-build'); + const paths = bootstrap.files.map(f => f.path); + expect(paths).toContain('/tmp/harness/grok-build/package.json'); + expect(paths).toContain('/tmp/harness/grok-build/pnpm-lock.yaml'); + expect(paths).toContain('/tmp/harness/grok-build/bridge.mjs'); + expect(bootstrap.commands.some(c => c.command.includes('pnpm'))).toBe(true); + }); +}); + +describe('grok-build doStart', () => { + beforeEach(() => { + sentMessages.length = 0; + openCalls.length = 0; + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('rejects built-in permission modes other than allow-all', async () => { + const harness = createGrokBuild(); + await expect( + harness.doStart({ + sessionId: 's1', + sandboxSession: {} as HarnessV1NetworkSandboxSession, + sessionWorkDir: '/vercel/sandbox/grok-s1', + permissionMode: 'allow-edits', + }), + ).rejects.toBeInstanceOf(HarnessCapabilityUnsupportedError); + }); + + it('throws when the network sandbox exposes no ports', async () => { + const harness = createGrokBuild(); + const sandboxSession = { + id: 'test-sandbox', + defaultWorkingDirectory: '/vercel/sandbox', + restricted: () => ({}) as never, + ports: [] as ReadonlyArray, + async getPortUrl() { + return ''; + }, + async stop() {}, + } as unknown as HarnessV1NetworkSandboxSession; + await expect( + harness.doStart({ + sessionId: 's1', + sandboxSession, + sessionWorkDir: '/vercel/sandbox/grok-s1', + }), + ).rejects.toBeInstanceOf(HarnessCapabilityUnsupportedError); + }); + + it('spawns the bridge with the workdir and mapped direct grok env', async () => { + const harness = createGrokBuild({ auth: { xai: { apiKey: 'sk-direct' } } }); + const spawnCalls: Array<{ + command: string; + env: Record; + }> = []; + const runs: string[] = []; + const session = await harness.doStart({ + sessionId: 's1', + sandboxSession: fakeSandbox({ spawnCalls, runs }), + sessionWorkDir: '/vercel/sandbox/grok-s1', + permissionMode: 'allow-all', + }); + + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0].command).toContain( + "node '/tmp/harness/grok-build/bridge.mjs' --workdir '/vercel/sandbox/grok-s1'", + ); + // Mapped direct-xai env var is forwarded to the bridge process. + expect(spawnCalls[0].env.XAI_API_KEY).toBe('sk-direct'); + expect(spawnCalls[0].env.BRIDGE_WS_PORT).toBe('4319'); + expect(spawnCalls[0].env.BRIDGE_CHANNEL_TOKEN).toBeTruthy(); + // Direct route pins the bare model id. + expect(session.modelId).toBe('grok-build-0.1'); + expect(session.isResume).toBe(false); + }); + + it('uses the gateway-prefixed model id and mapped gateway env', async () => { + const harness = createGrokBuild({ + auth: { gateway: { apiKey: 'gw-key', baseUrl: 'https://gw/v1' } }, + }); + const spawnCalls: Array<{ + command: string; + env: Record; + }> = []; + const runs: string[] = []; + const session = await harness.doStart({ + sessionId: 's1', + sandboxSession: fakeSandbox({ spawnCalls, runs }), + sessionWorkDir: '/vercel/sandbox/grok-s1', + permissionMode: 'allow-all', + }); + + expect(spawnCalls[0].env.GROK_CODE_XAI_API_KEY).toBe('gw-key'); + expect(spawnCalls[0].env.GROK_MODELS_BASE_URL).toBe('https://gw/v1'); + expect(spawnCalls[0].env.XAI_API_KEY).toBeUndefined(); + expect(session.modelId).toBe('xai/grok-build-0.1'); + }); + + it('sends a start message on doPromptTurn carrying the model', async () => { + const harness = createGrokBuild({ auth: { xai: { apiKey: 'sk' } } }); + const spawnCalls: Array<{ + command: string; + env: Record; + }> = []; + const runs: string[] = []; + const session = await harness.doStart({ + sessionId: 's1', + sandboxSession: fakeSandbox({ spawnCalls, runs }), + sessionWorkDir: '/vercel/sandbox/grok-s1', + permissionMode: 'allow-all', + }); + + await session.doPromptTurn({ prompt: 'hello', emit: () => {} }); + const start = sentMessages.find(m => m.type === 'start'); + expect(start).toMatchObject({ + type: 'start', + prompt: 'hello', + model: 'grok-build-0.1', + }); + }); + + it('sends a continuation start message with continue:true on doContinueTurn', async () => { + const harness = createGrokBuild({ auth: { xai: { apiKey: 'sk' } } }); + const spawnCalls: Array<{ + command: string; + env: Record; + }> = []; + const runs: string[] = []; + const session = await harness.doStart({ + sessionId: 's1', + sandboxSession: fakeSandbox({ spawnCalls, runs }), + sessionWorkDir: '/vercel/sandbox/grok-s1', + permissionMode: 'allow-all', + }); + + await session.doContinueTurn({ emit: () => {} }); + const start = sentMessages.find(m => m.type === 'start'); + expect(start).toMatchObject({ type: 'start', continue: true }); + }); + + it('reports isResume when resumeFrom carries a grok session id', async () => { + const harness = createGrokBuild({ auth: { xai: { apiKey: 'sk' } } }); + const spawnCalls: Array<{ + command: string; + env: Record; + }> = []; + const runs: string[] = []; + const session = await harness.doStart({ + sessionId: 's1', + sandboxSession: fakeSandbox({ spawnCalls, runs }), + sessionWorkDir: '/vercel/sandbox/grok-s1', + permissionMode: 'allow-all', + resumeFrom: { + type: 'resume-session', + harnessId: 'grok-build', + specificationVersion: 'harness-v1', + data: { sessionId: 'grok-sess-123' }, + }, + }); + expect(session.isResume).toBe(true); + }); + + it('attaches (no spawn) when resumeFrom carries live bridge coords', async () => { + const harness = createGrokBuild({ auth: { xai: { apiKey: 'sk' } } }); + const spawnCalls: Array<{ + command: string; + env: Record; + }> = []; + const runs: string[] = []; + const session = await harness.doStart({ + sessionId: 's1', + sandboxSession: fakeSandbox({ spawnCalls, runs }), + sessionWorkDir: '/vercel/sandbox/grok-s1', + permissionMode: 'allow-all', + resumeFrom: { + type: 'resume-session', + harnessId: 'grok-build', + specificationVersion: 'harness-v1', + data: { + sessionId: 'grok-sess-123', + bridge: { + port: 4319, + token: 'tok-abc', + lastSeenEventId: 7, + }, + }, + }, + }); + expect(spawnCalls).toHaveLength(0); + expect(openCalls).toEqual([{ resume: true }]); + expect(session.isResume).toBe(true); + }); + + it('spawns (fresh path) when resumeFrom has a session id but no bridge coords', async () => { + const harness = createGrokBuild({ auth: { xai: { apiKey: 'sk' } } }); + const spawnCalls: Array<{ + command: string; + env: Record; + }> = []; + const runs: string[] = []; + await harness.doStart({ + sessionId: 's1', + sandboxSession: fakeSandbox({ spawnCalls, runs }), + sessionWorkDir: '/vercel/sandbox/grok-s1', + permissionMode: 'allow-all', + resumeFrom: { + type: 'resume-session', + harnessId: 'grok-build', + specificationVersion: 'harness-v1', + data: { sessionId: 'grok-sess-123' }, + }, + }); + expect(spawnCalls).toHaveLength(1); + expect(openCalls).toEqual([undefined]); + }); + + it('spawns the bridge on a fresh start with no resumeFrom', async () => { + const harness = createGrokBuild({ auth: { xai: { apiKey: 'sk' } } }); + const spawnCalls: Array<{ + command: string; + env: Record; + }> = []; + const runs: string[] = []; + await harness.doStart({ + sessionId: 's1', + sandboxSession: fakeSandbox({ spawnCalls, runs }), + sessionWorkDir: '/vercel/sandbox/grok-s1', + permissionMode: 'allow-all', + }); + expect(spawnCalls).toHaveLength(1); + expect(openCalls).toEqual([undefined]); + }); + + it('continues the grok thread on the first prompt after resume, then stops', async () => { + const harness = createGrokBuild({ auth: { xai: { apiKey: 'sk' } } }); + const spawnCalls: Array<{ + command: string; + env: Record; + }> = []; + const runs: string[] = []; + const session = await harness.doStart({ + sessionId: 's1', + sandboxSession: fakeSandbox({ spawnCalls, runs }), + sessionWorkDir: '/vercel/sandbox/grok-s1', + permissionMode: 'allow-all', + resumeFrom: { + type: 'resume-session', + harnessId: 'grok-build', + specificationVersion: 'harness-v1', + data: { sessionId: 'grok-sess-123' }, + }, + }); + + await session.doPromptTurn({ prompt: 'first', emit: () => {} }); + await session.doPromptTurn({ prompt: 'second', emit: () => {} }); + const starts = sentMessages.filter(m => m.type === 'start'); + expect(starts[0]).toMatchObject({ prompt: 'first', continue: true }); + expect(starts[1]?.continue).toBeUndefined(); + }); + + it('applies instructions once, on the first fresh-session prompt', async () => { + const harness = createGrokBuild({ auth: { xai: { apiKey: 'sk' } } }); + const spawnCalls: Array<{ + command: string; + env: Record; + }> = []; + const runs: string[] = []; + const session = await harness.doStart({ + sessionId: 's1', + sandboxSession: fakeSandbox({ spawnCalls, runs }), + sessionWorkDir: '/vercel/sandbox/grok-s1', + permissionMode: 'allow-all', + }); + + await session.doPromptTurn({ + prompt: 'hello', + instructions: 'BE TERSE', + emit: () => {}, + }); + await session.doPromptTurn({ + prompt: 'again', + instructions: 'BE TERSE', + emit: () => {}, + }); + const starts = sentMessages.filter(m => m.type === 'start'); + expect(starts[0]?.prompt).toBe('BE TERSE\n\nhello'); + expect(starts[1]?.prompt).toBe('again'); + }); + + it('rejects manual compaction', async () => { + const harness = createGrokBuild({ auth: { xai: { apiKey: 'sk' } } }); + const spawnCalls: Array<{ + command: string; + env: Record; + }> = []; + const runs: string[] = []; + const session = await harness.doStart({ + sessionId: 's1', + sandboxSession: fakeSandbox({ spawnCalls, runs }), + sessionWorkDir: '/vercel/sandbox/grok-s1', + permissionMode: 'allow-all', + }); + await expect(session.doCompact!()).rejects.toBeInstanceOf( + HarnessCapabilityUnsupportedError, + ); + }); +}); diff --git a/packages/harness-grok-build/src/grok-build-harness.ts b/packages/harness-grok-build/src/grok-build-harness.ts new file mode 100644 index 000000000000..bfa1d1e75769 --- /dev/null +++ b/packages/harness-grok-build/src/grok-build-harness.ts @@ -0,0 +1,820 @@ +import { randomBytes } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { + commonTool, + HarnessCapabilityUnsupportedError, + harnessV1DiagnosticFromBridgeFrame, + type HarnessV1, + type HarnessV1Bootstrap, + type HarnessV1BuiltinTool, + type HarnessV1BuiltinToolName, + type HarnessV1DebugConfig, + type HarnessV1NetworkSandboxSession, + type HarnessV1PermissionMode, + type HarnessV1ResumeSessionState, + type HarnessV1Session, + type HarnessV1StreamPart, +} from '@ai-sdk/harness'; +import { + markBridgeStarting, + SandboxChannel, + waitForBridgeReady, +} from '@ai-sdk/harness/utils'; +import { type Experimental_SandboxProcess } from '@ai-sdk/provider-utils'; +import { WebSocket } from 'ws'; +import { z } from 'zod'; +import { + resolveGrokBuildEnv, + toGrokCliEnv, + type GrokBuildAuthOptions, +} from './grok-build-auth'; +import { + outboundMessageSchema, + type InboundMessage, + type OutboundMessage, +} from './grok-build-bridge-protocol'; + +type GrokBuildChannel = SandboxChannel; + +// Native tool name → common name. Placeholder names; reconcile with real CLI output. +export const NATIVE_TO_COMMON: Readonly< + Record +> = { + Read: 'read', + Write: 'write', + Edit: 'edit', + Bash: 'bash', + Glob: 'glob', + Grep: 'grep', + WebSearch: 'webSearch', +}; + +export function toCommonName( + nativeName: string, +): HarnessV1BuiltinToolName | string { + return NATIVE_TO_COMMON[nativeName] ?? nativeName; +} + +// Builtin tools, keyed by common name. Placeholder names/schemas; reconcile with real CLI output. +export const GROK_BUILD_BUILTIN_TOOLS = { + read: commonTool('read', { + nativeName: 'Read', + toolUseKind: 'readonly', + description: 'Read file contents (text, image, PDF, notebook)', + inputSchema: z.object({ + file_path: z.string(), + offset: z.number().optional(), + limit: z.number().optional(), + pages: z.string().optional(), + }), + }), + write: commonTool('write', { + nativeName: 'Write', + toolUseKind: 'edit', + description: 'Overwrite or create a file at an absolute path', + inputSchema: z.object({ + file_path: z.string(), + content: z.string(), + }), + }), + edit: commonTool('edit', { + nativeName: 'Edit', + toolUseKind: 'edit', + description: 'Edit a file by exact string replacement', + inputSchema: z.object({ + file_path: z.string(), + old_string: z.string(), + new_string: z.string(), + replace_all: z.boolean().optional(), + }), + }), + bash: commonTool('bash', { + nativeName: 'Bash', + toolUseKind: 'bash', + description: 'Execute a shell command', + inputSchema: z.object({ + command: z.string(), + timeout: z.number().optional(), + description: z.string().optional(), + run_in_background: z.boolean().optional(), + }), + }), + glob: commonTool('glob', { + nativeName: 'Glob', + toolUseKind: 'readonly', + description: 'Fast file-pattern search using glob syntax', + inputSchema: z.object({ + pattern: z.string(), + path: z.string().optional(), + }), + }), + grep: commonTool('grep', { + nativeName: 'Grep', + toolUseKind: 'readonly', + description: 'Regex search over file contents', + inputSchema: z.object({ + pattern: z.string(), + path: z.string().optional(), + }), + }), + webSearch: commonTool('webSearch', { + nativeName: 'WebSearch', + toolUseKind: 'readonly', + description: 'Issue web search queries', + inputSchema: z.object({ + query: z.string(), + allowed_domains: z.array(z.string()).optional(), + blocked_domains: z.array(z.string()).optional(), + }), + }), +} as const satisfies Record>; + +const BOOTSTRAP_DIR = '/tmp/harness/grok-build'; + +// Direct (xAI) uses the bare id; the gateway requires the `xai/` prefix. +const DEFAULT_GROK_MODEL_DIRECT = 'grok-build-0.1'; +const DEFAULT_GROK_MODEL_GATEWAY = 'xai/grok-build-0.1'; + +export type GrokBuildHarnessSettings = { + readonly model?: string; + readonly auth?: GrokBuildAuthOptions; + readonly port?: number; + /** Maximum milliseconds to wait for the bridge to advertise its port. Defaults to 120000. */ + readonly startupTimeoutMs?: number; +}; + +// `sessionId` (from the `end` event) feeds `-r/--resume`; `bridge` carries attach coords. +const lifecycleStateSchema = z.object({ + sessionId: z.string().optional(), + bridge: z + .object({ + port: z.number(), + token: z.string(), + lastSeenEventId: z.number(), + sandboxId: z.string().optional(), + }) + .optional(), +}); + +// Live bridge coordinates for cross-process attach (present on detach/suspend payloads). +type GrokBridgeCoords = NonNullable< + z.infer['bridge'] +>; + +async function readBridgeAsset(name: string): Promise { + const candidates = [ + new URL(`./bridge/${name}`, import.meta.url), + new URL(`../bridge/${name}`, import.meta.url), + ]; + let lastErr: unknown; + for (const url of candidates) { + try { + return await readFile(fileURLToPath(url), 'utf8'); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw err; + lastErr = err; + } + } + throw lastErr ?? new Error(`bridge asset not found: ${name}`); +} + +export function createGrokBuild( + settings: GrokBuildHarnessSettings = {}, +): HarnessV1 { + // Per-instance cache (in the closure, not module scope, to avoid cross-instance leakage). + let cachedBootstrap: HarnessV1Bootstrap | null = null; + return { + specificationVersion: 'harness-v1', + harnessId: 'grok-build', + builtinTools: GROK_BUILD_BUILTIN_TOOLS, + supportsBuiltinToolApprovals: false, + lifecycleStateSchema, + getBootstrap: async () => { + if (cachedBootstrap != null) return cachedBootstrap; + const [pkg, lock, bridge] = await Promise.all([ + readBridgeAsset('package.json'), + readBridgeAsset('pnpm-lock.yaml'), + readBridgeAsset('index.mjs'), + ]); + cachedBootstrap = { + harnessId: 'grok-build', + bootstrapDir: BOOTSTRAP_DIR, + files: [ + { path: `${BOOTSTRAP_DIR}/package.json`, content: pkg }, + { path: `${BOOTSTRAP_DIR}/pnpm-lock.yaml`, content: lock }, + { path: `${BOOTSTRAP_DIR}/bridge.mjs`, content: bridge }, + ], + commands: [ + { command: `mkdir -p ${BOOTSTRAP_DIR}` }, + { + command: `pnpm --dir ${BOOTSTRAP_DIR} install --frozen-lockfile --store-dir ${BOOTSTRAP_DIR}/.pnpm-store`, + }, + { + command: `cd ${BOOTSTRAP_DIR} && ./node_modules/.bin/grok --version`, + }, + ], + }; + return cachedBootstrap; + }, + doStart: async startOpts => { + if ( + startOpts.permissionMode != null && + startOpts.permissionMode !== 'allow-all' + ) { + throw new HarnessCapabilityUnsupportedError({ + message: + "Harness 'grok-build' does not support built-in tool approval requests; use permissionMode: 'allow-all'. The grok CLI runs with --always-approve and executes tools itself.", + harnessId: 'grok-build', + }); + } + + const sandboxSession = startOpts.sandboxSession; + const session = sandboxSession.restricted(); + const sandboxId = sandboxSession.id; + const lifecycleState = startOpts.continueFrom ?? startOpts.resumeFrom; + const isResume = lifecycleState != null; + const resumeData = + isResume && typeof lifecycleState?.data === 'object' + ? (lifecycleState.data as { + sessionId?: unknown; + bridge?: GrokBridgeCoords; + }) + : undefined; + const resumeSessionId = + typeof resumeData?.sessionId === 'string' && + resumeData.sessionId.length > 0 + ? resumeData.sessionId + : undefined; + const coords = resumeData?.bridge; + + const workDir = startOpts.sessionWorkDir; + const sessionDataDir = `${sandboxSession.defaultWorkingDirectory}/.agent-runs/${startOpts.sessionId}`; + const bridgeStateDir = `${sessionDataDir}/bridge`; + const timeoutMs = settings.startupTimeoutMs ?? 120_000; + + // Normalize forwarded bridge diagnostics frames and report them. + const report = startOpts.observability?.report; + const onDiagnostic = report + ? (frame: Parameters[0]) => + report( + harnessV1DiagnosticFromBridgeFrame(frame, { + sessionId: startOpts.sessionId, + timestamp: Date.now(), + }), + ) + : undefined; + + // Resolve auth → grok CLI env vars + the matching model id (direct vs gateway). + const resolvedAuth = resolveGrokBuildEnv(settings.auth); + const model = + settings.model ?? + (resolvedAuth.AI_GATEWAY_API_KEY != null + ? DEFAULT_GROK_MODEL_GATEWAY + : DEFAULT_GROK_MODEL_DIRECT); + + // Attach: reopen a socket to the still-running bridge instead of respawning. + if (coords) { + const attachUrl = + (await sandboxSession.getPortUrl({ + port: coords.port, + protocol: 'ws', + })) + `?agent_bridge_token=${encodeURIComponent(coords.token)}`; + const attachChannel: GrokBuildChannel = new SandboxChannel({ + connect: () => openWebSocket(attachUrl), + outboundSchema: outboundMessageSchema, + initialLastSeenEventId: coords.lastSeenEventId, + onDiagnostic, + }); + await attachChannel.open({ resume: true }); + return createSession({ + sessionId: startOpts.sessionId, + channel: attachChannel, + proc: undefined, // live bridge owned by another process + model, + isResume: true, + bridgePort: coords.port, + bridgeToken: coords.token, + sandboxId, + debug: startOpts.observability?.debug, + permissionMode: startOpts.permissionMode, + resumeGrokSessionId: resumeSessionId, + }); + } + + const grokEnv = toGrokCliEnv(resolvedAuth); + const port = resolveBridgePort(sandboxSession, settings.port); + const token = randomBytes(32).toString('hex'); + const env = { + ...grokEnv, + BRIDGE_CHANNEL_TOKEN: token, + BRIDGE_WS_PORT: String(port), + }; + + await session.run({ + command: `mkdir -p ${shellQuote(workDir)} ${shellQuote(bridgeStateDir)}`, + abortSignal: startOpts.abortSignal, + }); + + await markBridgeStarting({ + sandbox: session, + bridgeStateDir, + bridgeType: 'grok-build', + abortSignal: startOpts.abortSignal, + }); + + const proc = await session.spawn({ + command: `node ${shellQuote(`${BOOTSTRAP_DIR}/bridge.mjs`)} --workdir ${shellQuote(workDir)} --bridge-state-dir ${shellQuote(bridgeStateDir)} --bootstrap-dir ${shellQuote(BOOTSTRAP_DIR)}`, + env, + abortSignal: startOpts.abortSignal, + }); + + // Collect bridge stderr from spawn so a startup failure is diagnosable. + const startupStderr: string[] = []; + const stderrDone = forwardBridgeStderr( + proc.stderr, + startOpts.observability?.debug, + startupStderr, + ); + const withTail = async ( + message: string, + ctx: { stdoutTail: string[] }, + ): Promise => { + // Bounded waits so the timeout path (stream still open) never hangs. + await raceTimeout(stderrDone, 1000); + const result = (await raceTimeout(proc.wait(), 250)) as + | { exitCode?: number } + | undefined; + const exit = + result?.exitCode != null ? ` Exit code: ${result.exitCode}.` : ''; + const parts = [`${message}${exit}`]; + if (startupStderr.length > 0) { + parts.push(`stderr:\n${startupStderr.join('\n')}`); + } + if (ctx.stdoutTail.length > 0) { + parts.push(`stdout:\n${ctx.stdoutTail.join('\n')}`); + } + return new Error(parts.join('\n\n')); + }; + + const { port: boundPort } = await waitForBridgeReady({ + proc, + sandbox: session, + bridgeStateDir, + bridgeType: 'grok-build', + timeoutMs, + abortSignal: startOpts.abortSignal, + createTimeoutError: ctx => + withTail('grok-build bridge did not become ready in time.', ctx), + createExitError: ctx => + withTail('grok-build bridge exited before becoming ready.', ctx), + }); + void drainRest(proc.stdout); + + const wsUrl = + (await sandboxSession.getPortUrl({ + port: boundPort, + protocol: 'ws', + })) + `?agent_bridge_token=${encodeURIComponent(token)}`; + + const channel: GrokBuildChannel = new SandboxChannel({ + connect: () => openWebSocket(wsUrl), + outboundSchema: outboundMessageSchema, + onDiagnostic, + }); + await channel.open(); + + return createSession({ + sessionId: startOpts.sessionId, + channel, + proc, + model, + isResume, + bridgePort: boundPort, + bridgeToken: token, + sandboxId, + debug: startOpts.observability?.debug, + permissionMode: startOpts.permissionMode, + resumeGrokSessionId: resumeSessionId, + }); + }, + }; +} + +// Single-quote a value for safe use in a POSIX shell command +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function resolveBridgePort( + sandboxSession: HarnessV1NetworkSandboxSession, + override: number | undefined, +): number { + if (override !== undefined) return override; + if (sandboxSession.ports.length > 0) return sandboxSession.ports[0]; + throw new HarnessCapabilityUnsupportedError({ + harnessId: 'grok-build', + message: + 'The grok-build harness needs a TCP port exposed by the sandbox. ' + + 'Create the sandbox with `ports: []` or pass `createGrokBuild({ port })`.', + }); +} + +function openWebSocket(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const onOpen = () => { + ws.off('error', onError); + resolve(ws); + }; + const onError = (err: Error) => { + ws.off('open', onOpen); + reject(err); + }; + ws.once('open', onOpen); + ws.once('error', onError); + }); +} + +// Resolve a promise but give up after `ms`, swallowing rejections. +function raceTimeout(p: PromiseLike, ms: number): Promise { + return Promise.race([ + Promise.resolve(p).catch(() => undefined), + new Promise(resolve => setTimeout(resolve, ms)), + ]); +} + +// Collect a stderr tail for startup diagnostics; echo to terminal only under debug. +async function forwardBridgeStderr( + stream: ReadableStream, + debug: HarnessV1DebugConfig | undefined, + collectTail?: string[], +): Promise { + try { + const reader = stream.pipeThrough(new TextDecoderStream()).getReader(); + while (true) { + const { value, done } = await reader.read(); + if (done) return; + if (value) { + const trimmed = value.endsWith('\n') ? value.slice(0, -1) : value; + if (trimmed.length > 0) { + if (collectTail) { + collectTail.push(trimmed); + if (collectTail.length > 20) collectTail.shift(); + } + if (debug?.enabled === true) { + // eslint-disable-next-line no-console + console.log(`[bridge stderr] ${trimmed}`); + } + } + } + } + } catch { + // Reader errors are non-fatal — best-effort diagnostic only. + } +} + +async function drainRest(stream: ReadableStream): Promise { + try { + const reader = stream.pipeThrough(new TextDecoderStream()).getReader(); + while (true) { + const { done } = await reader.read(); + if (done) return; + } + } catch {} +} + +function createSession({ + sessionId, + channel, + proc, + model, + isResume, + bridgePort, + bridgeToken, + sandboxId, + debug, + permissionMode, + resumeGrokSessionId, +}: { + sessionId: string; + channel: GrokBuildChannel; + /** Undefined on `attach` — the live bridge was spawned by another process. */ + proc: Experimental_SandboxProcess | undefined; + model: string | undefined; + isResume: boolean; + bridgePort: number; + bridgeToken: string; + sandboxId: string; + debug: HarnessV1DebugConfig | undefined; + permissionMode: HarnessV1PermissionMode | undefined; + resumeGrokSessionId: string | undefined; +}): HarnessV1Session { + void debug; + void permissionMode; + let stopped = false; + let stopPromise: Promise | undefined; + + // Latest grok CLI session id; seeded from lifecycle state so detach/stop have an id pre-turn. + let latestGrokSessionId = resumeGrokSessionId; + + // On a resumed session, the first prompt must continue the prior grok thread. + let continueOnNextPrompt = isResume; + // Session-level instructions are applied once, on the first fresh-session prompt. + let instructionsApplied = false; + + // Wire the channel into one turn and return the control surface (shared by prompt/continue). + const wireTurn = (turnOpts: { + emit: (event: HarnessV1StreamPart) => void; + abortSignal?: AbortSignal; + }): { done: Promise } => { + let pendingResolve: (() => void) | undefined; + let pendingReject: ((err: unknown) => void) | undefined; + const done = new Promise((resolve, reject) => { + pendingResolve = resolve; + pendingReject = reject; + }); + + const unsubs: Array<() => void> = []; + const forward = (event: HarnessV1StreamPart) => { + try { + turnOpts.emit(event); + } catch {} + }; + + const eventTypes = [ + 'stream-start', + 'text-start', + 'text-delta', + 'text-end', + 'reasoning-start', + 'reasoning-delta', + 'reasoning-end', + 'tool-call', + 'tool-approval-request', + 'tool-result', + 'file-change', + 'finish-step', + 'raw', + ] as const; + let isSettled = false; + const settleSuccess = () => { + if (isSettled) return; + isSettled = true; + for (const u of unsubs) u(); + pendingResolve!(); + }; + const settleError = (err: unknown) => { + if (isSettled) return; + isSettled = true; + for (const u of unsubs) u(); + pendingReject!(err); + }; + + for (const type of eventTypes) { + unsubs.push(channel.on(type, msg => forward(msg))); + } + unsubs.push( + channel.on('finish', msg => { + forward(msg); + settleSuccess(); + }), + ); + unsubs.push( + channel.on('error', msg => { + forward(msg); + settleError(msg.error); + }), + ); + + const onClose = (_code?: number, reason?: string) => { + if (isSettled) return; + if (reason === 'suspended') { + settleSuccess(); + return; + } + settleError( + new Error('grok-build bridge closed before the turn finished.'), + ); + }; + channel.onClose(onClose); + + const onAbort = () => { + if (isSettled) return; + try { + channel.send({ type: 'abort' }); + } catch {} + settleError( + turnOpts.abortSignal?.reason ?? + new DOMException('Aborted', 'AbortError'), + ); + }; + if (turnOpts.abortSignal) { + if (turnOpts.abortSignal.aborted) { + onAbort(); + } else { + turnOpts.abortSignal.addEventListener('abort', onAbort, { + once: true, + }); + } + } + + return { done }; + }; + + // grok self-executes tools (`--always-approve`); these are unsupported no-ops. + const unsupportedToolControl = { + submitToolResult: async () => { + throw new HarnessCapabilityUnsupportedError({ + harnessId: 'grok-build', + message: + 'The grok-build harness executes tools inside the CLI (--always-approve); host tool results are not accepted.', + }); + }, + submitToolApproval: async () => { + throw new HarnessCapabilityUnsupportedError({ + harnessId: 'grok-build', + message: + 'The grok-build harness executes tools inside the CLI (--always-approve); host tool approvals are not accepted.', + }); + }, + }; + + return { + sessionId, + isResume, + modelId: model, + doPromptTurn: async promptOpts => { + const { done } = wireTurn({ + emit: promptOpts.emit, + abortSignal: promptOpts.abortSignal, + }); + let prompt = extractUserText(promptOpts.prompt); + // Apply session instructions once, on the first prompt of a fresh session. + if (promptOpts.instructions && !instructionsApplied && !isResume) { + prompt = `${promptOpts.instructions}\n\n${prompt}`; + } + instructionsApplied = true; + const shouldContinue = continueOnNextPrompt; + continueOnNextPrompt = false; + channel.send({ + type: 'start', + prompt, + ...(model ? { model } : {}), + ...(shouldContinue ? { continue: true } : {}), + }); + return { ...unsupportedToolControl, done }; + }, + doContinueTurn: async continueOpts => { + const { done } = wireTurn({ + emit: continueOpts.emit, + abortSignal: continueOpts.abortSignal, + }); + // No prompt on continue, but grok `-p` requires one — send a nudge with `-c`. + channel.send({ + type: 'start', + prompt: 'Continue.', + ...(model ? { model } : {}), + continue: true, + }); + return { ...unsupportedToolControl, done }; + }, + doCompact: async () => { + throw new HarnessCapabilityUnsupportedError({ + message: "Harness 'grok-build' does not support manual compaction.", + harnessId: 'grok-build', + }); + }, + doDetach: async () => { + if (stopped) { + throw new Error( + `grok-build session ${sessionId} is already stopped; cannot detach.`, + ); + } + stopped = true; + const lastSeenEventId = await channel.suspend(); + return { + type: 'resume-session', + harnessId: 'grok-build', + specificationVersion: 'harness-v1', + data: { + ...(latestGrokSessionId ? { sessionId: latestGrokSessionId } : {}), + bridge: { + port: bridgePort, + token: bridgeToken, + lastSeenEventId, + sandboxId, + }, + }, + } satisfies HarnessV1ResumeSessionState; + }, + doStop: async () => { + if (stopped) { + throw new Error( + `grok-build session ${sessionId} is already stopped; cannot stop.`, + ); + } + stopped = true; + channel.beginClose(); + let stopTimer: ReturnType | undefined; + try { + if (proc) { + await Promise.race([ + proc.wait(), + new Promise(resolve => { + stopTimer = setTimeout(resolve, 5000); + stopTimer.unref?.(); + }), + ]); + } + } finally { + if (stopTimer) clearTimeout(stopTimer); + try { + await proc?.kill(); + } catch {} + channel.close(); + } + return { + type: 'resume-session', + harnessId: 'grok-build', + specificationVersion: 'harness-v1', + data: latestGrokSessionId ? { sessionId: latestGrokSessionId } : {}, + } satisfies HarnessV1ResumeSessionState; + }, + doSuspendTurn: async () => { + if (stopped) { + throw new Error( + `grok-build session ${sessionId} is stopped; cannot suspend.`, + ); + } + stopped = true; + const lastSeenEventId = await channel.suspend(); + return { + type: 'continue-turn', + harnessId: 'grok-build', + specificationVersion: 'harness-v1', + data: { + ...(latestGrokSessionId ? { sessionId: latestGrokSessionId } : {}), + bridge: { + port: bridgePort, + token: bridgeToken, + lastSeenEventId, + sandboxId, + }, + }, + }; + }, + doDestroy: async () => { + if (stopped) return stopPromise; + stopped = true; + stopPromise = (async () => { + channel.beginClose(); + try { + if (!channel.isClosed()) { + channel.send({ type: 'shutdown' }); + } + } catch {} + let stopTimer: ReturnType | undefined; + try { + if (proc) { + await Promise.race([ + proc.wait(), + new Promise(resolve => { + stopTimer = setTimeout(resolve, 5000); + stopTimer.unref?.(); + }), + ]); + } + } finally { + if (stopTimer) clearTimeout(stopTimer); + try { + await proc?.kill(); + } catch {} + channel.close(); + } + })(); + return stopPromise; + }, + }; +} + +// Reduce a prompt to plain text for grok `-p`. File/image parts are unsupported — throw. +function extractUserText( + prompt: Parameters[0]['prompt'], +): string { + if (typeof prompt === 'string') return prompt; + const { content } = prompt; + if (typeof content === 'string') return content; + const parts: string[] = []; + for (const part of content) { + if (part.type !== 'text') { + throw new HarnessCapabilityUnsupportedError({ + harnessId: 'grok-build', + message: `The grok-build harness does not yet support user message parts of type '${part.type}'. Pass a string or a user message whose content contains only text parts.`, + }); + } + parts.push(part.text); + } + return parts.join('\n\n'); +} diff --git a/packages/harness-grok-build/src/grok-build-stream-map.test.ts b/packages/harness-grok-build/src/grok-build-stream-map.test.ts new file mode 100644 index 000000000000..80f055b9502d --- /dev/null +++ b/packages/harness-grok-build/src/grok-build-stream-map.test.ts @@ -0,0 +1,55 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createStreamMapState, mapStreamLine } from './grok-build-stream-map'; + +const lines = readFileSync( + join(__dirname, '__fixtures__/streaming-json-basic.jsonl'), + 'utf8', +) + .split('\n') + .filter(Boolean); + +const mapAll = () => { + const s = createStreamMapState(); + return lines.flatMap(l => mapStreamLine(l, s)); +}; + +describe('mapStreamLine (grok streaming-json)', () => { + it('emits exactly one stream-start', () => { + expect(mapAll().filter(p => p.type === 'stream-start')).toHaveLength(1); + }); + it('maps thought chunks to reasoning start/delta/end', () => { + const t = mapAll().map(p => p.type); + expect(t).toContain('reasoning-start'); + expect(t).toContain('reasoning-delta'); + expect(t).toContain('reasoning-end'); + }); + it('maps text chunks to text start/delta/end', () => { + const t = mapAll().map(p => p.type); + expect(t).toContain('text-start'); + expect(t).toContain('text-delta'); + expect(t).toContain('text-end'); + }); + it('reasoning ends before text starts (single ordered transition)', () => { + const types = mapAll().map(p => p.type); + const firstText = types.indexOf('text-start'); + const reasoningEnd = types.indexOf('reasoning-end'); + expect(reasoningEnd).toBeGreaterThanOrEqual(0); + expect(firstText).toBeGreaterThan(reasoningEnd); + }); + it('concatenated text deltas reconstruct the final answer', () => { + const text = mapAll() + .filter(p => p.type === 'text-delta') + .map((p: any) => p.delta) + .join(''); + expect(text).toContain('hello.txt'); + }); + it('emits a finish for the end event', () => { + const f = mapAll().find(p => p.type === 'finish'); + expect(f).toBeDefined(); + }); + it('never throws on malformed input', () => { + expect(mapStreamLine('not json', createStreamMapState())).toEqual([]); + }); +}); diff --git a/packages/harness-grok-build/src/grok-build-stream-map.ts b/packages/harness-grok-build/src/grok-build-stream-map.ts new file mode 100644 index 000000000000..9a6613918141 --- /dev/null +++ b/packages/harness-grok-build/src/grok-build-stream-map.ts @@ -0,0 +1,163 @@ +import type { HarnessV1StreamPart } from '@ai-sdk/harness'; + +// V4 types via the finish part shape (@ai-sdk/provider isn't a dependency). +type FinishPart = Extract; +type LanguageModelV4FinishReason = FinishPart['finishReason']; +type LanguageModelV4Usage = FinishPart['totalUsage']; + +export type StreamMapState = { + streamStarted: boolean; + openTextId: string | null; + openReasoningId: string | null; + nextId: number; +}; + +export function createStreamMapState(): StreamMapState { + return { + streamStarted: false, + openTextId: null, + openReasoningId: null, + nextId: 0, + }; +} + +function mintId(state: StreamMapState, prefix: string): string { + return `${prefix}_${state.nextId++}`; +} + +// streaming-json reports no token counts; `undefined` (not 0) signals "not reported". +function unknownUsage(): LanguageModelV4Usage { + return { + inputTokens: { + total: undefined, + noCache: undefined, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: undefined, text: undefined, reasoning: undefined }, + }; +} + +function mapStopReason(raw: string | undefined): LanguageModelV4FinishReason { + switch (raw) { + case 'EndTurn': + return { unified: 'stop', raw }; + case 'MaxTokens': + return { unified: 'length', raw }; + case 'ToolUse': + return { unified: 'tool-calls', raw }; + case 'ContentFilter': + return { unified: 'content-filter', raw }; + case 'Error': + return { unified: 'error', raw }; + default: + return { unified: 'other', raw }; + } +} + +// --------------------------------------------------------------------------- +// Core mapping +// --------------------------------------------------------------------------- + +// Map one streaming-json line (`thought`/`text`/`end`) to stream parts. Pure, never throws. +export function mapStreamLine( + rawLine: string, + state: StreamMapState, +): HarnessV1StreamPart[] { + // JSON.parse, not async safeParseJSON, since this runs per line synchronously. + let msg: unknown; + try { + msg = JSON.parse(rawLine); + } catch { + return []; + } + if (typeof msg !== 'object' || msg === null) return []; + + const anyMsg = msg as Record; + const eventType = anyMsg['type'] as string | undefined; + + const parts: HarnessV1StreamPart[] = []; + + function ensureStreamStart() { + if (!state.streamStarted) { + state.streamStarted = true; + parts.push({ type: 'stream-start' }); + } + } + + function closeTextBlock() { + if (state.openTextId !== null) { + parts.push({ type: 'text-end', id: state.openTextId }); + state.openTextId = null; + } + } + + function closeReasoningBlock() { + if (state.openReasoningId !== null) { + parts.push({ type: 'reasoning-end', id: state.openReasoningId }); + state.openReasoningId = null; + } + } + + ensureStreamStart(); + + switch (eventType) { + case 'thought': { + const data = typeof anyMsg['data'] === 'string' ? anyMsg['data'] : ''; + + closeTextBlock(); + if (state.openReasoningId === null) { + const id = mintId(state, 'reasoning'); + state.openReasoningId = id; + parts.push({ type: 'reasoning-start', id }); + } + + parts.push({ + type: 'reasoning-delta', + id: state.openReasoningId, + delta: data, + }); + break; + } + + case 'text': { + const data = typeof anyMsg['data'] === 'string' ? anyMsg['data'] : ''; + + closeReasoningBlock(); + if (state.openTextId === null) { + const id = mintId(state, 'text'); + state.openTextId = id; + parts.push({ type: 'text-start', id }); + } + + parts.push({ + type: 'text-delta', + id: state.openTextId, + delta: data, + }); + break; + } + + case 'end': { + // Close any open blocks. + closeReasoningBlock(); + closeTextBlock(); + + const stopReason = anyMsg['stopReason'] as string | undefined; + + parts.push({ + type: 'finish', + finishReason: mapStopReason(stopReason), + totalUsage: unknownUsage(), + }); + break; + } + + default: { + parts.push({ type: 'raw', rawValue: msg }); + break; + } + } + + return parts; +} diff --git a/packages/harness-grok-build/src/index.ts b/packages/harness-grok-build/src/index.ts new file mode 100644 index 000000000000..72c53b682376 --- /dev/null +++ b/packages/harness-grok-build/src/index.ts @@ -0,0 +1,12 @@ +import { createGrokBuild } from './grok-build-harness'; + +/** + * Default `grok-build` harness instance with no overrides — suitable for the + * common case where the underlying `grok` CLI's defaults are fine. + * Equivalent to `createGrokBuild()`. + */ +export const grokBuild = createGrokBuild(); + +export { createGrokBuild } from './grok-build-harness'; +export type { GrokBuildHarnessSettings } from './grok-build-harness'; +export type { GrokBuildAuthOptions } from './grok-build-auth'; diff --git a/packages/harness-grok-build/tsconfig.build.json b/packages/harness-grok-build/tsconfig.build.json new file mode 100644 index 000000000000..fbc3852add7a --- /dev/null +++ b/packages/harness-grok-build/tsconfig.build.json @@ -0,0 +1 @@ +{"extends": "./tsconfig.json", "compilerOptions": {"composite": false}} diff --git a/packages/harness-grok-build/tsconfig.json b/packages/harness-grok-build/tsconfig.json new file mode 100644 index 000000000000..2d31269fd57d --- /dev/null +++ b/packages/harness-grok-build/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./node_modules/@vercel/ai-tsconfig/ts-library.json", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist" + }, + "exclude": ["dist", "build", "node_modules", "tsup.config.ts"], + "references": [{ "path": "../harness" }, { "path": "../provider-utils" }] +} diff --git a/packages/harness-grok-build/tsup.config.ts b/packages/harness-grok-build/tsup.config.ts new file mode 100644 index 000000000000..46d75096eb81 --- /dev/null +++ b/packages/harness-grok-build/tsup.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'tsup'; +export default defineConfig([ + { + entry: { index: 'src/index.ts' }, + format: ['esm'], + target: 'es2022', + dts: true, + sourcemap: true, + }, + { + entry: { 'bridge/index': 'src/bridge/index.ts' }, + format: ['esm'], + target: 'es2022', + outExtension: () => ({ js: '.mjs' }), + dts: false, + sourcemap: true, + platform: 'node', + noExternal: ['@ai-sdk/harness'], + external: ['@xai-official/grok', 'ws', 'zod'], + }, +]); diff --git a/packages/harness-grok-build/turbo.json b/packages/harness-grok-build/turbo.json new file mode 100644 index 000000000000..a4dcd04164c8 --- /dev/null +++ b/packages/harness-grok-build/turbo.json @@ -0,0 +1 @@ +{"extends": ["//"], "tasks": {"build": {"outputs": ["**/dist/**"]}}} diff --git a/packages/harness-grok-build/vitest.node.config.js b/packages/harness-grok-build/vitest.node.config.js new file mode 100644 index 000000000000..346cc2a14c36 --- /dev/null +++ b/packages/harness-grok-build/vitest.node.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ + test: { + environment: 'node', + include: ['**/*.test.ts', '**/*.test.tsx'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 927edeee96f0..c69c9c14a307 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -307,6 +307,9 @@ importers: '@ai-sdk/harness-codex': specifier: workspace:* version: link:../../packages/harness-codex + '@ai-sdk/harness-grok-build': + specifier: workspace:* + version: link:../../packages/harness-grok-build '@ai-sdk/harness-pi': specifier: workspace:* version: link:../../packages/harness-pi @@ -599,6 +602,9 @@ importers: '@ai-sdk/harness-codex': specifier: workspace:* version: link:../../packages/harness-codex + '@ai-sdk/harness-grok-build': + specifier: workspace:* + version: link:../../packages/harness-grok-build '@ai-sdk/harness-pi': specifier: workspace:* version: link:../../packages/harness-pi @@ -678,6 +684,9 @@ importers: '@ai-sdk/harness-codex': specifier: workspace:* version: link:../../packages/harness-codex + '@ai-sdk/harness-grok-build': + specifier: workspace:* + version: link:../../packages/harness-grok-build '@ai-sdk/harness-pi': specifier: workspace:* version: link:../../packages/harness-pi @@ -2597,6 +2606,40 @@ importers: specifier: 5.8.3 version: 5.8.3 + packages/harness-grok-build: + dependencies: + '@ai-sdk/harness': + specifier: workspace:* + version: link:../harness + '@ai-sdk/provider-utils': + specifier: workspace:* + version: link:../provider-utils + ws: + specifier: ^8.21.0 + version: 8.21.0 + zod: + specifier: 3.25.76 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: 22.19.19 + version: 22.19.19 + '@types/ws': + specifier: ^8.5.13 + version: 8.18.1 + '@vercel/ai-tsconfig': + specifier: workspace:* + version: link:../../tools/tsconfig + '@xai-official/grok': + specifier: 0.2.51 + version: 0.2.51 + tsup: + specifier: ^8.5.1 + version: 8.5.1(@swc/core@1.15.3(@swc/helpers@0.5.21))(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.0)(typescript@5.8.3)(yaml@2.9.0) + typescript: + specifier: 5.8.3 + version: 5.8.3 + packages/harness-pi: dependencies: '@ai-sdk/harness': @@ -6440,6 +6483,9 @@ packages: hono: '>=3.9.0' zod: ^3.25.0 || ^4.0.0 + '@iarna/toml@3.0.0': + resolution: {integrity: sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==} + '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -12177,6 +12223,43 @@ packages: peerDependencies: zod: 4.3.6 + '@xai-official/grok-darwin-arm64@0.2.51': + resolution: {integrity: sha512-HKkXN+1ui1P4SqRJNIWgjMZZEP47+1H+utNxD0R/cUPHOZd7oP7si/fNJMk8BVwTxkDtNuOtoIdHxt1TinwgmA==} + cpu: [arm64] + os: [darwin] + + '@xai-official/grok-darwin-x64@0.2.51': + resolution: {integrity: sha512-JAQ/VLnbkvhgvFMsmesX2HaCLcp+hEu88koLTG344rSJJ2VSCv5SZGYS6zTPlvBV5E54v/fq9RdMSahM2HLt5A==} + cpu: [x64] + os: [darwin] + + '@xai-official/grok-linux-arm64@0.2.51': + resolution: {integrity: sha512-pnAizhZolOYu9swOx7STTanzfWRm5vyW6jkh4TsQd0SjRDj8UsNgkRdhXS72Sk6dxfQd1/0BAROl8ogs1+/NYQ==} + cpu: [arm64] + os: [linux] + + '@xai-official/grok-linux-x64@0.2.51': + resolution: {integrity: sha512-TmJPsUETGgfxKyDg3ra7mmcdWIBIRgKDJ1NKPOj7RzkaskLv3QXiX7n8oXkxcLAoXsz9RRvsSCUMaDYFHDgrOQ==} + cpu: [x64] + os: [linux] + + '@xai-official/grok-win32-arm64@0.2.51': + resolution: {integrity: sha512-VTNoLVgtQAvvIx03fzqb51jga1czD7tjtl2l53DaeY23MakSr4VZp/2XTX+39J9rM2GnqwZ2xTL0oH48eoHtmQ==} + cpu: [arm64] + os: [win32] + + '@xai-official/grok-win32-x64@0.2.51': + resolution: {integrity: sha512-qp0krbn1GHuV+mlHes9v13NlmgAaI53QE8MpqiXr74Jyn4aho+vHbRAJbKTQsHcpoOeqAunl0zNbtRVyLxoVGw==} + cpu: [x64] + os: [win32] + + '@xai-official/grok@0.2.51': + resolution: {integrity: sha512-HZp/7PljrHeT/bKwzZ+fwOlKi9Q42O+kB9G5LA2Pv3xTLa1Sr2eIIqIMjb8e0cOC/qVsFZkEe/wNCqU+R/bnoA==} + engines: {node: '>=20'} + cpu: [arm64, x64] + os: [darwin, linux, win32] + hasBin: true + '@xhmikosr/archive-type@8.1.0': resolution: {integrity: sha512-EXOjEbnZFE5c/nFMf4FOrEURVanzHpnkPYmnmr78u02/8hAhE0FMq8p9TK1IM0/bFr5VcyBUY0gfLm8f7dKy+Q==} engines: {node: '>=20'} @@ -24348,6 +24431,8 @@ snapshots: hono: 4.12.25 zod: 3.25.76 + '@iarna/toml@3.0.0': {} + '@iconify/types@2.0.0': {} '@iconify/utils@3.1.3': @@ -31277,6 +31362,35 @@ snapshots: ulid: 3.0.2 zod: 4.3.6 + '@xai-official/grok-darwin-arm64@0.2.51': + optional: true + + '@xai-official/grok-darwin-x64@0.2.51': + optional: true + + '@xai-official/grok-linux-arm64@0.2.51': + optional: true + + '@xai-official/grok-linux-x64@0.2.51': + optional: true + + '@xai-official/grok-win32-arm64@0.2.51': + optional: true + + '@xai-official/grok-win32-x64@0.2.51': + optional: true + + '@xai-official/grok@0.2.51': + dependencies: + '@iarna/toml': 3.0.0 + optionalDependencies: + '@xai-official/grok-darwin-arm64': 0.2.51 + '@xai-official/grok-darwin-x64': 0.2.51 + '@xai-official/grok-linux-arm64': 0.2.51 + '@xai-official/grok-linux-x64': 0.2.51 + '@xai-official/grok-win32-arm64': 0.2.51 + '@xai-official/grok-win32-x64': 0.2.51 + '@xhmikosr/archive-type@8.1.0': dependencies: file-type: 21.3.4 @@ -35732,7 +35846,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.20.1 + ws: 8.21.0 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -40934,7 +41048,7 @@ snapshots: picocolors: 1.1.1 postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.19.2)(yaml@2.9.0) resolve-from: 5.0.0 - rollup: 4.61.1 + rollup: 4.62.0 source-map: 0.7.6 sucrase: 3.35.1 tinyexec: 1.0.2 @@ -40963,7 +41077,7 @@ snapshots: picocolors: 1.1.1 postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.0)(yaml@2.9.0) resolve-from: 5.0.0 - rollup: 4.61.1 + rollup: 4.62.0 source-map: 0.7.6 sucrase: 3.35.1 tinyexec: 1.0.2 @@ -40992,7 +41106,7 @@ snapshots: picocolors: 1.1.1 postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.0)(yaml@2.9.0) resolve-from: 5.0.0 - rollup: 4.61.1 + rollup: 4.62.0 source-map: 0.7.6 sucrase: 3.35.1 tinyexec: 1.0.2 @@ -42072,7 +42186,7 @@ snapshots: sockjs: 0.3.24 spdy: 4.0.2 webpack-dev-middleware: 7.4.2(tslib@2.8.1)(webpack@5.105.0(esbuild@0.28.0)(lightningcss@1.32.0)(postcss@8.5.12)) - ws: 8.20.1 + ws: 8.21.0 optionalDependencies: webpack: 5.105.0(esbuild@0.28.0)(lightningcss@1.32.0)(postcss@8.5.12) transitivePeerDependencies: diff --git a/tsconfig.json b/tsconfig.json index e537f9b9c4fb..ae486df8b044 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -84,6 +84,9 @@ { "path": "packages/harness-codex" }, + { + "path": "packages/harness-grok-build" + }, { "path": "packages/harness-pi" },