From 444453152c015fa140bf47a575cccbf01fb47c35 Mon Sep 17 00:00:00 2001 From: Sanjay Ramadugu Date: Sat, 11 Jul 2026 17:09:49 -0400 Subject: [PATCH 1/4] feat: add Grok Build subscription CLI as a provider Add a `grok-build` provider that runs documentation jobs through the local Grok Build CLI (`grok`) using a subscription login instead of a metered API key. OpenWiki spawns the binary headlessly with `--always-approve --output-format streaming-json`, streams and coalesces its NDJSON events, and reuses the CLI's own session for follow-ups. - Model provider config becomes a discriminated union (api | agent-cli) so agent-CLI providers carry a binary/install hint instead of an API key; onboarding, credential setup, and startup checks skip the key collection for them. - New engines module: a generic CLI runner (temp prompt file, detached process group with SIGTERM/SIGKILL timeout, stream parsing) plus a Grok Build adapter and streaming-json parser. - Extract shared prepareAgentRun/finalizeAgentRun so the CLI and DeepAgents run paths share snapshot/metadata handling. - Docs and tests for the new provider, adapter, and runner. --- README.md | 43 +++- src/agent/engines/grok-build.ts | 330 ++++++++++++++++++++++++++++++ src/agent/engines/index.ts | 13 ++ src/agent/engines/runner.ts | 337 +++++++++++++++++++++++++++++++ src/agent/engines/types.ts | 42 ++++ src/agent/index.ts | 141 ++++++++++--- src/agent/prompt.ts | 20 +- src/cli.tsx | 44 ++-- src/constants.ts | 188 +++++++++++++++-- src/credentials.tsx | 33 ++- src/env.ts | 5 +- src/startup.ts | 45 +++-- test/agent-cli-runner.test.ts | 157 ++++++++++++++ test/grok-build-adapter.test.ts | 303 +++++++++++++++++++++++++++ test/grok-build-provider.test.ts | 90 +++++++++ 15 files changed, 1698 insertions(+), 93 deletions(-) create mode 100644 src/agent/engines/grok-build.ts create mode 100644 src/agent/engines/index.ts create mode 100644 src/agent/engines/runner.ts create mode 100644 src/agent/engines/types.ts create mode 100644 test/agent-cli-runner.test.ts create mode 100644 test/grok-build-adapter.test.ts create mode 100644 test/grok-build-provider.test.ts diff --git a/README.md b/README.md index 69037f62..d1424dff 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ notes. ## Customizing -OpenWiki supports OpenAI (with an API key or a ChatGPT login), OpenRouter, Nebius Token Factory, Fireworks, Baseten, NVIDIA NIM, an OpenAI-compatible provider, AWS Bedrock, Anthropic, and Google Vertex AI (Claude models) out of the box. The onboarding default is OpenAI with `gpt-5.6-terra`, and each inference provider also includes pre-defined model options plus support for custom model IDs. +OpenWiki supports OpenAI (with an API key or a ChatGPT login), Grok Build (subscription CLI), OpenRouter, Nebius Token Factory, Fireworks, Baseten, NVIDIA NIM, an OpenAI-compatible provider, AWS Bedrock, Anthropic, and Google Vertex AI (Claude models) out of the box. The onboarding default is OpenAI with `gpt-5.6-terra`, and each inference provider also includes pre-defined model options plus support for custom model IDs. ### Alternative base URLs @@ -320,6 +320,47 @@ For CI, authenticate before the update job runs — for example with Base URLs (and all credentials) can be set in your environment or stored in `~/.openwiki/.env`. +### Grok Build (subscription) + +The `grok-build` provider runs documentation jobs through the local [Grok Build](https://grok.com) +CLI (`grok`) using your Grok subscription login — no xAI API key required. OpenWiki +spawns `grok` headlessly with `--always-approve` and `--output-format streaming-json`, +and model usage draws on the same session as interactive Grok Build. + +Prerequisites: + +1. Install the Grok Build CLI and ensure `grok` is on your `PATH` (or set + `OPENWIKI_GROK_BUILD_BINARY` to the full path). +2. Run `grok login` once so the CLI has a valid subscription session. + +```bash +OPENWIKI_PROVIDER=grok-build openwiki code --init +# or +OPENWIKI_PROVIDER=grok-build openwiki personal --init +``` + +Optional overrides: + +```bash +# Binary path when `grok` is not on PATH +OPENWIKI_GROK_BUILD_BINARY=/path/to/grok + +# Model (defaults to grok-4.5) +OPENWIKI_MODEL_ID=grok-4.5 + +# Max agent turns for a documentation run (default 50) +OPENWIKI_GROK_BUILD_MAX_TURNS=50 + +# Overall run timeout in seconds (default 1800) +OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS=1800 +``` + +**Local / subscription only.** Prefer this provider on a machine where you already +use Grok Build interactively. It is not a drop-in for the scheduled GitHub Actions +or GitLab CI examples, which expect a metered API key and do not have a `grok login` +session. Runs use `--always-approve` so the CLI can write documentation files; +treat the model like any coding agent with write access to the repo. + ### Provider retry attempts OpenWiki uses LangChain's built-in retry handling for transient provider errors. diff --git a/src/agent/engines/grok-build.ts b/src/agent/engines/grok-build.ts new file mode 100644 index 00000000..38cc79cc --- /dev/null +++ b/src/agent/engines/grok-build.ts @@ -0,0 +1,330 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { + AgentCliAdapter, + AgentCliEvent, + AgentCliInstallStatus, + AgentCliStreamParser, + EngineRunSpec, +} from "./types.js"; + +const execFileAsync = promisify(execFile); + +/** Default max agent turns for documentation runs. Override with OPENWIKI_GROK_BUILD_MAX_TURNS. */ +export const DEFAULT_GROK_BUILD_MAX_TURNS = 50; + +/** + * Grok Build headless adapter. + * + * Auth is the CLI's own subscription login (`grok login` → ~/.grok/auth.json). + * OpenWiki never sees or stores an xAI API key for this provider. + * + * Streaming text is coalesced: Grok emits many partial `text` tokens across + * intermediate turns (planning, tool narration). OpenWiki only surfaces the + * text buffered after the last tool *start* (or the whole run if no tools), + * so `-p` / the TUI show a clean final answer instead of a jumbled stream. + */ +export const grokBuildAdapter: AgentCliAdapter = { + id: "grok-build", + + async detectInstall(binary: string): Promise { + try { + const { stdout } = await execFileAsync(binary, ["--version"], { + timeout: 15_000, + }); + + return { found: true, version: stdout.trim().split("\n")[0]?.trim() }; + } catch { + return { found: false }; + } + }, + + buildArgs(spec: EngineRunSpec, promptFilePath: string): string[] { + const args = [ + "--prompt-file", + promptFilePath, + "--output-format", + "streaming-json", + "--always-approve", + "--max-turns", + String(resolveMaxTurns()), + "--disable-web-search", + ]; + + if (spec.modelId.length > 0) { + args.push("--model", spec.modelId); + } + + if (spec.resumeSessionId) { + args.push("--resume", spec.resumeSessionId); + } + + return args; + }, + + createParser(): AgentCliStreamParser { + return createGrokBuildStreamParser(); + }, +}; + +/** + * Stateful parser for Grok Build streaming-json. + * + * Text is buffered. Tool *starts* (not tool ends) clear the buffer so + * pre-tool planning is dropped but a final answer that arrives before a + * trailing tool_end is kept. On `end` / flush the buffer is emitted once. + */ +export function createGrokBuildStreamParser(): AgentCliStreamParser { + let finalCandidate = ""; + let finished = false; + + function finalTextEvent(): AgentCliEvent[] { + const text = finalCandidate; + finalCandidate = ""; + + if (text.trim().length === 0) { + return []; + } + + return [ + { + type: "openwiki", + event: { source: "main", type: "text", text }, + }, + ]; + } + + return { + parse(line: unknown): AgentCliEvent[] { + if (finished) { + return []; + } + + if (!isRecord(line) || typeof line.type !== "string") { + if (isFinalResultObject(line)) { + finished = true; + return parseFinalResultObject(line); + } + + return []; + } + + if (line.type === "text" && typeof line.data === "string") { + finalCandidate += line.data; + return []; + } + + if (line.type === "thought") { + if (typeof line.data === "string" && line.data.length > 0) { + return [ + { + type: "openwiki", + event: { + type: "debug", + message: `grok-build.thought=${JSON.stringify(line.data)}`, + }, + }, + ]; + } + + return []; + } + + if ( + line.type === "tool_start" || + line.type === "tool_use" || + line.type === "tool_call" + ) { + // New tool use starts a work phase — drop pre-tool narration only. + finalCandidate = ""; + return parseToolStartEvent(line); + } + + if (line.type === "tool_end" || line.type === "tool_result") { + // Do not clear finalCandidate: trailing tool_end after the answer + // must not discard the buffered user-visible text. + return parseToolEndEvent(line); + } + + if (line.type === "error") { + finished = true; + const message = + typeof line.message === "string" && line.message.length > 0 + ? line.message + : typeof line.data === "string" && line.data.length > 0 + ? line.data + : "Grok Build reported an error."; + + return [{ type: "result", ok: false, errorMessage: message }]; + } + + if (line.type === "end") { + finished = true; + return [...finalTextEvent(), ...parseEndEvent(line)]; + } + + return []; + }, + + flush(): AgentCliEvent[] { + if (finished) { + return []; + } + + finished = true; + return finalTextEvent(); + }, + }; +} + +function resolveToolName(line: Record): string { + if (typeof line.name === "string" && line.name.length > 0) { + return line.name; + } + + if (typeof line.tool === "string" && line.tool.length > 0) { + return line.tool; + } + + return "tool"; +} + +function resolveToolId(line: Record, name: string): string { + if (typeof line.id === "string" && line.id.length > 0) { + return line.id; + } + + if (typeof line.toolCallId === "string" && line.toolCallId.length > 0) { + return line.toolCallId; + } + + // Same fallback as tool_start so toolNames.get(id) matches tool_end. + return name; +} + +function parseToolStartEvent(line: Record): AgentCliEvent[] { + const name = resolveToolName(line); + const id = resolveToolId(line, name); + + return [ + { + type: "openwiki", + event: { + type: "tool_start", + call: name, + id, + input: line.input ?? line.args ?? line.parameters, + name, + }, + }, + ]; +} + +function parseToolEndEvent(line: Record): AgentCliEvent[] { + const name = resolveToolName(line); + const id = resolveToolId(line, name); + + return [ + { + type: "openwiki", + event: { + type: "tool_end", + id, + name, + status: + line.is_error === true || line.error === true ? "error" : "finished", + }, + }, + ]; +} + +function resolveMaxTurns(): number { + const raw = process.env.OPENWIKI_GROK_BUILD_MAX_TURNS; + const parsed = raw === undefined ? Number.NaN : Number.parseInt(raw, 10); + + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_GROK_BUILD_MAX_TURNS; +} + +function parseEndEvent(line: Record): AgentCliEvent[] { + const events: AgentCliEvent[] = []; + + if (typeof line.sessionId === "string" && line.sessionId.length > 0) { + events.push({ type: "session", sessionId: line.sessionId }); + } + + const stopReason = + typeof line.stopReason === "string" ? line.stopReason : "unknown"; + const ok = isSuccessfulStopReason(stopReason); + + events.push({ + type: "result", + ok, + errorMessage: ok + ? undefined + : `Grok Build run ended with stopReason=${stopReason}.`, + }); + + return events; +} + +function isFinalResultObject(value: unknown): value is Record { + // Require stopReason plus either sessionId or text so mid-stream untyped + // objects cannot be mistaken for a terminal non-streaming result. + return ( + isRecord(value) && + value.type === undefined && + typeof value.stopReason === "string" && + value.stopReason.length > 0 && + (typeof value.sessionId === "string" || typeof value.text === "string") + ); +} + +function parseFinalResultObject( + line: Record, +): AgentCliEvent[] { + const events: AgentCliEvent[] = []; + + if (typeof line.text === "string" && line.text.length > 0) { + events.push({ + type: "openwiki", + event: { source: "main", type: "text", text: line.text }, + }); + } + + if (typeof line.sessionId === "string" && line.sessionId.length > 0) { + events.push({ type: "session", sessionId: line.sessionId }); + } + + const stopReason = + typeof line.stopReason === "string" ? line.stopReason : "EndTurn"; + const ok = isSuccessfulStopReason(stopReason); + + events.push({ + type: "result", + ok, + errorMessage: ok + ? undefined + : `Grok Build run ended with stopReason=${stopReason}.`, + }); + + return events; +} + +function isSuccessfulStopReason(stopReason: string): boolean { + const normalized = stopReason.toLowerCase(); + + // max_turns is intentionally NOT success: a truncated documentation run + // should fail so OpenWiki does not record .last-update.json for partial work. + return ( + normalized === "endturn" || + normalized === "end_turn" || + normalized === "completed" || + normalized === "success" + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/agent/engines/index.ts b/src/agent/engines/index.ts new file mode 100644 index 00000000..cb09b0f3 --- /dev/null +++ b/src/agent/engines/index.ts @@ -0,0 +1,13 @@ +import type { OpenWikiProvider } from "../../constants.js"; +import { grokBuildAdapter } from "./grok-build.js"; +import type { AgentCliAdapter } from "./types.js"; + +export function getAgentCliAdapter( + provider: OpenWikiProvider, +): AgentCliAdapter { + if (provider === "grok-build") { + return grokBuildAdapter; + } + + throw new Error(`No agent CLI adapter is registered for ${provider}.`); +} diff --git a/src/agent/engines/runner.ts b/src/agent/engines/runner.ts new file mode 100644 index 00000000..afc97e04 --- /dev/null +++ b/src/agent/engines/runner.ts @@ -0,0 +1,337 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { unlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createInterface } from "node:readline"; +import type { AgentCliProviderConfig } from "../../constants.js"; +import type { OpenWikiRunOptions } from "../types.js"; +import type { AgentCliAdapter, EngineRunSpec } from "./types.js"; + +const DEFAULT_TIMEOUT_SECONDS = 1800; +const STDERR_TAIL_LIMIT = 4000; + +type RunResult = { ok: boolean; errorMessage?: string } | null; + +const threadSessionIds = new Map(); + +// Detached agent-CLI children live in their own process group so the timeout +// path can kill the whole group with `process.kill(-pid)`. Track live group +// leaders so exit/signal handlers can clean them up if the parent dies mid-run. +const liveProcessGroupIds = new Set(); +let cleanupHandlersRegistered = false; + +/** Exposed for tests only. */ +export function getLiveProcessGroupIdsForTesting(): ReadonlySet { + return liveProcessGroupIds; +} + +function killLiveProcessGroups(): void { + for (const pid of liveProcessGroupIds) { + try { + process.kill(-pid, "SIGKILL"); + } catch { + // Already gone. + } + } +} + +function registerCleanupHandlersOnce(): void { + if (cleanupHandlersRegistered) { + return; + } + + cleanupHandlersRegistered = true; + + process.on("exit", () => { + try { + killLiveProcessGroups(); + } catch { + // Never throw from an exit handler. + } + }); + + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { + const onSignal = () => { + try { + killLiveProcessGroups(); + } catch { + // Never throw from a signal handler. + } finally { + process.removeListener(signal, onSignal); + process.kill(process.pid, signal); + } + }; + + process.on(signal, onSignal); + } +} + +export function getThreadSessionId(threadId: string): string | undefined { + return threadSessionIds.get(threadId); +} + +export function setThreadSessionId(threadId: string, sessionId: string): void { + threadSessionIds.set(threadId, sessionId); +} + +export type AgentCliRunOutcome = { + sessionId?: string; +}; + +export async function runAgentCli( + adapter: AgentCliAdapter, + providerConfig: AgentCliProviderConfig, + spec: EngineRunSpec, + options: OpenWikiRunOptions, +): Promise { + const binary = + process.env[providerConfig.binaryEnvKey]?.trim() || + providerConfig.defaultBinary; + const install = await adapter.detectInstall(binary); + + if (!install.found) { + throw new Error( + `Could not run the ${providerConfig.label} CLI (${binary}). ${providerConfig.installHint}`, + ); + } + + emitDebug( + options, + `engine=${adapter.id} binary=${binary} version=${install.version ?? "unknown"}`, + ); + + const promptFilePath = path.join( + os.tmpdir(), + `openwiki-agent-cli-${randomUUID()}.md`, + ); + await writeFile(promptFilePath, spec.prompt, { + encoding: "utf8", + mode: 0o600, + }); + + const timeoutSeconds = resolveTimeoutSeconds(); + const outcome: AgentCliRunOutcome = {}; + const toolNames = new Map(); + let result: RunResult = null; + let stderrTail = ""; + let timedOut = false; + let sigkillTimer: ReturnType | undefined; + + function readResult(): RunResult { + return result; + } + + try { + const child = spawn(binary, adapter.buildArgs(spec, promptFilePath), { + cwd: spec.cwd, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + registerCleanupHandlersOnce(); + + if (child.pid !== undefined) { + liveProcessGroupIds.add(child.pid); + } + + const timeout = setTimeout(() => { + timedOut = true; + sigkillTimer = killProcessGroup(child.pid); + }, timeoutSeconds * 1000); + + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_LIMIT); + }); + + const streamParser = adapter.createParser(); + + const handleParsedEvents = ( + events: ReturnType, + ): void => { + for (const event of events) { + if (event.type === "session") { + outcome.sessionId = event.sessionId; + continue; + } + + if (event.type === "result") { + result = { ok: event.ok, errorMessage: event.errorMessage }; + continue; + } + + if (event.event.type === "tool_start") { + toolNames.set(event.event.id, event.event.name); + options.onEvent?.(event.event); + continue; + } + + if (event.event.type === "tool_end") { + options.onEvent?.({ + ...event.event, + name: toolNames.get(event.event.id) ?? event.event.name, + }); + continue; + } + + if (event.event.type === "debug") { + emitDebug(options, event.event.message); + continue; + } + + options.onEvent?.(event.event); + } + }; + + const lines = createInterface({ input: child.stdout }); + + lines.on("line", (line) => { + const trimmed = line.trim(); + + if (trimmed.length === 0) { + return; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(trimmed); + } catch { + emitDebug( + options, + `engine.unparsedLine=${JSON.stringify(trimmed.slice(0, 200))}`, + ); + return; + } + + handleParsedEvents(streamParser.parse(parsed)); + }); + + let spawnErrorMessage: string | undefined; + + const exitCodePromise = new Promise((resolve) => { + child.on("close", (code) => { + if (child.pid !== undefined) { + liveProcessGroupIds.delete(child.pid); + } + if (sigkillTimer !== undefined) { + clearTimeout(sigkillTimer); + sigkillTimer = undefined; + } + resolve(code); + }); + child.on("error", (error) => { + spawnErrorMessage = error.message; + resolve(null); + }); + }); + + // Wait for both process exit and readline EOF so the final NDJSON line is + // parsed before flush/success decisions (stdout may not end on a newline). + const stdoutDone = new Promise((resolve) => { + lines.on("close", () => { + resolve(); + }); + }); + + const exitCode = await exitCodePromise; + await stdoutDone; + + // Flush any text buffered when the process exits without a terminal `end`. + handleParsedEvents(streamParser.flush()); + + clearTimeout(timeout); + + if (timedOut) { + throw new Error( + `${providerConfig.label} run timed out after ${timeoutSeconds} seconds. Set OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS to allow longer runs.`, + ); + } + + const finalResult = readResult(); + + if (finalResult?.ok) { + return outcome; + } + + throw new Error( + formatRunFailure( + providerConfig, + finalResult, + exitCode, + stderrTail, + spawnErrorMessage, + ), + ); + } finally { + await unlink(promptFilePath).catch(() => { + // Temp file may already be gone. + }); + } +} + +function formatRunFailure( + providerConfig: AgentCliProviderConfig, + result: RunResult, + exitCode: number | null, + stderrTail: string, + spawnErrorMessage?: string, +): string { + const summary = + result?.errorMessage ?? + (spawnErrorMessage !== undefined + ? `${providerConfig.label} run failed to start: ${spawnErrorMessage}` + : `${providerConfig.label} run failed (exit code ${exitCode ?? "unknown"}) without reporting a result.`); + const stderr = stderrTail.trim(); + const loginHint = /login|api key|authenticat|logged out/iu.test( + `${summary} ${stderr}`, + ) + ? ` ${providerConfig.installHint}` + : ""; + + return `${summary}${loginHint}${stderr.length > 0 ? `\nstderr: ${stderr}` : ""}`; +} + +function resolveTimeoutSeconds(): number { + const raw = process.env.OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS; + const parsed = raw === undefined ? Number.NaN : Number.parseInt(raw, 10); + + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_TIMEOUT_SECONDS; +} + +/** + * Sends SIGTERM to the process group, then schedules SIGKILL. Returns the + * SIGKILL timer so callers can clear it if the child exits cleanly. + */ +function killProcessGroup( + pid: number | undefined, +): ReturnType | undefined { + if (pid === undefined) { + return undefined; + } + + try { + process.kill(-pid, "SIGTERM"); + } catch { + // The process may already have exited. + } + + return setTimeout(() => { + try { + process.kill(-pid, "SIGKILL"); + } catch { + // Already gone. + } + }, 5000).unref(); +} + +function emitDebug(options: OpenWikiRunOptions, message: string): void { + if (!options.debug) { + return; + } + + options.onEvent?.({ type: "debug", message }); +} diff --git a/src/agent/engines/types.ts b/src/agent/engines/types.ts new file mode 100644 index 00000000..5870d610 --- /dev/null +++ b/src/agent/engines/types.ts @@ -0,0 +1,42 @@ +import type { OpenWikiCommand, OpenWikiRunEvent } from "../types.js"; + +export type EngineRunSpec = { + command: OpenWikiCommand; + cwd: string; + /** Fully assembled prompt, delivered via a temporary prompt file. */ + prompt: string; + /** Vendor session id to resume for interactive follow-ups. */ + resumeSessionId?: string; + modelId: string; +}; + +export type AgentCliEvent = + | { type: "openwiki"; event: OpenWikiRunEvent } + | { type: "session"; sessionId: string } + | { type: "result"; ok: boolean; errorMessage?: string }; + +export type AgentCliInstallStatus = { + found: boolean; + version?: string; +}; + +/** + * Stateful stream parser. Holds buffer across NDJSON lines so partial text + * tokens can be coalesced before OpenWiki surfaces them. + */ +export type AgentCliStreamParser = { + parse(line: unknown): AgentCliEvent[]; + /** Flush any remaining buffered output once the process exits. */ + flush(): AgentCliEvent[]; +}; + +export type AgentCliAdapter = { + id: string; + detectInstall(binary: string): Promise; + /** + * Builds CLI args. The runner writes `spec.prompt` to a temp file and passes + * its path as `promptFilePath` so long system+user prompts do not hit ARG_MAX. + */ + buildArgs(spec: EngineRunSpec, promptFilePath: string): string[]; + createParser(): AgentCliStreamParser; +}; diff --git a/src/agent/index.ts b/src/agent/index.ts index c554efa0..44789c5c 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -33,6 +33,13 @@ import { readCodexTokensFromEnv, refreshChatGptTokens, } from "./openai-chatgpt-oauth.js"; +import { getAgentCliAdapter } from "./engines/index.js"; +import { + getThreadSessionId, + runAgentCli, + setThreadSessionId, +} from "./engines/runner.js"; +import type { EngineRunSpec } from "./engines/types.js"; import { createSystemPrompt, createUserPrompt } from "./prompt.js"; import { syncBundledSkills } from "./skills.js"; import type { @@ -46,6 +53,7 @@ import { ANTHROPIC_API_KEY_ENV_KEY, ANTHROPIC_BASE_URL_ENV_KEY, BEDROCK_AWS_REGION_ENV_KEY, + getAgentCliProviderConfig, getDefaultModelId, getMissingProviderEnvKey, getProviderApiKeyEnvKey, @@ -56,6 +64,7 @@ import { getProviderRegionEnvKey, getProviderSecretKeyEnvKey, GOOGLE_CLOUD_PROJECT_ENV_KEY, + isAgentCliProvider, isValidModelId, normalizeModelId, OPENAI_COMPATIBLE_BASE_URL_ENV_KEY, @@ -125,8 +134,22 @@ export async function runOpenWikiAgent( } const provider = resolveConfiguredProvider(); - const providerBaseUrl = resolveProviderBaseUrl(provider); emitDebug(options, `provider=${provider}`); + + if (isAgentCliProvider(provider)) { + const agentCliModelId = resolveModelId(options, provider); + emitDebug(options, `model=${agentCliModelId}`); + + return runAgentCliRun( + command, + runtimeCwd, + options, + provider, + agentCliModelId, + ); + } + + const providerBaseUrl = resolveProviderBaseUrl(provider); if (providerBaseUrl) { emitDebug(options, `provider.baseUrl=${JSON.stringify(providerBaseUrl)}`); } @@ -166,14 +189,18 @@ export async function runOpenWikiAgent( } } -async function runOpenWikiAgentCore( +type PreparedAgentRun = { + context: Awaited>; + openWikiSnapshotBefore: string | null; + outputMode: OpenWikiOutputMode; + threadId: string; +}; + +async function prepareAgentRun( command: OpenWikiCommand, cwd: string, options: OpenWikiRunOptions, - provider: OpenWikiProvider, - modelId: string, - providerRetryAttempts: number, -): Promise { +): Promise { const outputMode = options.outputMode ?? "local-wiki"; const context = await createRunContext(command, cwd, outputMode); emitDebug(options, "context=created"); @@ -182,11 +209,58 @@ async function runOpenWikiAgentCore( ? null : await createOpenWikiContentSnapshot(cwd, outputMode); emitDebug(options, "openwiki.snapshot=created"); + const threadId = options.threadId ?? createThreadId(cwd, createRunThreadId()); + emitDebug(options, `thread=${threadId}`); + + return { context, openWikiSnapshotBefore, outputMode, threadId }; +} + +async function finalizeAgentRun( + command: OpenWikiCommand, + cwd: string, + modelId: string, + options: OpenWikiRunOptions, + prepared: PreparedAgentRun, +): Promise { + const { openWikiSnapshotBefore, outputMode } = prepared; + const metadataWritten = await persistRunMetadataIfChanged( + command, + cwd, + modelId, + outputMode, + openWikiSnapshotBefore, + ); + + if (metadataWritten) { + emitDebug(options, "metadata=written"); + } else { + emitDebug( + options, + command === "chat" + ? "metadata=skipped command=chat" + : "metadata=skipped openwiki=unchanged", + ); + } + + return { + command, + model: modelId, + }; +} + +async function runOpenWikiAgentCore( + command: OpenWikiCommand, + cwd: string, + options: OpenWikiRunOptions, + provider: OpenWikiProvider, + modelId: string, + providerRetryAttempts: number, +): Promise { + const prepared = await prepareAgentRun(command, cwd, options); + const { context, openWikiSnapshotBefore, outputMode, threadId } = prepared; const model = createModel(provider, modelId, providerRetryAttempts); emitDebug(options, `model.provider=${provider}`); emitDebug(options, "model=initialized"); - const threadId = options.threadId ?? createThreadId(cwd, createRunThreadId()); - emitDebug(options, `thread=${threadId}`); const checkpointTarget = resolveCheckpointTarget(command); const checkpointer = await createCheckpointer(checkpointTarget); emitDebug( @@ -288,29 +362,48 @@ async function runOpenWikiAgentCore( await chmodIfExists(checkpointTarget.connString, 0o600); } - const metadataWritten = await persistRunMetadataIfChanged( + return finalizeAgentRun(command, cwd, modelId, options, prepared); +} + +async function runAgentCliRun( + command: OpenWikiCommand, + cwd: string, + options: OpenWikiRunOptions, + provider: OpenWikiProvider, + modelId: string, +): Promise { + const prepared = await prepareAgentRun(command, cwd, options); + const { context, outputMode, threadId } = prepared; + const resumeSessionId = + options.isFollowup === true ? getThreadSessionId(threadId) : undefined; + + if (resumeSessionId) { + emitDebug(options, `engine.resume session=${resumeSessionId}`); + } + + // Path discipline for agent-cli lives in createSystemPrompt(..., "agent-cli"); + // the user message reuses the same root/cwd framing as DeepAgents. + const prompt = `${createSystemPrompt(command, outputMode, "agent-cli")}\n\n---\n\n${createRunUserMessage(command, cwd, context, options)}`; + const spec: EngineRunSpec = { command, cwd, modelId, - outputMode, - openWikiSnapshotBefore, + prompt, + resumeSessionId, + }; + + const outcome = await runAgentCli( + getAgentCliAdapter(provider), + getAgentCliProviderConfig(provider), + spec, + options, ); - if (metadataWritten) { - emitDebug(options, "metadata=written"); - } else { - emitDebug( - options, - command === "chat" - ? "metadata=skipped command=chat" - : "metadata=skipped openwiki=unchanged", - ); + if (outcome.sessionId) { + setThreadSessionId(threadId, outcome.sessionId); } - return { - command, - model: modelId, - }; + return finalizeAgentRun(command, cwd, modelId, options, prepared); } const checkpointPath = path.join(openWikiEnvDir, "openwiki.sqlite"); diff --git a/src/agent/prompt.ts b/src/agent/prompt.ts index 843ed126..8db30b59 100644 --- a/src/agent/prompt.ts +++ b/src/agent/prompt.ts @@ -13,13 +13,16 @@ function formatLastUpdate(lastUpdate: UpdateMetadata | null): string { return JSON.stringify(lastUpdate, null, 2); } +export type PromptEngine = "deepagents" | "agent-cli"; + export function createSystemPrompt( command: OpenWikiCommand, outputMode: OpenWikiOutputMode = "local-wiki", + engine: PromptEngine = "deepagents", ): string { const output = getOutputPromptConfig(outputMode); - return ` + const prompt = ` You are OpenWiki, an expert technical writer, software architect, and product analyst. Your job is to inspect the relevant source evidence and local OpenWiki knowledge sources, then produce documentation in ${output.docsLocation} that is excellent for both humans and future agents. OpenWiki can maintain a local general-purpose knowledge wiki from connector raw dumps under ~/.openwiki. @@ -164,8 +167,21 @@ Coverage self-check: Mode-specific behavior: ${createModeInstructions(command, outputMode)} `.trim(); -} + if (engine === "agent-cli") { + return `${prompt} + +Agent CLI runtime note: +- Your working directory is the runtime root for this run. +- Prefer repository-relative paths with your file tools (for example README.md and openwiki/quickstart.md in code mode). +- Do not rely on DeepAgents virtual paths that start with a lone /. +- Do not read or modify files outside the runtime root unless a source-specific instruction explicitly requires it. +- For init/update documentation runs, write only wiki/docs outputs under openwiki/ (and the OpenWiki blocks in top-level AGENTS.md / CLAUDE.md when required). Do not refactor application source, change tests, or edit unrelated project files. +`.trim(); + } + + return prompt; +} export function createModeInstructions( command: OpenWikiCommand, outputMode: OpenWikiOutputMode = "local-wiki", diff --git a/src/cli.tsx b/src/cli.tsx index 264f3b51..920e7710 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -63,6 +63,7 @@ import { type ScheduleMutationResult, } from "./schedules.js"; import { + formatProviderSwitchNotice, getDefaultModelId, getMissingProviderEnvKey, getProviderApiKeyEnvKey, @@ -70,6 +71,7 @@ import { getProviderLabel, getProviderModelOptions, getProviderProjectEnvKey, + isAgentCliProvider, isValidModelId, normalizeModelId, normalizeProvider, @@ -401,18 +403,20 @@ function App({ command }: AppProps) { return; } - const missingEnvKey = getMissingProviderEnvKey(sessionProvider); + if (!isAgentCliProvider(sessionProvider)) { + const missingEnvKey = getMissingProviderEnvKey(sessionProvider); - if (missingEnvKey && !process.stdin.isTTY) { - const hint = getProviderCredentialHint(sessionProvider); + if (missingEnvKey && !process.stdin.isTTY) { + const hint = getProviderCredentialHint(sessionProvider); - setRunState({ - status: "error", - message: `${missingEnvKey} is required. Run openwiki in an interactive terminal to save credentials.${ - hint ? ` ${hint}` : "" - }`, - }); - return; + setRunState({ + status: "error", + message: `${missingEnvKey} is required. Run openwiki in an interactive terminal to save credentials.${ + hint ? ` ${hint}` : "" + }`, + }); + return; + } } if (shouldRunInteractiveCredentialSetup) { @@ -1778,6 +1782,13 @@ function ChatInput({ } if (option.id === "api-key") { + if (isAgentCliProvider(currentProvider)) { + setError( + `${getProviderLabel(currentProvider)} uses the local Grok Build CLI login. Run \`grok login\` instead of pasting an API key.`, + ); + return; + } + if (args && args.length > 0) { setError( "Use the masked prompt for API keys; do not pass keys inline.", @@ -1931,18 +1942,7 @@ function ChatInput({ try { await onProviderSelect(provider); resetInput(); - const apiKeyEnvKey = getProviderApiKeyEnvKey(provider); - const requirement = apiKeyEnvKey - ? `Ensure ${apiKeyEnvKey} is set.` - : `Ensure ${getProviderProjectEnvKey(provider)} is set. ${getProviderCredentialHint(provider) ?? ""}`.trim(); - const modelNotice = - getProviderModelOptions(provider).length > 0 - ? ` with model ${getDefaultModelId(provider)}` - : ". Set a model with /model"; - - setNotice( - `Provider switched to ${getProviderLabel(provider)}${modelNotice}. ${requirement}`, - ); + setNotice(formatProviderSwitchNotice(provider)); } catch (saveError) { setError( saveError instanceof Error diff --git a/src/constants.ts b/src/constants.ts index 8e61641e..6d108db0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -27,6 +27,7 @@ export const GOOGLE_CLOUD_LOCATION_ENV_KEY = "GOOGLE_CLOUD_LOCATION"; export const GOOGLE_APPLICATION_CREDENTIALS_ENV_KEY = "GOOGLE_APPLICATION_CREDENTIALS"; export const DEFAULT_VERTEX_LOCATION = "global"; +export const GROK_BUILD_BINARY_ENV_KEY = "OPENWIKI_GROK_BUILD_BINARY"; export const OPENWIKI_PROVIDER_ENV_KEY = "OPENWIKI_PROVIDER"; export const OPENWIKI_MODEL_ID_ENV_KEY = "OPENWIKI_MODEL_ID"; export const NEBIUS_BASE_URL = "https://api.tokenfactory.nebius.com/v1/"; @@ -69,6 +70,7 @@ export type OpenWikiProvider = | "baseten" | "bedrock" | "fireworks" + | "grok-build" | "nebius" | "nvidia" | "openai" @@ -80,9 +82,10 @@ export type OpenWikiProvider = /** * How a provider authenticates. Providers default to `"api-key"` (a pasted * secret persisted to a `*_API_KEY` env var); `"oauth"` providers instead run a - * browser login flow and persist short-lived access/refresh tokens. + * browser login flow and persist short-lived access/refresh tokens; + * `"cli-login"` providers use a local vendor CLI session (no OpenWiki secret). */ -export type ProviderAuthMethod = "api-key" | "oauth"; +export type ProviderAuthMethod = "api-key" | "oauth" | "cli-login"; export type SelectableOpenWikiProvider = OpenWikiProvider; @@ -104,7 +107,8 @@ const OPENAI_MODEL_OPTIONS: ProviderModelOption[] = [ { id: "gpt-5.4-mini", label: "5.4 mini" }, ]; -type ProviderConfig = { +export type ApiProviderConfig = { + kind: "api"; /** * Environment variable holding the provider's API key. Absent when the * provider authenticates without an API key (e.g. Google Application @@ -119,13 +123,13 @@ type ProviderConfig = { authMethod?: ProviderAuthMethod; baseURL?: string; /** - * Environment variable that, when set, overrides {@link ProviderConfig.baseURL} + * Environment variable that, when set, overrides {@link ApiProviderConfig.baseURL} * with an alternative base URL (e.g. a self-hosted or proxied endpoint). */ baseUrlEnvKey?: string; /** * When true, the provider has no default endpoint and requires a base URL to - * be supplied via {@link ProviderConfig.baseUrlEnvKey}. + * be supplied via {@link ApiProviderConfig.baseUrlEnvKey}. */ requiresBaseUrl?: boolean; /** @@ -134,7 +138,7 @@ type ProviderConfig = { */ projectEnvKey?: string; /** - * Environment variable that overrides {@link ProviderConfig.defaultLocation} + * Environment variable that overrides {@link ApiProviderConfig.defaultLocation} * with an alternative cloud location/region. */ locationEnvKey?: string; @@ -143,26 +147,45 @@ type ProviderConfig = { modelOptions: ProviderModelOption[]; /** * Environment variable holding a second required secret (e.g. an AWS secret - * access key paired with {@link ProviderConfig.apiKeyEnvKey} as an access key + * access key paired with {@link ApiProviderConfig.apiKeyEnvKey} as an access key * ID). Omitted for providers authenticated by a single API key. */ secretKeyEnvKey?: string; /** * Environment variable holding the provider's region (e.g. an AWS region). - * Only relevant when {@link ProviderConfig.requiresRegion} is true. + * Only relevant when {@link ApiProviderConfig.requiresRegion} is true. */ regionEnvKey?: string; /** * When true, the provider has no default region and requires one to be - * supplied via {@link ProviderConfig.regionEnvKey}. + * supplied via {@link ApiProviderConfig.regionEnvKey}. */ requiresRegion?: boolean; }; +/** + * Subscription-authenticated local agent CLIs (for example Grok Build). These + * providers have no API key; OpenWiki spawns the vendor binary and uses the + * CLI's own login session. + */ +export type AgentCliProviderConfig = { + kind: "agent-cli"; + /** Environment variable that overrides the default binary path. */ + binaryEnvKey: string; + defaultBinary: string; + /** Shown when the binary is missing or not logged in. */ + installHint: string; + label: string; + modelOptions: ProviderModelOption[]; +}; + +export type ProviderConfig = ApiProviderConfig | AgentCliProviderConfig; + export const SELECTABLE_OPENWIKI_PROVIDERS = [ "openai", "openai-chatgpt", "anthropic", + "grok-build", "vertex", "openrouter", "openai-compatible", @@ -175,6 +198,7 @@ export const SELECTABLE_OPENWIKI_PROVIDERS = [ export const PROVIDER_CONFIGS: Record = { baseten: { + kind: "api", apiKeyEnvKey: BASETEN_API_KEY_ENV_KEY, baseURL: "https://inference.baseten.co/v1", label: "Baseten", @@ -184,6 +208,7 @@ export const PROVIDER_CONFIGS: Record = { ], }, bedrock: { + kind: "api", apiKeyEnvKey: BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY, label: "AWS Bedrock", // Available model IDs are account- and region-specific (they depend on @@ -196,6 +221,7 @@ export const PROVIDER_CONFIGS: Record = { requiresRegion: true, }, fireworks: { + kind: "api", apiKeyEnvKey: FIREWORKS_API_KEY_ENV_KEY, baseURL: "https://api.fireworks.ai/inference/v1", label: "Fireworks", @@ -207,13 +233,27 @@ export const PROVIDER_CONFIGS: Record = { }, ], }, + "grok-build": { + kind: "agent-cli", + binaryEnvKey: GROK_BUILD_BINARY_ENV_KEY, + defaultBinary: "grok", + installHint: + "Install the Grok Build CLI, then run `grok login` so OpenWiki can use your subscription session.", + label: "Grok Build (subscription)", + modelOptions: [ + { id: "grok-4.5", label: "Grok 4.5" }, + { id: "grok-composer-2.5-fast", label: "Composer 2.5 Fast" }, + ], + }, nebius: { + kind: "api", apiKeyEnvKey: NEBIUS_API_KEY_ENV_KEY, baseURL: NEBIUS_BASE_URL, label: "Nebius Token Factory", modelOptions: [{ id: "moonshotai/Kimi-K2.6", label: "Kimi K2.6" }], }, nvidia: { + kind: "api", apiKeyEnvKey: NVIDIA_API_KEY_ENV_KEY, baseURL: "https://integrate.api.nvidia.com/v1", label: "NVIDIA NIM", @@ -236,17 +276,20 @@ export const PROVIDER_CONFIGS: Record = { ], }, openai: { + kind: "api", apiKeyEnvKey: OPENAI_API_KEY_ENV_KEY, label: "OpenAI", modelOptions: OPENAI_MODEL_OPTIONS, }, "openai-chatgpt": { + kind: "api", apiKeyEnvKey: OPENAI_CHATGPT_ACCESS_TOKEN_ENV_KEY, authMethod: "oauth", label: "OpenAI (ChatGPT login)", modelOptions: OPENAI_MODEL_OPTIONS, }, "openai-compatible": { + kind: "api", apiKeyEnvKey: OPENAI_COMPATIBLE_API_KEY_ENV_KEY, baseUrlEnvKey: OPENAI_COMPATIBLE_BASE_URL_ENV_KEY, requiresBaseUrl: true, @@ -254,6 +297,7 @@ export const PROVIDER_CONFIGS: Record = { modelOptions: [], }, anthropic: { + kind: "api", apiKeyEnvKey: ANTHROPIC_API_KEY_ENV_KEY, baseUrlEnvKey: ANTHROPIC_BASE_URL_ENV_KEY, label: "Anthropic", @@ -264,6 +308,7 @@ export const PROVIDER_CONFIGS: Record = { ], }, vertex: { + kind: "api", projectEnvKey: GOOGLE_CLOUD_PROJECT_ENV_KEY, locationEnvKey: GOOGLE_CLOUD_LOCATION_ENV_KEY, defaultLocation: DEFAULT_VERTEX_LOCATION, @@ -275,6 +320,7 @@ export const PROVIDER_CONFIGS: Record = { ], }, openrouter: { + kind: "api", apiKeyEnvKey: OPENROUTER_API_KEY_ENV_KEY, baseURL: OPENROUTER_BASE_URL, label: "OpenRouter", @@ -305,16 +351,50 @@ export function getProviderLabel(provider: OpenWikiProvider): string { return getProviderConfig(provider).label; } +export function isAgentCliProvider(provider: OpenWikiProvider): boolean { + return getProviderConfig(provider).kind === "agent-cli"; +} + +export function getAgentCliProviderConfig( + provider: OpenWikiProvider, +): AgentCliProviderConfig { + const config = getProviderConfig(provider); + + if (config.kind !== "agent-cli") { + throw new Error(`${provider} is not an agent CLI provider.`); + } + + return config; +} + +function getApiProviderConfig(provider: OpenWikiProvider): ApiProviderConfig { + const config = getProviderConfig(provider); + + if (config.kind !== "api") { + throw new Error( + `${provider} is an agent CLI provider and has no API key configuration.`, + ); + } + + return config; +} + export function getProviderApiKeyEnvKey( provider: OpenWikiProvider, ): string | undefined { - return getProviderConfig(provider).apiKeyEnvKey; + return getApiProviderConfig(provider).apiKeyEnvKey; } export function getProviderAuthMethod( provider: OpenWikiProvider, ): ProviderAuthMethod { - return getProviderConfig(provider).authMethod ?? "api-key"; + const config = getProviderConfig(provider); + + if (config.kind === "agent-cli") { + return "cli-login"; + } + + return config.authMethod ?? "api-key"; } export function providerUsesOAuth(provider: OpenWikiProvider): boolean { @@ -322,19 +402,57 @@ export function providerUsesOAuth(provider: OpenWikiProvider): boolean { } export function providerRequiresApiKey(provider: OpenWikiProvider): boolean { - return getProviderConfig(provider).apiKeyEnvKey !== undefined; + const config = getProviderConfig(provider); + + return config.kind === "api" && config.apiKeyEnvKey !== undefined; +} + +/** + * Composes the in-session notice shown after switching providers. Agent CLI + * providers have no API key, so their notice points at the local CLI login + * instead of an API-key environment variable. + */ +export function formatProviderSwitchNotice(provider: OpenWikiProvider): string { + const modelOptions = getProviderModelOptions(provider); + const modelNotice = + modelOptions.length > 0 + ? ` with model ${getDefaultModelId(provider)}` + : ". Set a model with /model"; + const switched = `Provider switched to ${getProviderLabel(provider)}${modelNotice}.`; + + if (isAgentCliProvider(provider)) { + return `${switched} Runs use the local agent CLI login.`; + } + + const apiKeyEnvKey = getProviderApiKeyEnvKey(provider); + + if (apiKeyEnvKey) { + return `${switched} Ensure ${apiKeyEnvKey} is set.`; + } + + const projectEnvKey = getProviderProjectEnvKey(provider); + const hint = getProviderCredentialHint(provider); + const requirement = projectEnvKey + ? `Ensure ${projectEnvKey} is set.${hint ? ` ${hint}` : ""}` + : (hint ?? ""); + + return requirement ? `${switched} ${requirement}` : switched; } export function getProviderProjectEnvKey( provider: OpenWikiProvider, ): string | undefined { - return getProviderConfig(provider).projectEnvKey; + const config = getProviderConfig(provider); + + return config.kind === "api" ? config.projectEnvKey : undefined; } export function getProviderLocationEnvKey( provider: OpenWikiProvider, ): string | undefined { - return getProviderConfig(provider).locationEnvKey; + const config = getProviderConfig(provider); + + return config.kind === "api" ? config.locationEnvKey : undefined; } /** @@ -342,6 +460,8 @@ export function getProviderLocationEnvKey( * (its API key, or its cloud project for providers that authenticate without * one), or `null` when the provider has everything it needs to run. Base URL * requirements are checked separately via {@link providerRequiresBaseUrl}. + * Agent CLI providers never report a missing key here — auth is the vendor CLI + * login, which is checked when the binary is spawned. */ export function getMissingProviderEnvKey( provider: OpenWikiProvider, @@ -349,6 +469,10 @@ export function getMissingProviderEnvKey( ): string | null { const config = getProviderConfig(provider); + if (config.kind === "agent-cli") { + return null; + } + if (config.apiKeyEnvKey && !env[config.apiKeyEnvKey]) { return config.apiKeyEnvKey; } @@ -370,6 +494,11 @@ export function resolveProviderLocation( env: NodeJS.ProcessEnv = process.env, ): string | undefined { const config = getProviderConfig(provider); + + if (config.kind !== "api") { + return undefined; + } + const override = config.locationEnvKey ? env[config.locationEnvKey] : undefined; @@ -397,6 +526,10 @@ export function getProviderCredentialHint( ); } + if (isAgentCliProvider(provider)) { + return getAgentCliProviderConfig(provider).installHint; + } + return null; } @@ -411,6 +544,11 @@ export function resolveProviderBaseUrl( env: NodeJS.ProcessEnv = process.env, ): string | undefined { const config = getProviderConfig(provider); + + if (config.kind !== "api") { + return undefined; + } + const override = config.baseUrlEnvKey ? env[config.baseUrlEnvKey] : undefined; const trimmedOverride = override?.trim(); @@ -424,31 +562,41 @@ export function resolveProviderBaseUrl( export function getProviderBaseUrlEnvKey( provider: OpenWikiProvider, ): string | undefined { - return getProviderConfig(provider).baseUrlEnvKey; + const config = getProviderConfig(provider); + + return config.kind === "api" ? config.baseUrlEnvKey : undefined; } export function providerRequiresBaseUrl(provider: OpenWikiProvider): boolean { - return getProviderConfig(provider).requiresBaseUrl === true; + const config = getProviderConfig(provider); + + return config.kind === "api" && config.requiresBaseUrl === true; } export function getProviderSecretKeyEnvKey( provider: OpenWikiProvider, ): string | undefined { - return getProviderConfig(provider).secretKeyEnvKey; + const config = getProviderConfig(provider); + + return config.kind === "api" ? config.secretKeyEnvKey : undefined; } export function providerRequiresSecretKey(provider: OpenWikiProvider): boolean { - return getProviderConfig(provider).secretKeyEnvKey !== undefined; + return getProviderSecretKeyEnvKey(provider) !== undefined; } export function getProviderRegionEnvKey( provider: OpenWikiProvider, ): string | undefined { - return getProviderConfig(provider).regionEnvKey; + const config = getProviderConfig(provider); + + return config.kind === "api" ? config.regionEnvKey : undefined; } export function providerRequiresRegion(provider: OpenWikiProvider): boolean { - return getProviderConfig(provider).requiresRegion === true; + const config = getProviderConfig(provider); + + return config.kind === "api" && config.requiresRegion === true; } /** diff --git a/src/credentials.tsx b/src/credentials.tsx index f47b080a..d3304022 100644 --- a/src/credentials.tsx +++ b/src/credentials.tsx @@ -19,6 +19,7 @@ import { getProviderProjectEnvKey, getProviderRegionEnvKey, getProviderSecretKeyEnvKey, + isAgentCliProvider, providerRequiresApiKey, isValidBaseUrl, isValidModelId, @@ -397,9 +398,14 @@ export function needsCredentialSetup( * Whether the provider still needs its primary credential collected. For * `oauth` providers this is a valid, non-expired stored token; for API-key * providers it is a pasted key; for keyless providers (vertex) it is the - * required GCP project id. + * required GCP project id. Agent CLI providers authenticate through the + * vendor CLI login and never collect a credential here. */ function needsCredentialStep(provider: OpenWikiProvider): boolean { + if (isAgentCliProvider(provider)) { + return false; + } + return providerUsesOAuth(provider) ? !hasValidStoredToken() : getMissingProviderEnvKey(provider) !== null; @@ -469,6 +475,11 @@ function isRegionConfigured(provider: OpenWikiProvider): boolean { } function isCredentialConfigured(provider: OpenWikiProvider): boolean { + if (isAgentCliProvider(provider)) { + // Auth is the local vendor CLI login; OpenWiki does not hold a key. + return true; + } + return providerUsesOAuth(provider) ? hasValidStoredToken() : getMissingProviderEnvKey(provider) === null; @@ -478,6 +489,10 @@ function getCredentialSetupDetail( provider: OpenWikiProvider, tokens: CodexTokens | null = null, ): string { + if (isAgentCliProvider(provider)) { + return "uses local Grok Build CLI login (`grok login`)"; + } + if (providerUsesOAuth(provider)) { if (!isCredentialConfigured(provider) && !tokens) { return "sign in with your ChatGPT account"; @@ -1889,7 +1904,9 @@ export function InitSetup({ mode: options.runMode, runIngestionNow: false, savedApiKey: - options.nextApiKey !== null || options.nextOAuthTokens != null, + isAgentCliProvider(options.nextProvider) || + options.nextApiKey !== null || + options.nextOAuthTokens != null, savedBaseUrl: options.nextBaseUrl !== null, savedRegion: options.nextRegion !== null, savedSecretKey: options.nextSecretKey !== null, @@ -2246,7 +2263,9 @@ export function InitSetup({ (modelIdOverride === null && process.env[OPENWIKI_MODEL_ID_ENV_KEY] === undefined) || process.env.LANGSMITH_API_KEY === undefined; - const apiKeyEnvKey = getProviderApiKeyEnvKey(provider); + const apiKeyEnvKey = isAgentCliProvider(provider) + ? undefined + : getProviderApiKeyEnvKey(provider); const projectEnvKey = getProviderProjectEnvKey(provider); const locationEnvKey = getProviderLocationEnvKey(provider); @@ -2266,7 +2285,13 @@ export function InitSetup({ } detail={getProviderSetupDetail(provider)} /> - {providerUsesOAuth(provider) || apiKeyEnvKey ? ( + {isAgentCliProvider(provider) ? ( + + ) : providerUsesOAuth(provider) || apiKeyEnvKey ? ( { + if (originalTimeout === undefined) { + delete process.env.OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS; + } else { + process.env.OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS = originalTimeout; + } +}); + +function parseFakeLine(line: unknown): AgentCliEvent[] { + if ( + typeof line === "object" && + line !== null && + "type" in line && + (line as { type: string }).type === "text" + ) { + const data = (line as { data?: string }).data ?? ""; + return [ + { + type: "openwiki", + event: { source: "main", type: "text", text: data }, + }, + ]; + } + + if ( + typeof line === "object" && + line !== null && + "type" in line && + (line as { type: string }).type === "end" + ) { + const sessionId = (line as { sessionId?: string }).sessionId ?? ""; + return [ + { type: "session", sessionId }, + { type: "result", ok: true }, + ]; + } + + return []; +} + +describe("runAgentCli", () => { + test("spawns a fake CLI, forwards text events, and returns the session id", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "openwiki-agent-cli-")); + const binary = path.join(dir, "fake-grok.sh"); + await writeFile( + binary, + `#!/bin/sh +# Consume args; emit a minimal streaming-json success stream. +echo '{"type":"text","data":"hello from fake grok"}' +echo '{"type":"end","stopReason":"EndTurn","sessionId":"sess-test-1"}' +`, + "utf8", + ); + await chmod(binary, 0o755); + + const adapter: AgentCliAdapter = { + id: "fake-grok", + detectInstall() { + return Promise.resolve({ found: true, version: "fake 1.0" }); + }, + buildArgs(_spec, promptFilePath) { + return ["--prompt-file", promptFilePath]; + }, + createParser() { + return { + parse: parseFakeLine, + flush: () => [], + }; + }, + }; + + const providerConfig: AgentCliProviderConfig = { + kind: "agent-cli", + binaryEnvKey: "OPENWIKI_TEST_FAKE_GROK_BINARY", + defaultBinary: binary, + installHint: "install fake", + label: "Fake Grok", + modelOptions: [{ id: "fake", label: "Fake" }], + }; + + process.env.OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS = "30"; + + const events: string[] = []; + const spec: EngineRunSpec = { + command: "chat", + cwd: dir, + modelId: "fake", + prompt: "hello", + }; + + const outcome = await runAgentCli(adapter, providerConfig, spec, { + onEvent: (event) => { + if (event.type === "text") { + events.push(event.text); + } + }, + }); + + expect(outcome.sessionId).toBe("sess-test-1"); + expect(events.join("")).toContain("hello from fake grok"); + }); + + test("throws a clear error when the binary is missing", async () => { + const adapter: AgentCliAdapter = { + id: "missing", + detectInstall() { + return Promise.resolve({ found: false }); + }, + buildArgs() { + return []; + }, + createParser() { + return { + parse: () => [], + flush: () => [], + }; + }, + }; + + const providerConfig: AgentCliProviderConfig = { + kind: "agent-cli", + binaryEnvKey: "OPENWIKI_TEST_MISSING_BINARY", + defaultBinary: "missing-binary", + installHint: "Install the thing.", + label: "Missing CLI", + modelOptions: [], + }; + + await expect( + runAgentCli( + adapter, + providerConfig, + { + command: "chat", + cwd: process.cwd(), + modelId: "x", + prompt: "hi", + }, + {}, + ), + ).rejects.toThrow(/Install the thing/); + }); +}); diff --git a/test/grok-build-adapter.test.ts b/test/grok-build-adapter.test.ts new file mode 100644 index 00000000..bb8c3741 --- /dev/null +++ b/test/grok-build-adapter.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, test } from "vitest"; +import { + createGrokBuildStreamParser, + DEFAULT_GROK_BUILD_MAX_TURNS, + grokBuildAdapter, +} from "../src/agent/engines/grok-build.ts"; +import type { EngineRunSpec } from "../src/agent/engines/types.ts"; + +const baseSpec: EngineRunSpec = { + command: "init", + cwd: "/tmp/repo", + modelId: "grok-4.5", + prompt: "Initialize docs.", +}; + +describe("grokBuildAdapter.buildArgs", () => { + test("builds headless streaming-json args with prompt file and auto-approve", () => { + expect(grokBuildAdapter.buildArgs(baseSpec, "/tmp/prompt.md")).toEqual([ + "--prompt-file", + "/tmp/prompt.md", + "--output-format", + "streaming-json", + "--always-approve", + "--max-turns", + String(DEFAULT_GROK_BUILD_MAX_TURNS), + "--disable-web-search", + "--model", + "grok-4.5", + ]); + }); + + test("adds --resume for follow-up sessions", () => { + const args = grokBuildAdapter.buildArgs( + { + ...baseSpec, + resumeSessionId: "sess-1", + }, + "/tmp/prompt.md", + ); + + expect(args[args.indexOf("--resume") + 1]).toBe("sess-1"); + }); +}); + +describe("grokBuildAdapter.detectInstall", () => { + test("reports a missing binary", async () => { + const status = await grokBuildAdapter.detectInstall( + "definitely-not-a-real-binary-xyz", + ); + + expect(status.found).toBe(false); + }); + + test("reports a version for an executable that prints one", async () => { + const status = await grokBuildAdapter.detectInstall(process.execPath); + + expect(status.found).toBe(true); + expect(status.version).toMatch(/\d+\.\d+/); + }); +}); + +describe("createGrokBuildStreamParser", () => { + test("buffers text tokens and only emits post-tool-start final text on end", () => { + const parser = createGrokBuildStreamParser(); + + expect(parser.parse({ type: "text", data: "I will inspect " })).toEqual([]); + expect(parser.parse({ type: "text", data: "the repo." })).toEqual([]); + expect( + parser.parse({ + type: "tool_start", + name: "read_file", + id: "t1", + }), + ).toEqual([ + { + type: "openwiki", + event: { + type: "tool_start", + call: "read_file", + id: "t1", + input: undefined, + name: "read_file", + }, + }, + ]); + + expect(parser.parse({ type: "text", data: "Done.\n\n" })).toEqual([]); + expect( + parser.parse({ type: "text", data: "## Summary\nClean wiki." }), + ).toEqual([]); + + const endEvents = parser.parse({ + type: "end", + stopReason: "EndTurn", + sessionId: "sess-abc", + }); + + expect(endEvents).toEqual([ + { + type: "openwiki", + event: { + source: "main", + type: "text", + text: "Done.\n\n## Summary\nClean wiki.", + }, + }, + { type: "session", sessionId: "sess-abc" }, + { type: "result", ok: true, errorMessage: undefined }, + ]); + }); + + test("discards pre-tool planning when tools run with no final answer", () => { + const parser = createGrokBuildStreamParser(); + + parser.parse({ type: "text", data: "planning..." }); + parser.parse({ type: "tool_start", name: "write", id: "w1" }); + parser.parse({ type: "tool_end", id: "w1", name: "write" }); + const endEvents = parser.parse({ + type: "end", + stopReason: "EndTurn", + sessionId: "sess-x", + }); + + expect(endEvents).toEqual([ + { type: "session", sessionId: "sess-x" }, + { type: "result", ok: true, errorMessage: undefined }, + ]); + }); + + test("keeps final answer when a trailing tool_end follows the text", () => { + const parser = createGrokBuildStreamParser(); + + parser.parse({ type: "tool_start", name: "write", id: "w1" }); + parser.parse({ type: "text", data: "Wiki ready." }); + // Trailing completion event must not wipe the answer. + expect(parser.parse({ type: "tool_end", id: "w1", name: "write" })).toEqual( + [ + { + type: "openwiki", + event: { + type: "tool_end", + id: "w1", + name: "write", + status: "finished", + }, + }, + ], + ); + + expect( + parser.parse({ + type: "end", + stopReason: "EndTurn", + sessionId: "sess-trail", + }), + ).toEqual([ + { + type: "openwiki", + event: { source: "main", type: "text", text: "Wiki ready." }, + }, + { type: "session", sessionId: "sess-trail" }, + { type: "result", ok: true, errorMessage: undefined }, + ]); + }); + + test("tool_start and tool_end share the same id fallback when ids are missing", () => { + const parser = createGrokBuildStreamParser(); + + expect(parser.parse({ type: "tool_start", name: "read" })).toEqual([ + { + type: "openwiki", + event: { + type: "tool_start", + call: "read", + id: "read", + input: undefined, + name: "read", + }, + }, + ]); + + expect(parser.parse({ type: "tool_end", name: "read" })).toEqual([ + { + type: "openwiki", + event: { + type: "tool_end", + id: "read", + name: "read", + status: "finished", + }, + }, + ]); + }); + + test("thought tokens stay debug-only and do not break coalescing", () => { + const parser = createGrokBuildStreamParser(); + + expect(parser.parse({ type: "thought", data: "hmm" })).toEqual([ + { + type: "openwiki", + event: { + type: "debug", + message: 'grok-build.thought="hmm"', + }, + }, + ]); + + parser.parse({ type: "text", data: "Final answer." }); + expect( + parser.parse({ type: "end", stopReason: "EndTurn", sessionId: "s1" }), + ).toEqual([ + { + type: "openwiki", + event: { source: "main", type: "text", text: "Final answer." }, + }, + { type: "session", sessionId: "s1" }, + { type: "result", ok: true, errorMessage: undefined }, + ]); + }); + + test("cancelled end is a failed result", () => { + const parser = createGrokBuildStreamParser(); + const events = parser.parse({ + type: "end", + stopReason: "Cancelled", + sessionId: "sess-x", + }); + + expect(events).toContainEqual({ type: "session", sessionId: "sess-x" }); + expect(events).toContainEqual({ + type: "result", + ok: false, + errorMessage: "Grok Build run ended with stopReason=Cancelled.", + }); + }); + + test("max_turns is a failed result so partial docs are not marked complete", () => { + const parser = createGrokBuildStreamParser(); + const events = parser.parse({ + type: "end", + stopReason: "max_turns", + sessionId: "sess-max", + }); + + expect(events).toContainEqual({ + type: "result", + ok: false, + errorMessage: "Grok Build run ended with stopReason=max_turns.", + }); + }); + + test("non-streaming final JSON blob is accepted", () => { + const parser = createGrokBuildStreamParser(); + const events = parser.parse({ + text: "done", + stopReason: "EndTurn", + sessionId: "sess-final", + }); + + expect(events).toEqual([ + { + type: "openwiki", + event: { source: "main", type: "text", text: "done" }, + }, + { type: "session", sessionId: "sess-final" }, + { type: "result", ok: true, errorMessage: undefined }, + ]); + }); + + test("untyped mid-stream objects without stopReason are ignored", () => { + const parser = createGrokBuildStreamParser(); + + expect(parser.parse({ text: "not a final blob" })).toEqual([]); + parser.parse({ type: "text", data: "real final" }); + expect( + parser.parse({ + type: "end", + stopReason: "EndTurn", + sessionId: "s2", + }), + ).toEqual([ + { + type: "openwiki", + event: { source: "main", type: "text", text: "real final" }, + }, + { type: "session", sessionId: "s2" }, + { type: "result", ok: true, errorMessage: undefined }, + ]); + }); + + test("flush emits last buffered segment when process exits without end", () => { + const parser = createGrokBuildStreamParser(); + parser.parse({ type: "text", data: "orphan final" }); + expect(parser.flush()).toEqual([ + { + type: "openwiki", + event: { source: "main", type: "text", text: "orphan final" }, + }, + ]); + // Second flush is a no-op. + expect(parser.flush()).toEqual([]); + }); +}); diff --git a/test/grok-build-provider.test.ts b/test/grok-build-provider.test.ts new file mode 100644 index 00000000..dbf104c5 --- /dev/null +++ b/test/grok-build-provider.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "vitest"; +import { + formatProviderSwitchNotice, + getAgentCliProviderConfig, + getDefaultModelId, + getProviderApiKeyEnvKey, + getProviderAuthMethod, + getProviderLabel, + GROK_BUILD_BINARY_ENV_KEY, + isAgentCliProvider, + isValidProvider, + normalizeProvider, + SELECTABLE_OPENWIKI_PROVIDERS, +} from "../src/constants.ts"; +import { resolveStartupCommand } from "../src/startup.ts"; + +describe("grok-build provider config", () => { + test("is a valid selectable agent-cli provider", () => { + expect(isValidProvider("grok-build")).toBe(true); + expect(normalizeProvider("GROK-BUILD")).toBe("grok-build"); + expect(SELECTABLE_OPENWIKI_PROVIDERS).toContain("grok-build"); + expect(isAgentCliProvider("grok-build")).toBe(true); + expect(isAgentCliProvider("anthropic")).toBe(false); + }); + + test("exposes binary override and subscription label", () => { + const config = getAgentCliProviderConfig("grok-build"); + + expect(config.kind).toBe("agent-cli"); + expect(config.defaultBinary).toBe("grok"); + expect(config.binaryEnvKey).toBe(GROK_BUILD_BINARY_ENV_KEY); + expect(config.installHint).toMatch(/grok login/i); + expect(getProviderLabel("grok-build")).toBe("Grok Build (subscription)"); + expect(getDefaultModelId("grok-build")).toBe("grok-4.5"); + }); + + test("switch notice does not mention an API key", () => { + const notice = formatProviderSwitchNotice("grok-build"); + + expect(notice).toContain("Grok Build (subscription)"); + expect(notice).toMatch(/local agent CLI login/i); + expect(notice).not.toMatch(/API_KEY/); + }); + + test("getAgentCliProviderConfig rejects API providers", () => { + expect(() => getAgentCliProviderConfig("openai")).toThrow(/openai/); + }); + + test("agent-cli providers have no API key env and use cli-login auth", () => { + expect(() => getProviderApiKeyEnvKey("grok-build")).toThrow(/grok-build/); + expect(getProviderAuthMethod("grok-build")).toBe("cli-login"); + expect(getProviderAuthMethod("openai")).toBe("api-key"); + expect(getProviderAuthMethod("openai-chatgpt")).toBe("oauth"); + }); +}); + +describe("startup gate for grok-build", () => { + const originalProvider = process.env.OPENWIKI_PROVIDER; + const originalOpenRouterKey = process.env.OPENROUTER_API_KEY; + + test("allows non-interactive print runs without an API key", async () => { + process.env.OPENWIKI_PROVIDER = "grok-build"; + delete process.env.OPENROUTER_API_KEY; + + try { + const result = await resolveStartupCommand( + { + kind: "run", + exitCode: 0, + command: "chat", + dryRun: false, + modelId: null, + print: true, + shouldStart: true, + userMessage: "hello", + }, + { isStdinTTY: false }, + ); + + expect(result.kind).toBe("run"); + } finally { + if (originalProvider === undefined) delete process.env.OPENWIKI_PROVIDER; + else process.env.OPENWIKI_PROVIDER = originalProvider; + + if (originalOpenRouterKey === undefined) + delete process.env.OPENROUTER_API_KEY; + else process.env.OPENROUTER_API_KEY = originalOpenRouterKey; + } + }); +}); From b7aa0d34c967c6a7874a565895775a0e98ec2a33 Mon Sep 17 00:00:00 2001 From: Sanjay Ramadugu Date: Wed, 15 Jul 2026 22:16:10 -0400 Subject: [PATCH 2/4] feat: add Antigravity subscription CLI as a provider Wire OpenWiki to the local Antigravity CLI (`agy`) as an agent-cli provider alongside Grok Build. Runs use print mode with auto-approved edits, recover conversation ids from the per-run log, and accept `agy models` display names. --- README.md | 41 +++- src/agent/engines/antigravity.ts | 323 ++++++++++++++++++++++++++++++ src/agent/engines/index.ts | 6 + src/agent/engines/runner.ts | 18 ++ src/agent/engines/types.ts | 27 ++- src/cli.tsx | 4 +- src/constants.ts | 33 ++- src/credentials.tsx | 3 +- src/env.ts | 5 +- test/agent-cli-runner.test.ts | 77 +++++++ test/antigravity-adapter.test.ts | 130 ++++++++++++ test/antigravity-provider.test.ts | 92 +++++++++ test/constants.test.ts | 5 + test/env-behavior.test.ts | 4 +- 14 files changed, 760 insertions(+), 8 deletions(-) create mode 100644 src/agent/engines/antigravity.ts create mode 100644 test/antigravity-adapter.test.ts create mode 100644 test/antigravity-provider.test.ts diff --git a/README.md b/README.md index d1424dff..bcd76ad1 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ notes. ## Customizing -OpenWiki supports OpenAI (with an API key or a ChatGPT login), Grok Build (subscription CLI), OpenRouter, Nebius Token Factory, Fireworks, Baseten, NVIDIA NIM, an OpenAI-compatible provider, AWS Bedrock, Anthropic, and Google Vertex AI (Claude models) out of the box. The onboarding default is OpenAI with `gpt-5.6-terra`, and each inference provider also includes pre-defined model options plus support for custom model IDs. +OpenWiki supports OpenAI (with an API key or a ChatGPT login), Grok Build (subscription CLI), Antigravity (subscription CLI), OpenRouter, Nebius Token Factory, Fireworks, Baseten, NVIDIA NIM, an OpenAI-compatible provider, AWS Bedrock, Anthropic, and Google Vertex AI (Claude models) out of the box. The onboarding default is OpenAI with `gpt-5.6-terra`, and each inference provider also includes pre-defined model options plus support for custom model IDs. ### Alternative base URLs @@ -361,6 +361,45 @@ or GitLab CI examples, which expect a metered API key and do not have a `grok lo session. Runs use `--always-approve` so the CLI can write documentation files; treat the model like any coding agent with write access to the repo. +### Antigravity (subscription) + +The `antigravity` provider runs documentation jobs through the local +[Antigravity](https://antigravity.google/product/antigravity-cli) CLI (`agy`) using your existing +Antigravity session — no separate API key is stored by OpenWiki. OpenWiki +spawns `agy -p` headlessly with `--dangerously-skip-permissions` and +`--mode accept-edits` so documentation writes can proceed without a TTY. + +Prerequisites: + +1. Install the Antigravity CLI and ensure `agy` is on your `PATH` (or set + `OPENWIKI_ANTIGRAVITY_BINARY` to the full path). On macOS: + `brew install --cask antigravity-cli`. +2. Sign in with the CLI once so it has a valid session. + +```bash +OPENWIKI_PROVIDER=antigravity openwiki code --init +# or +OPENWIKI_PROVIDER=antigravity openwiki personal --init +``` + +Optional overrides: + +```bash +# Binary path when `agy` is not on PATH +OPENWIKI_ANTIGRAVITY_BINARY=/path/to/agy + +# Model — must match an exact `agy models` display string +OPENWIKI_MODEL_ID="Gemini 3.5 Flash (Medium)" + +# Overall run timeout in seconds (default 1800). Also drives agy's --print-timeout. +OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS=1800 +``` + +**Local / subscription only.** Prefer this provider on a machine where you already +use Antigravity interactively. It is not a drop-in for the scheduled GitHub Actions +or GitLab CI examples. Model ids are the human display strings from `agy models` +(for example `Claude Sonnet 4.6 (Thinking)`), not provider/model slugs. + ### Provider retry attempts OpenWiki uses LangChain's built-in retry handling for transient provider errors. diff --git a/src/agent/engines/antigravity.ts b/src/agent/engines/antigravity.ts new file mode 100644 index 00000000..904cefb1 --- /dev/null +++ b/src/agent/engines/antigravity.ts @@ -0,0 +1,323 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { readFile, unlink } from "node:fs/promises"; +import { readFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import type { + AgentCliAdapter, + AgentCliEvent, + AgentCliExitInfo, + AgentCliInstallStatus, + AgentCliStreamParser, + EngineRunSpec, +} from "./types.js"; + +const execFileAsync = promisify(execFile); + +/** + * Match the glog line printmode.go writes when the CLI dispatches the user's + * message — the only place in the log that reliably surfaces the conversation + * UUID for both fresh and resumed turns. + * + * Example: `Print mode: conversation=b8b263a4-4b2f-4339-acc9-78b248e2b606, sending message` + */ +const CONVERSATION_ID_RE = + /conversation=([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/gu; + +/** agy print-mode wall-clock timeout marker (exit code may still be 0). */ +const PRINT_TIMEOUT_RE = /Print mode: timed out after \d+ polls/u; + +const PROVIDER_ERROR_RE = /agent executor error:\s*(.+)/gu; + +/** + * Antigravity (`agy`) headless adapter. + * + * Auth is the CLI's own subscription / Google login — OpenWiki never stores an + * API key. Print mode (`agy -p`) emits plain assistant text on stdout (not + * NDJSON). Session resume uses `--conversation `, recovered from a + * per-run `--log-file`. + * + * Model ids are the exact display strings from `agy models` (e.g. + * `Gemini 3.5 Flash (Medium)`), not provider/model slugs. + */ +export function createAntigravityAdapter(): AgentCliAdapter { + let logFilePath: string | undefined; + let sawStdoutText = false; + let finished = false; + + return { + id: "antigravity", + streamFormat: "text", + + async detectInstall(binary: string): Promise { + try { + const { stdout } = await execFileAsync(binary, ["--version"], { + timeout: 15_000, + }); + + return { found: true, version: stdout.trim().split("\n")[0]?.trim() }; + } catch { + return { found: false }; + } + }, + + buildArgs(spec: EngineRunSpec, promptFilePath: string): string[] { + const prompt = readFileSync(promptFilePath, "utf8"); + logFilePath = path.join( + os.tmpdir(), + `openwiki-agy-log-${randomUUID()}.log`, + ); + + const args = [ + "-p", + prompt, + "--dangerously-skip-permissions", + "--mode", + "accept-edits", + "--print-timeout", + formatPrintTimeout(resolvePrintTimeoutSeconds()), + "--log-file", + logFilePath, + "--add-dir", + path.resolve(spec.cwd), + ]; + + if (spec.modelId.length > 0) { + args.push("--model", spec.modelId); + } + + if (spec.resumeSessionId) { + args.push("--conversation", spec.resumeSessionId); + } + + return args; + }, + + createParser(): AgentCliStreamParser { + return { + parse(line: unknown): AgentCliEvent[] { + if (finished || typeof line !== "string") { + return []; + } + + // Skip the print-mode timeout/error lines that agy writes to stdout + // with a still-zero exit code; afterExit classifies those via the log. + if ( + line.startsWith("Error: timeout waiting for response") || + line.startsWith("Error: timed out waiting for response") + ) { + return []; + } + + sawStdoutText = true; + + return [ + { + type: "openwiki", + event: { source: "main", type: "text", text: `${line}\n` }, + }, + ]; + }, + + flush(): AgentCliEvent[] { + return []; + }, + }; + }, + + async afterExit(info: AgentCliExitInfo): Promise { + if (finished) { + return []; + } + + finished = true; + const events: AgentCliEvent[] = []; + const logContents = logFilePath + ? await readFile(logFilePath, "utf8").catch(() => "") + : ""; + + const sessionId = extractConversationId(logContents); + + if (sessionId) { + events.push({ type: "session", sessionId }); + } + + if (PRINT_TIMEOUT_RE.test(logContents)) { + events.push({ + type: "result", + ok: false, + errorMessage: + "Antigravity print mode timed out waiting for a response. Increase OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS (controls --print-timeout).", + }); + return events; + } + + const providerError = extractProviderError(logContents); + + if (providerError) { + events.push({ + type: "result", + ok: false, + errorMessage: `Antigravity provider error: ${providerError}`, + }); + return events; + } + + if (info.exitCode !== 0 && info.exitCode !== null) { + events.push({ + type: "result", + ok: false, + errorMessage: `Antigravity run failed (exit code ${info.exitCode}).`, + }); + return events; + } + + // agy can finish a turn (tools + reply) while emitting zero stdout bytes. + // Recover the assistant text from the conversation transcript when we + // have a session id and saw nothing on stdout. + if (!sawStdoutText && sessionId && logFilePath) { + const recovered = await recoverTranscriptText(logFilePath, sessionId); + + if (recovered.trim().length > 0) { + events.push({ + type: "openwiki", + event: { source: "main", type: "text", text: recovered }, + }); + } + } + + events.push({ type: "result", ok: true }); + return events; + }, + + async cleanup(): Promise { + if (!logFilePath) { + return; + } + + await unlink(logFilePath).catch(() => { + // Log may already be gone. + }); + logFilePath = undefined; + }, + }; +} + +/** Singleton for simple detectInstall/buildArgs unit tests. */ +export const antigravityAdapter: AgentCliAdapter = createAntigravityAdapter(); + +/** Exported for tests. */ +export function extractConversationId(logContents: string): string | undefined { + let last: string | undefined; + + for (const match of logContents.matchAll(CONVERSATION_ID_RE)) { + last = match[1]; + } + + return last; +} + +/** Exported for tests. */ +export function extractProviderError(logContents: string): string | undefined { + let last: string | undefined; + + for (const match of logContents.matchAll(PROVIDER_ERROR_RE)) { + last = match[1]?.trim(); + } + + return last && last.length > 0 ? last : undefined; +} + +/** Exported for tests. */ +export function formatPrintTimeout(totalSeconds: number): string { + const seconds = Math.max(1, Math.floor(totalSeconds)); + const minutes = Math.floor(seconds / 60); + const rem = seconds % 60; + + return `${minutes}m${rem}s`; +} + +function resolvePrintTimeoutSeconds(): number { + const raw = process.env.OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS; + const parsed = raw === undefined ? Number.NaN : Number.parseInt(raw, 10); + + // Align with the runner's default 1800s wall clock so agy's own print-timeout + // does not guillotine the run at the 5m default while the parent is still waiting. + return Number.isFinite(parsed) && parsed > 0 ? parsed : 1800; +} + +async function recoverTranscriptText( + logPath: string, + conversationId: string, +): Promise { + const logContents = await readFile(logPath, "utf8").catch(() => ""); + const appDataDir = extractAppDataDir(logContents); + + if (!appDataDir) { + return ""; + } + + const transcriptPath = path.join( + appDataDir, + "brain", + conversationId, + ".system_generated", + "logs", + "transcript.jsonl", + ); + + const contents = await readFile(transcriptPath, "utf8").catch(() => ""); + + if (contents.length === 0) { + return ""; + } + + const parts: string[] = []; + + for (const line of contents.split(/\r?\n/u)) { + if (line.trim().length === 0) { + continue; + } + + let record: { + type?: string; + source?: string; + status?: string; + content?: unknown; + }; + + try { + record = JSON.parse(line) as typeof record; + } catch { + continue; + } + + if (record.type === "USER_INPUT") { + // New turn boundary — only keep the current turn's model text. + parts.length = 0; + continue; + } + + if ( + record.type !== "PLANNER_RESPONSE" || + record.source !== "MODEL" || + record.status !== "DONE" || + typeof record.content !== "string" || + record.content.trim().length === 0 + ) { + continue; + } + + parts.push(record.content); + } + + return parts.join("\n\n"); +} + +function extractAppDataDir(logContents: string): string | undefined { + const match = /CLI app data directory:\s*(.+)/u.exec(logContents); + + return match?.[1]?.trim(); +} diff --git a/src/agent/engines/index.ts b/src/agent/engines/index.ts index cb09b0f3..02b6653e 100644 --- a/src/agent/engines/index.ts +++ b/src/agent/engines/index.ts @@ -1,4 +1,5 @@ import type { OpenWikiProvider } from "../../constants.js"; +import { createAntigravityAdapter } from "./antigravity.js"; import { grokBuildAdapter } from "./grok-build.js"; import type { AgentCliAdapter } from "./types.js"; @@ -9,5 +10,10 @@ export function getAgentCliAdapter( return grokBuildAdapter; } + if (provider === "antigravity") { + // Fresh instance per run so per-run log paths cannot clash. + return createAntigravityAdapter(); + } + throw new Error(`No agent CLI adapter is registered for ${provider}.`); } diff --git a/src/agent/engines/runner.ts b/src/agent/engines/runner.ts index afc97e04..2ce1340b 100644 --- a/src/agent/engines/runner.ts +++ b/src/agent/engines/runner.ts @@ -146,6 +146,7 @@ export async function runAgentCli( }); const streamParser = adapter.createParser(); + const streamFormat = adapter.streamFormat ?? "ndjson"; const handleParsedEvents = ( events: ReturnType, @@ -187,6 +188,16 @@ export async function runAgentCli( const lines = createInterface({ input: child.stdout }); lines.on("line", (line) => { + if (streamFormat === "text") { + // Preserve whitespace-only lines as structure but skip pure empties. + if (line.length === 0) { + return; + } + + handleParsedEvents(streamParser.parse(line)); + return; + } + const trimmed = line.trim(); if (trimmed.length === 0) { @@ -241,6 +252,12 @@ export async function runAgentCli( // Flush any text buffered when the process exits without a terminal `end`. handleParsedEvents(streamParser.flush()); + if (adapter.afterExit) { + handleParsedEvents( + await adapter.afterExit({ exitCode, stderrTail }), + ); + } + clearTimeout(timeout); if (timedOut) { @@ -268,6 +285,7 @@ export async function runAgentCli( await unlink(promptFilePath).catch(() => { // Temp file may already be gone. }); + await adapter.cleanup?.(); } } diff --git a/src/agent/engines/types.ts b/src/agent/engines/types.ts index 5870d610..be198d26 100644 --- a/src/agent/engines/types.ts +++ b/src/agent/engines/types.ts @@ -30,13 +30,38 @@ export type AgentCliStreamParser = { flush(): AgentCliEvent[]; }; +/** + * How the runner interprets adapter stdout. + * - `ndjson` (default): one JSON object per line (Grok Build streaming-json). + * - `text`: plain assistant text lines (Antigravity print mode). + */ +export type AgentCliStreamFormat = "ndjson" | "text"; + +export type AgentCliExitInfo = { + exitCode: number | null; + stderrTail: string; +}; + export type AgentCliAdapter = { id: string; + /** + * How stdout is interpreted. Defaults to {@link AgentCliStreamFormat} `ndjson`. + */ + streamFormat?: AgentCliStreamFormat; detectInstall(binary: string): Promise; /** * Builds CLI args. The runner writes `spec.prompt` to a temp file and passes - * its path as `promptFilePath` so long system+user prompts do not hit ARG_MAX. + * its path as `promptFilePath` so long system+user prompts do not hit ARG_MAX + * (adapters that only accept an inline prompt may still read that file). */ buildArgs(spec: EngineRunSpec, promptFilePath: string): string[]; createParser(): AgentCliStreamParser; + /** + * Optional post-process after the child exits. Used by plain-text CLIs to + * recover session ids from log files, recover empty stdout, and synthesize a + * terminal {@link AgentCliEvent} `result` when the stream has no NDJSON end. + */ + afterExit?(info: AgentCliExitInfo): AgentCliEvent[] | Promise; + /** Optional cleanup for per-run temp files (log files, etc.). */ + cleanup?(): void | Promise; }; diff --git a/src/cli.tsx b/src/cli.tsx index 920e7710..716fd1ab 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -64,13 +64,13 @@ import { } from "./schedules.js"; import { formatProviderSwitchNotice, + getAgentCliProviderConfig, getDefaultModelId, getMissingProviderEnvKey, getProviderApiKeyEnvKey, getProviderCredentialHint, getProviderLabel, getProviderModelOptions, - getProviderProjectEnvKey, isAgentCliProvider, isValidModelId, normalizeModelId, @@ -1784,7 +1784,7 @@ function ChatInput({ if (option.id === "api-key") { if (isAgentCliProvider(currentProvider)) { setError( - `${getProviderLabel(currentProvider)} uses the local Grok Build CLI login. Run \`grok login\` instead of pasting an API key.`, + `${getProviderLabel(currentProvider)} uses a local agent CLI login, not an API key. ${getAgentCliProviderConfig(currentProvider).installHint}`, ); return; } diff --git a/src/constants.ts b/src/constants.ts index 6d108db0..48c121b4 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -28,6 +28,7 @@ export const GOOGLE_APPLICATION_CREDENTIALS_ENV_KEY = "GOOGLE_APPLICATION_CREDENTIALS"; export const DEFAULT_VERTEX_LOCATION = "global"; export const GROK_BUILD_BINARY_ENV_KEY = "OPENWIKI_GROK_BUILD_BINARY"; +export const ANTIGRAVITY_BINARY_ENV_KEY = "OPENWIKI_ANTIGRAVITY_BINARY"; export const OPENWIKI_PROVIDER_ENV_KEY = "OPENWIKI_PROVIDER"; export const OPENWIKI_MODEL_ID_ENV_KEY = "OPENWIKI_MODEL_ID"; export const NEBIUS_BASE_URL = "https://api.tokenfactory.nebius.com/v1/"; @@ -67,6 +68,7 @@ export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; export type OpenWikiProvider = | "anthropic" + | "antigravity" | "baseten" | "bedrock" | "fireworks" @@ -186,6 +188,7 @@ export const SELECTABLE_OPENWIKI_PROVIDERS = [ "openai-chatgpt", "anthropic", "grok-build", + "antigravity", "vertex", "openrouter", "openai-compatible", @@ -245,6 +248,32 @@ export const PROVIDER_CONFIGS: Record = { { id: "grok-composer-2.5-fast", label: "Composer 2.5 Fast" }, ], }, + antigravity: { + kind: "agent-cli", + binaryEnvKey: ANTIGRAVITY_BINARY_ENV_KEY, + defaultBinary: "agy", + installHint: + "Install the Antigravity CLI (`agy`, e.g. `brew install --cask antigravity-cli`) and sign in so OpenWiki can use your local session.", + label: "Antigravity (subscription)", + // Exact display strings from `agy models` — agy rejects near-miss values + // silently (empty stdout, exit 0), so presets must match verbatim. + modelOptions: [ + { id: "Gemini 3.5 Flash (Medium)", label: "Gemini 3.5 Flash (Medium)" }, + { id: "Gemini 3.5 Flash (High)", label: "Gemini 3.5 Flash (High)" }, + { id: "Gemini 3.5 Flash (Low)", label: "Gemini 3.5 Flash (Low)" }, + { id: "Gemini 3.1 Pro (Low)", label: "Gemini 3.1 Pro (Low)" }, + { id: "Gemini 3.1 Pro (High)", label: "Gemini 3.1 Pro (High)" }, + { + id: "Claude Sonnet 4.6 (Thinking)", + label: "Claude Sonnet 4.6 (Thinking)", + }, + { + id: "Claude Opus 4.6 (Thinking)", + label: "Claude Opus 4.6 (Thinking)", + }, + { id: "GPT-OSS 120B (Medium)", label: "GPT-OSS 120B (Medium)" }, + ], + }, nebius: { kind: "api", apiKeyEnvKey: NEBIUS_API_KEY_ENV_KEY, @@ -721,7 +750,9 @@ export function isValidModelId(value: string): boolean { return ( modelId.length > 0 && modelId.length <= 120 && - /^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/u.test(modelId) && + // Spaces and parentheses are allowed so agent-CLI display names + // (e.g. Antigravity's `Gemini 3.5 Flash (Medium)`) validate cleanly. + /^[A-Za-z0-9][A-Za-z0-9._:/@+\-() ]*$/u.test(modelId) && !modelId.includes("://") ); } diff --git a/src/credentials.tsx b/src/credentials.tsx index d3304022..5a8f6cd6 100644 --- a/src/credentials.tsx +++ b/src/credentials.tsx @@ -9,6 +9,7 @@ import { runOAuthAuth } from "./auth/oauth.js"; import { DEFAULT_PROVIDER, DEFAULT_VERTEX_LOCATION, + getAgentCliProviderConfig, getDefaultModelId, getMissingProviderEnvKey, getProviderApiKeyEnvKey, @@ -490,7 +491,7 @@ function getCredentialSetupDetail( tokens: CodexTokens | null = null, ): string { if (isAgentCliProvider(provider)) { - return "uses local Grok Build CLI login (`grok login`)"; + return getAgentCliProviderConfig(provider).installHint; } if (providerUsesOAuth(provider)) { diff --git a/src/env.ts b/src/env.ts index 9b83f9e1..a1421cd0 100644 --- a/src/env.ts +++ b/src/env.ts @@ -9,6 +9,7 @@ import { BEDROCK_AWS_REGION_ENV_KEY, BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY, FIREWORKS_API_KEY_ENV_KEY, + ANTIGRAVITY_BINARY_ENV_KEY, GOOGLE_APPLICATION_CREDENTIALS_ENV_KEY, GOOGLE_CLOUD_LOCATION_ENV_KEY, GOOGLE_CLOUD_PROJECT_ENV_KEY, @@ -102,6 +103,7 @@ export const MANAGED_ENV_KEYS = [ BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY, BEDROCK_AWS_REGION_ENV_KEY, GROK_BUILD_BINARY_ENV_KEY, + ANTIGRAVITY_BINARY_ENV_KEY, OPENWIKI_PROVIDER_ENV_KEY, OPENWIKI_MODEL_ID_ENV_KEY, OPENWIKI_PROVIDER_RETRY_ATTEMPTS_ENV_KEY, @@ -287,7 +289,8 @@ function isNonSecretDiagnosticKey(key: string): boolean { key === GOOGLE_CLOUD_PROJECT_ENV_KEY || key === GOOGLE_CLOUD_LOCATION_ENV_KEY || key === GOOGLE_APPLICATION_CREDENTIALS_ENV_KEY || - key === GROK_BUILD_BINARY_ENV_KEY + key === GROK_BUILD_BINARY_ENV_KEY || + key === ANTIGRAVITY_BINARY_ENV_KEY ); } diff --git a/test/agent-cli-runner.test.ts b/test/agent-cli-runner.test.ts index 75646da8..d2700f42 100644 --- a/test/agent-cli-runner.test.ts +++ b/test/agent-cli-runner.test.ts @@ -155,3 +155,80 @@ echo '{"type":"end","stopReason":"EndTurn","sessionId":"sess-test-1"}' ).rejects.toThrow(/Install the thing/); }); }); + +describe("runAgentCli text stream format", () => { + test("feeds plain stdout lines to the parser and honors afterExit result", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "openwiki-agent-cli-")); + const script = path.join(dir, "fake-text-cli.mjs"); + await writeFile( + script, + `#!/usr/bin/env node +process.stdout.write("line one\\n"); +process.stdout.write("line two\\n"); +process.exit(0); +`, + { mode: 0o755 }, + ); + + const events: string[] = []; + let afterExitCalled = false; + + const adapter: AgentCliAdapter = { + id: "fake-text", + streamFormat: "text", + detectInstall() { + return Promise.resolve({ found: true, version: "0" }); + }, + buildArgs() { + return []; + }, + createParser() { + return { + parse(line: unknown) { + if (typeof line !== "string") return []; + return [ + { + type: "openwiki", + event: { source: "main", type: "text", text: line }, + }, + ]; + }, + flush() { + return []; + }, + }; + }, + afterExit() { + afterExitCalled = true; + return [{ type: "result", ok: true }]; + }, + }; + + const providerConfig: AgentCliProviderConfig = { + kind: "agent-cli", + binaryEnvKey: "OPENWIKI_FAKE_TEXT_BINARY", + defaultBinary: script, + installHint: "install fake-text", + label: "Fake Text", + modelOptions: [], + }; + + const spec: EngineRunSpec = { + command: "chat", + cwd: dir, + prompt: "hi", + modelId: "fake", + }; + + process.env.OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS = "30"; + + await runAgentCli(adapter, providerConfig, spec, { + onEvent: (event) => { + if (event.type === "text") events.push(event.text); + }, + }); + + expect(afterExitCalled).toBe(true); + expect(events).toEqual(["line one", "line two"]); + }); +}); diff --git a/test/antigravity-adapter.test.ts b/test/antigravity-adapter.test.ts new file mode 100644 index 00000000..f64443a9 --- /dev/null +++ b/test/antigravity-adapter.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "vitest"; +import { + createAntigravityAdapter, + extractConversationId, + extractProviderError, + formatPrintTimeout, +} from "../src/agent/engines/antigravity.ts"; +import type { EngineRunSpec } from "../src/agent/engines/types.ts"; +import { writeFile, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +describe("antigravity adapter helpers", () => { + test("extractConversationId returns the last conversation uuid", () => { + const log = ` +I0528 13:36:23.318877 73304 printmode.go:130] Print mode: conversation=b8b263a4-4b2f-4339-acc9-78b248e2b606, sending message +I0528 13:36:24.000000 73304 printmode.go:130] Print mode: conversation=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee, sending message +`; + + expect(extractConversationId(log)).toBe( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ); + }); + + test("extractConversationId returns undefined when absent", () => { + expect(extractConversationId("no conversation here")).toBeUndefined(); + }); + + test("extractProviderError returns the last agent executor error", () => { + const log = ` +E0623 agent executor error: rate limited +E0623 agent executor error: model overloaded +`; + + expect(extractProviderError(log)).toBe("model overloaded"); + }); + + test("formatPrintTimeout renders m/s form", () => { + expect(formatPrintTimeout(1800)).toBe("30m0s"); + expect(formatPrintTimeout(90)).toBe("1m30s"); + expect(formatPrintTimeout(0)).toBe("0m1s"); + }); +}); + +describe("createAntigravityAdapter", () => { + test("buildArgs uses -p, skip-permissions, log-file, and model display name", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "openwiki-agy-")); + const promptFile = path.join(dir, "prompt.md"); + await writeFile(promptFile, "document the repo", "utf8"); + + const adapter = createAntigravityAdapter(); + const spec: EngineRunSpec = { + command: "init", + cwd: dir, + prompt: "document the repo", + modelId: "Gemini 3.5 Flash (Medium)", + resumeSessionId: "b8b263a4-4b2f-4339-acc9-78b248e2b606", + }; + + const args = adapter.buildArgs(spec, promptFile); + + expect(args[0]).toBe("-p"); + expect(args[1]).toBe("document the repo"); + expect(args).toContain("--dangerously-skip-permissions"); + expect(args).toContain("--mode"); + expect(args).toContain("accept-edits"); + expect(args).toContain("--model"); + expect(args).toContain("Gemini 3.5 Flash (Medium)"); + expect(args).toContain("--conversation"); + expect(args).toContain("b8b263a4-4b2f-4339-acc9-78b248e2b606"); + expect(args).toContain("--log-file"); + expect(args).toContain("--add-dir"); + expect(args).toContain(path.resolve(dir)); + + await adapter.cleanup?.(); + }); + + test("text parser emits openwiki text events and afterExit synthesizes success", async () => { + const adapter = createAntigravityAdapter(); + const parser = adapter.createParser(); + + expect(parser.parse("Hello from agy")).toEqual([ + { + type: "openwiki", + event: { source: "main", type: "text", text: "Hello from agy\n" }, + }, + ]); + + const events = await adapter.afterExit?.({ + exitCode: 0, + stderrTail: "", + }); + + expect(events).toEqual([{ type: "result", ok: true }]); + await adapter.cleanup?.(); + }); + + test("afterExit reports timeout from log contents", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "openwiki-agy-")); + const promptFile = path.join(dir, "prompt.md"); + await writeFile(promptFile, "hi", "utf8"); + + const adapter = createAntigravityAdapter(); + const args = adapter.buildArgs( + { + command: "chat", + cwd: dir, + prompt: "hi", + modelId: "Gemini 3.5 Flash (Medium)", + }, + promptFile, + ); + const logFile = args[args.indexOf("--log-file") + 1]; + await writeFile( + logFile, + "E0623 17:17:59.017212 65926 printmode.go:289] Print mode: timed out after 100 polls (printed=3)\n", + "utf8", + ); + + const events = await adapter.afterExit?.({ + exitCode: 0, + stderrTail: "", + }); + + expect(events?.some((event) => event.type === "result" && !event.ok)).toBe( + true, + ); + await adapter.cleanup?.(); + }); +}); diff --git a/test/antigravity-provider.test.ts b/test/antigravity-provider.test.ts new file mode 100644 index 00000000..0519fcf5 --- /dev/null +++ b/test/antigravity-provider.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "vitest"; +import { + ANTIGRAVITY_BINARY_ENV_KEY, + formatProviderSwitchNotice, + getAgentCliProviderConfig, + getDefaultModelId, + getProviderApiKeyEnvKey, + getProviderAuthMethod, + getProviderLabel, + isAgentCliProvider, + isValidModelId, + isValidProvider, + normalizeProvider, + SELECTABLE_OPENWIKI_PROVIDERS, +} from "../src/constants.ts"; +import { resolveStartupCommand } from "../src/startup.ts"; + +describe("antigravity provider config", () => { + test("is a valid selectable agent-cli provider", () => { + expect(isValidProvider("antigravity")).toBe(true); + expect(normalizeProvider("ANTIGRAVITY")).toBe("antigravity"); + expect(SELECTABLE_OPENWIKI_PROVIDERS).toContain("antigravity"); + expect(isAgentCliProvider("antigravity")).toBe(true); + expect(isAgentCliProvider("openai")).toBe(false); + }); + + test("exposes binary override and subscription label", () => { + const config = getAgentCliProviderConfig("antigravity"); + + expect(config.kind).toBe("agent-cli"); + expect(config.defaultBinary).toBe("agy"); + expect(config.binaryEnvKey).toBe(ANTIGRAVITY_BINARY_ENV_KEY); + expect(config.installHint).toMatch(/agy/i); + expect(getProviderLabel("antigravity")).toBe( + "Antigravity (subscription)", + ); + expect(getDefaultModelId("antigravity")).toBe( + "Gemini 3.5 Flash (Medium)", + ); + expect(isValidModelId(getDefaultModelId("antigravity"))).toBe(true); + }); + + test("switch notice does not mention an API key", () => { + const notice = formatProviderSwitchNotice("antigravity"); + + expect(notice).toContain("Antigravity (subscription)"); + expect(notice).toMatch(/local agent CLI login/i); + expect(notice).not.toMatch(/API_KEY/); + }); + + test("agent-cli providers have no API key env and use cli-login auth", () => { + expect(() => getProviderApiKeyEnvKey("antigravity")).toThrow( + /antigravity/, + ); + expect(getProviderAuthMethod("antigravity")).toBe("cli-login"); + }); +}); + +describe("startup gate for antigravity", () => { + const originalProvider = process.env.OPENWIKI_PROVIDER; + const originalOpenRouterKey = process.env.OPENROUTER_API_KEY; + + test("allows non-interactive print runs without an API key", async () => { + process.env.OPENWIKI_PROVIDER = "antigravity"; + delete process.env.OPENROUTER_API_KEY; + + try { + const result = await resolveStartupCommand( + { + kind: "run", + exitCode: 0, + command: "chat", + dryRun: false, + modelId: null, + print: true, + shouldStart: true, + userMessage: "hello", + }, + { isStdinTTY: false }, + ); + + expect(result.kind).toBe("run"); + } finally { + if (originalProvider === undefined) delete process.env.OPENWIKI_PROVIDER; + else process.env.OPENWIKI_PROVIDER = originalProvider; + + if (originalOpenRouterKey === undefined) + delete process.env.OPENROUTER_API_KEY; + else process.env.OPENROUTER_API_KEY = originalOpenRouterKey; + } + }); +}); diff --git a/test/constants.test.ts b/test/constants.test.ts index e6151fd5..27fd1415 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -50,6 +50,11 @@ describe("isValidModelId", () => { expect(isValidModelId("claude-haiku-4-5@20251001")).toBe(true); }); + test("accepts agent-CLI display names with spaces and parentheses", () => { + expect(isValidModelId("Gemini 3.5 Flash (Medium)")).toBe(true); + expect(isValidModelId("Claude Sonnet 4.6 (Thinking)")).toBe(true); + }); + test("rejects ids starting with a non-alphanumeric character", () => { expect(isValidModelId("-leading-dash")).toBe(false); expect(isValidModelId("/leading-slash")).toBe(false); diff --git a/test/env-behavior.test.ts b/test/env-behavior.test.ts index f4594945..f12b6cfa 100644 --- a/test/env-behavior.test.ts +++ b/test/env-behavior.test.ts @@ -245,7 +245,9 @@ describe("getCredentialDiagnostics", () => { }); test("flags an invalid model ID with a warning", async () => { - await env.saveOpenWikiEnv({ [OPENWIKI_MODEL_ID_ENV_KEY]: "bad model id" }); + await env.saveOpenWikiEnv({ + [OPENWIKI_MODEL_ID_ENV_KEY]: "http://evil.example/model", + }); const diagnostics = await env.getCredentialDiagnostics(); const entry = diagnostics.find( From 16a452ec2bf9cc341881282339be932677409e59 Mon Sep 17 00:00:00 2001 From: Sanjay Ramadugu Date: Wed, 15 Jul 2026 22:25:41 -0400 Subject: [PATCH 3/4] fix: scrub agent-CLI env and enforce docs-only write boundary Address Corridor findings on the agent-CLI path: - spawn vendor CLIs with a minimal env allowlist so OpenWiki-managed credentials never reach the child process - after repository init/update runs, reject any writes outside openwiki/ (plus AGENTS.md / CLAUDE.md) based on post-run mtime checks --- src/agent/engines/child-env.ts | 114 ++++++++++ src/agent/engines/runner.ts | 26 +++ src/agent/engines/types.ts | 7 + src/agent/engines/write-boundary.ts | 147 +++++++++++++ src/agent/index.ts | 10 +- test/agent-cli-security.test.ts | 326 ++++++++++++++++++++++++++++ 6 files changed, 628 insertions(+), 2 deletions(-) create mode 100644 src/agent/engines/child-env.ts create mode 100644 src/agent/engines/write-boundary.ts create mode 100644 test/agent-cli-security.test.ts diff --git a/src/agent/engines/child-env.ts b/src/agent/engines/child-env.ts new file mode 100644 index 00000000..a8fe273f --- /dev/null +++ b/src/agent/engines/child-env.ts @@ -0,0 +1,114 @@ +import { MANAGED_ENV_KEYS } from "../../env.js"; +import { isSecretLikeKey } from "../../diagnostics.js"; + +/** + * Environment keys the agent CLI is allowed to inherit from the parent. + * + * Intentionally minimal: enough for PATH lookup, home-directory session + * files (`~/.grok`, `~/.gemini/antigravity-cli`), temp dirs, locale, and + * corporate proxy/CA setup. Everything else — especially OpenWiki-managed + * credentials — is dropped. + */ +const PASSTHROUGH_ENV_KEYS = new Set([ + "PATH", + "Path", + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "USER", + "USERNAME", + "LOGNAME", + "TMPDIR", + "TMP", + "TEMP", + "TERM", + "COLORTERM", + "NO_COLOR", + "FORCE_COLOR", + "SHELL", + "ComSpec", + "COMSPEC", + "SYSTEMROOT", + "SystemRoot", + "WINDIR", + "PATHEXT", + "PROCESSOR_ARCHITECTURE", + "APPDATA", + "LOCALAPPDATA", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "XDG_RUNTIME_DIR", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "all_proxy", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", +]); + +const MANAGED_ENV_KEY_SET = new Set(MANAGED_ENV_KEYS); + +/** + * Builds a scrubbed environment for vendor agent-CLI children. + * + * Drops every {@link MANAGED_ENV_KEYS} entry (provider keys, OAuth tokens, + * connector secrets), secret-like keys, and OpenWiki/LangChain config so a + * prompt-injected shell step or compromised binary cannot read credentials + * that OpenWiki loaded into `process.env`. + */ +export function buildAgentCliChildEnv( + parent: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + + for (const [key, value] of Object.entries(parent)) { + if (value === undefined) { + continue; + } + + if (!shouldPassEnvKey(key)) { + continue; + } + + env[key] = value; + } + + return env; +} + +/** Exported for tests. */ +export function shouldPassEnvKey(key: string): boolean { + if (MANAGED_ENV_KEY_SET.has(key)) { + return false; + } + + if (isSecretLikeKey(key)) { + return false; + } + + // Defense in depth: never forward OpenWiki or LangChain/LangSmith vars even + // when they are not in MANAGED_ENV_KEYS (e.g. future keys, LANGCHAIN_ENDPOINT). + if ( + key.startsWith("OPENWIKI_") || + key.startsWith("LANGCHAIN_") || + key.startsWith("LANGSMITH_") + ) { + return false; + } + + if (key.startsWith("LC_")) { + return true; + } + + return PASSTHROUGH_ENV_KEYS.has(key) || key === "LANG"; +} diff --git a/src/agent/engines/runner.ts b/src/agent/engines/runner.ts index 2ce1340b..ec13e332 100644 --- a/src/agent/engines/runner.ts +++ b/src/agent/engines/runner.ts @@ -6,7 +6,12 @@ import path from "node:path"; import { createInterface } from "node:readline"; import type { AgentCliProviderConfig } from "../../constants.js"; import type { OpenWikiRunOptions } from "../types.js"; +import { buildAgentCliChildEnv } from "./child-env.js"; import type { AgentCliAdapter, EngineRunSpec } from "./types.js"; +import { + findDisallowedWrites, + formatDisallowedWritesError, +} from "./write-boundary.js"; const DEFAULT_TIMEOUT_SECONDS = 1800; const STDERR_TAIL_LIMIT = 4000; @@ -122,10 +127,17 @@ export async function runAgentCli( return result; } + // Capture the write-boundary clock before spawn so any file the child + // creates/touches can be detected by mtime after the run. + const writeBoundary = spec.writeBoundary ?? "none"; + const writeBoundarySinceMs = Date.now(); + try { const child = spawn(binary, adapter.buildArgs(spec, promptFilePath), { cwd: spec.cwd, detached: true, + // Scrub credentials loaded into process.env by loadOpenWikiEnv(). + env: buildAgentCliChildEnv(), stdio: ["ignore", "pipe", "pipe"], }); @@ -269,6 +281,20 @@ export async function runAgentCli( const finalResult = readResult(); if (finalResult?.ok) { + if (writeBoundary !== "none") { + const disallowed = await findDisallowedWrites( + spec.cwd, + writeBoundarySinceMs, + writeBoundary, + ); + + if (disallowed.length > 0) { + throw new Error( + formatDisallowedWritesError(providerConfig.label, disallowed), + ); + } + } + return outcome; } diff --git a/src/agent/engines/types.ts b/src/agent/engines/types.ts index be198d26..a5588bff 100644 --- a/src/agent/engines/types.ts +++ b/src/agent/engines/types.ts @@ -1,4 +1,5 @@ import type { OpenWikiCommand, OpenWikiRunEvent } from "../types.js"; +import type { AgentCliWriteBoundary } from "./write-boundary.js"; export type EngineRunSpec = { command: OpenWikiCommand; @@ -8,6 +9,12 @@ export type EngineRunSpec = { /** Vendor session id to resume for interactive follow-ups. */ resumeSessionId?: string; modelId: string; + /** + * Post-run filesystem write policy. Defaults to `none`. + * Repository init/update sets `docs-only` so the runner rejects runs that + * touch paths outside `openwiki/` (plus root AGENTS.md / CLAUDE.md). + */ + writeBoundary?: AgentCliWriteBoundary; }; export type AgentCliEvent = diff --git a/src/agent/engines/write-boundary.ts b/src/agent/engines/write-boundary.ts new file mode 100644 index 00000000..9240cd47 --- /dev/null +++ b/src/agent/engines/write-boundary.ts @@ -0,0 +1,147 @@ +import { readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { OPEN_WIKI_DIR } from "../../constants.js"; + +/** + * Filesystem write policy for agent-CLI runs. + * - `docs-only`: only paths under openwiki/ plus root agent instruction files may change. + * - `none`: no post-run path check (chat, or local-wiki where cwd is already the wiki root). + */ +export type AgentCliWriteBoundary = "docs-only" | "none"; + +const IGNORED_DIR_NAMES = new Set([ + ".git", + ".hg", + ".svn", + ".jj", + "node_modules", + "dist", + "build", + "coverage", + ".next", + ".nuxt", + ".turbo", + ".cache", + ".venv", + "venv", + "__pycache__", + "target", + ".idea", + ".vscode", +]); + +/** + * Relative paths allowed under a docs-only write boundary for repository + * init/update runs. Matches the agent-CLI system prompt (openwiki/ plus the + * OpenWiki blocks in top-level agent instruction files). + */ +export function isAllowedDocsOnlyWritePath(relativePath: string): boolean { + const normalized = relativePath.replace(/\\/gu, "/").replace(/^\.\/+/u, ""); + + if ( + normalized === OPEN_WIKI_DIR || + normalized.startsWith(`${OPEN_WIKI_DIR}/`) + ) { + return true; + } + + return normalized === "AGENTS.md" || normalized === "CLAUDE.md"; +} + +/** + * Lists files under `rootDir` whose mtime is at or after `sinceMs`. + * Skips heavy/irrelevant directories. Paths are returned relative to `rootDir` + * with forward slashes. + */ +export async function listFilesModifiedSince( + rootDir: string, + sinceMs: number, +): Promise { + const results: string[] = []; + const stack: string[] = [rootDir]; + + while (stack.length > 0) { + const currentDir = stack.pop(); + + if (currentDir === undefined) { + break; + } + + let entries; + + try { + entries = await readdir(currentDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (entry.name === "." || entry.name === "..") { + continue; + } + + const absolutePath = path.join(currentDir, entry.name); + const relativePath = path + .relative(rootDir, absolutePath) + .replace(/\\/gu, "/"); + + if (entry.isDirectory()) { + if (IGNORED_DIR_NAMES.has(entry.name) || entry.name.startsWith(".git")) { + continue; + } + + stack.push(absolutePath); + continue; + } + + if (!entry.isFile() && !entry.isSymbolicLink()) { + continue; + } + + try { + const fileStat = await stat(absolutePath); + + if (fileStat.mtimeMs >= sinceMs) { + results.push(relativePath); + } + } catch { + // Race: file deleted mid-walk. + } + } + } + + return results; +} + +/** + * Returns paths modified under `rootDir` since `sinceMs` that violate the + * docs-only policy, or an empty array when the boundary is not enforced. + */ +export async function findDisallowedWrites( + rootDir: string, + sinceMs: number, + boundary: AgentCliWriteBoundary, +): Promise { + if (boundary === "none") { + return []; + } + + const modified = await listFilesModifiedSince(rootDir, sinceMs); + + return modified.filter((relativePath) => !isAllowedDocsOnlyWritePath(relativePath)); +} + +export function formatDisallowedWritesError( + providerLabel: string, + disallowed: string[], +): string { + const sample = disallowed.slice(0, 8).join(", "); + const more = + disallowed.length > 8 ? ` (+${disallowed.length - 8} more)` : ""; + + return ( + `${providerLabel} modified files outside the OpenWiki docs-only write boundary ` + + `(only ${OPEN_WIKI_DIR}/, AGENTS.md, and CLAUDE.md are allowed for init/update). ` + + `Disallowed paths: ${sample}${more}.` + ); +} diff --git a/src/agent/index.ts b/src/agent/index.ts index 44789c5c..99ca48d9 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -381,15 +381,21 @@ async function runAgentCliRun( emitDebug(options, `engine.resume session=${resumeSessionId}`); } - // Path discipline for agent-cli lives in createSystemPrompt(..., "agent-cli"); - // the user message reuses the same root/cwd framing as DeepAgents. + // Path discipline for agent-cli lives in createSystemPrompt(..., "agent-cli") + // and a post-run docs-only write-boundary check (see runAgentCli). + // The user message reuses the same root/cwd framing as DeepAgents. const prompt = `${createSystemPrompt(command, outputMode, "agent-cli")}\n\n---\n\n${createRunUserMessage(command, cwd, context, options)}`; + // Repository init/update must not touch application source. Chat and + // local-wiki runs either need freeform edits or already use the wiki root as cwd. + const writeBoundary = + command !== "chat" && outputMode === "repository" ? "docs-only" : "none"; const spec: EngineRunSpec = { command, cwd, modelId, prompt, resumeSessionId, + writeBoundary, }; const outcome = await runAgentCli( diff --git a/test/agent-cli-security.test.ts b/test/agent-cli-security.test.ts new file mode 100644 index 00000000..b8554e70 --- /dev/null +++ b/test/agent-cli-security.test.ts @@ -0,0 +1,326 @@ +import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { + buildAgentCliChildEnv, + shouldPassEnvKey, +} from "../src/agent/engines/child-env.ts"; +import { runAgentCli } from "../src/agent/engines/runner.ts"; +import type { + AgentCliAdapter, + EngineRunSpec, +} from "../src/agent/engines/types.ts"; +import { + findDisallowedWrites, + isAllowedDocsOnlyWritePath, +} from "../src/agent/engines/write-boundary.ts"; +import type { AgentCliProviderConfig } from "../src/constants.ts"; + +const originalTimeout = process.env.OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS; +const originalAnthropic = process.env.ANTHROPIC_API_KEY; +const originalOpenWikiProvider = process.env.OPENWIKI_PROVIDER; + +afterEach(() => { + if (originalTimeout === undefined) { + delete process.env.OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS; + } else { + process.env.OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS = originalTimeout; + } + + if (originalAnthropic === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = originalAnthropic; + } + + if (originalOpenWikiProvider === undefined) { + delete process.env.OPENWIKI_PROVIDER; + } else { + process.env.OPENWIKI_PROVIDER = originalOpenWikiProvider; + } +}); + +describe("buildAgentCliChildEnv", () => { + test("passes PATH and HOME but drops managed secrets", () => { + const childEnv = buildAgentCliChildEnv({ + PATH: "/usr/bin", + HOME: "/home/user", + ANTHROPIC_API_KEY: "sk-secret", + OPENWIKI_PROVIDER: "anthropic", + OPENWIKI_NOTION_TOKEN: "secret-token", + LANGSMITH_API_KEY: "lsv2_secret", + LANGCHAIN_TRACING_V2: "true", + TERM: "xterm-256color", + SOME_RANDOM_APP_VAR: "should-not-pass", + }); + + expect(childEnv.PATH).toBe("/usr/bin"); + expect(childEnv.HOME).toBe("/home/user"); + expect(childEnv.TERM).toBe("xterm-256color"); + expect(childEnv.ANTHROPIC_API_KEY).toBeUndefined(); + expect(childEnv.OPENWIKI_PROVIDER).toBeUndefined(); + expect(childEnv.OPENWIKI_NOTION_TOKEN).toBeUndefined(); + expect(childEnv.LANGSMITH_API_KEY).toBeUndefined(); + expect(childEnv.LANGCHAIN_TRACING_V2).toBeUndefined(); + expect(childEnv.SOME_RANDOM_APP_VAR).toBeUndefined(); + }); + + test("shouldPassEnvKey blocks secret-like names", () => { + expect(shouldPassEnvKey("PATH")).toBe(true); + expect(shouldPassEnvKey("HOME")).toBe(true); + expect(shouldPassEnvKey("MY_API_KEY")).toBe(false); + expect(shouldPassEnvKey("REFRESH_TOKEN")).toBe(false); + expect(shouldPassEnvKey("OPENWIKI_DEBUG")).toBe(false); + }); +}); + +describe("write-boundary helpers", () => { + test("allows openwiki paths and root instruction files only", () => { + expect(isAllowedDocsOnlyWritePath("openwiki/quickstart.md")).toBe(true); + expect(isAllowedDocsOnlyWritePath("openwiki/.last-update.json")).toBe(true); + expect(isAllowedDocsOnlyWritePath("AGENTS.md")).toBe(true); + expect(isAllowedDocsOnlyWritePath("CLAUDE.md")).toBe(true); + expect(isAllowedDocsOnlyWritePath("src/index.ts")).toBe(false); + expect(isAllowedDocsOnlyWritePath("package.json")).toBe(false); + }); + + test("findDisallowedWrites reports files outside the boundary", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "openwiki-wb-")); + const sinceMs = Date.now() - 1000; + + await mkdir(path.join(dir, "openwiki"), { recursive: true }); + await writeFile(path.join(dir, "openwiki", "ok.md"), "docs", "utf8"); + await writeFile(path.join(dir, "evil.ts"), "pwned", "utf8"); + + const disallowed = await findDisallowedWrites(dir, sinceMs, "docs-only"); + + expect(disallowed).toContain("evil.ts"); + expect(disallowed).not.toContain("openwiki/ok.md"); + }); +}); + +describe("runAgentCli security controls", () => { + test("does not forward secrets into the child environment", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "openwiki-agent-cli-")); + const script = path.join(dir, "dump-env.mjs"); + const outFile = path.join(dir, "env-dump.json"); + + await writeFile( + script, + `#!/usr/bin/env node +import { writeFileSync } from "node:fs"; +writeFileSync(${JSON.stringify(outFile)}, JSON.stringify(process.env)); +console.log(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "s1" })); +`, + { mode: 0o755 }, + ); + + process.env.ANTHROPIC_API_KEY = "sk-should-not-leak"; + process.env.OPENWIKI_PROVIDER = "anthropic"; + process.env.OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS = "30"; + + const adapter: AgentCliAdapter = { + id: "dump-env", + detectInstall() { + return Promise.resolve({ found: true, version: "0" }); + }, + buildArgs() { + return []; + }, + createParser() { + return { + parse(line: unknown) { + if ( + typeof line === "object" && + line !== null && + (line as { type?: string }).type === "end" + ) { + return [ + { type: "session", sessionId: "s1" }, + { type: "result", ok: true }, + ]; + } + + return []; + }, + flush: () => [], + }; + }, + }; + + const providerConfig: AgentCliProviderConfig = { + kind: "agent-cli", + binaryEnvKey: "OPENWIKI_TEST_DUMP_ENV_BINARY", + defaultBinary: script, + installHint: "n/a", + label: "Dump Env", + modelOptions: [], + }; + + const spec: EngineRunSpec = { + command: "chat", + cwd: dir, + modelId: "x", + prompt: "hi", + writeBoundary: "none", + }; + + await runAgentCli(adapter, providerConfig, spec, {}); + + const dumped = JSON.parse(await readFile(outFile, "utf8")) as Record< + string, + string + >; + + expect(dumped.ANTHROPIC_API_KEY).toBeUndefined(); + expect(dumped.OPENWIKI_PROVIDER).toBeUndefined(); + expect(dumped.HOME ?? dumped.USERPROFILE).toBeTruthy(); + }); + + test("fails docs-only runs that write outside openwiki/", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "openwiki-agent-cli-")); + const script = path.join(dir, "write-evil.mjs"); + + await writeFile( + script, + `#!/usr/bin/env node +import { writeFileSync, mkdirSync } from "node:fs"; +import path from "node:path"; +mkdirSync(path.join(process.cwd(), "openwiki"), { recursive: true }); +writeFileSync(path.join(process.cwd(), "openwiki", "ok.md"), "docs"); +writeFileSync(path.join(process.cwd(), "evil.ts"), "pwned"); +console.log(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "s2" })); +`, + { mode: 0o755 }, + ); + await chmod(script, 0o755); + + process.env.OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS = "30"; + + const adapter: AgentCliAdapter = { + id: "write-evil", + detectInstall() { + return Promise.resolve({ found: true, version: "0" }); + }, + buildArgs() { + return []; + }, + createParser() { + return { + parse(line: unknown) { + if ( + typeof line === "object" && + line !== null && + (line as { type?: string }).type === "end" + ) { + return [ + { type: "session", sessionId: "s2" }, + { type: "result", ok: true }, + ]; + } + + return []; + }, + flush: () => [], + }; + }, + }; + + const providerConfig: AgentCliProviderConfig = { + kind: "agent-cli", + binaryEnvKey: "OPENWIKI_TEST_WRITE_EVIL_BINARY", + defaultBinary: script, + installHint: "n/a", + label: "Write Evil", + modelOptions: [], + }; + + await expect( + runAgentCli( + adapter, + providerConfig, + { + command: "init", + cwd: dir, + modelId: "x", + prompt: "init", + writeBoundary: "docs-only", + }, + {}, + ), + ).rejects.toThrow(/docs-only write boundary/); + }); + + test("allows docs-only runs that only write under openwiki/", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "openwiki-agent-cli-")); + const script = path.join(dir, "write-ok.mjs"); + + await writeFile( + script, + `#!/usr/bin/env node +import { writeFileSync, mkdirSync } from "node:fs"; +import path from "node:path"; +mkdirSync(path.join(process.cwd(), "openwiki"), { recursive: true }); +writeFileSync(path.join(process.cwd(), "openwiki", "quickstart.md"), "docs"); +console.log(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "s3" })); +`, + { mode: 0o755 }, + ); + + process.env.OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS = "30"; + + const adapter: AgentCliAdapter = { + id: "write-ok", + detectInstall() { + return Promise.resolve({ found: true, version: "0" }); + }, + buildArgs() { + return []; + }, + createParser() { + return { + parse(line: unknown) { + if ( + typeof line === "object" && + line !== null && + (line as { type?: string }).type === "end" + ) { + return [ + { type: "session", sessionId: "s3" }, + { type: "result", ok: true }, + ]; + } + + return []; + }, + flush: () => [], + }; + }, + }; + + const providerConfig: AgentCliProviderConfig = { + kind: "agent-cli", + binaryEnvKey: "OPENWIKI_TEST_WRITE_OK_BINARY", + defaultBinary: script, + installHint: "n/a", + label: "Write Ok", + modelOptions: [], + }; + + const outcome = await runAgentCli( + adapter, + providerConfig, + { + command: "init", + cwd: dir, + modelId: "x", + prompt: "init", + writeBoundary: "docs-only", + }, + {}, + ); + + expect(outcome.sessionId).toBe("s3"); + }); +}); From d86951060e2f3c3d4bdea9d1b89f909e85ad13f0 Mon Sep 17 00:00:00 2001 From: Sanjay Ramadugu Date: Wed, 15 Jul 2026 22:58:25 -0400 Subject: [PATCH 4/4] fix: scan .git in docs-only write boundary Stop skipping VCS metadata trees during the post-run path scan so prompt-injected git hook or config writes under .git/ are rejected. --- src/agent/engines/write-boundary.ts | 11 +-- test/agent-cli-security.test.ts | 104 +++++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/src/agent/engines/write-boundary.ts b/src/agent/engines/write-boundary.ts index 9240cd47..16c7abdd 100644 --- a/src/agent/engines/write-boundary.ts +++ b/src/agent/engines/write-boundary.ts @@ -9,11 +9,12 @@ import { OPEN_WIKI_DIR } from "../../constants.js"; */ export type AgentCliWriteBoundary = "docs-only" | "none"; +/** + * Directories skipped for performance only. Never skip VCS metadata trees + * (`.git`, `.hg`, …) — a docs-only run must not touch them, and the post-run + * scanner is the enforcement layer that catches hooks/config writes there. + */ const IGNORED_DIR_NAMES = new Set([ - ".git", - ".hg", - ".svn", - ".jj", "node_modules", "dist", "build", @@ -86,7 +87,7 @@ export async function listFilesModifiedSince( .replace(/\\/gu, "/"); if (entry.isDirectory()) { - if (IGNORED_DIR_NAMES.has(entry.name) || entry.name.startsWith(".git")) { + if (IGNORED_DIR_NAMES.has(entry.name)) { continue; } diff --git a/test/agent-cli-security.test.ts b/test/agent-cli-security.test.ts index b8554e70..a14c9237 100644 --- a/test/agent-cli-security.test.ts +++ b/test/agent-cli-security.test.ts @@ -98,6 +98,25 @@ describe("write-boundary helpers", () => { expect(disallowed).toContain("evil.ts"); expect(disallowed).not.toContain("openwiki/ok.md"); }); + + test("findDisallowedWrites detects writes under .git (hooks bypass)", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "openwiki-wb-")); + const sinceMs = Date.now() - 1000; + + await mkdir(path.join(dir, ".git", "hooks"), { recursive: true }); + await mkdir(path.join(dir, "openwiki"), { recursive: true }); + await writeFile(path.join(dir, "openwiki", "ok.md"), "docs", "utf8"); + await writeFile( + path.join(dir, ".git", "hooks", "pre-commit"), + "#!/bin/sh\necho pwned\n", + "utf8", + ); + + const disallowed = await findDisallowedWrites(dir, sinceMs, "docs-only"); + + expect(disallowed).toContain(".git/hooks/pre-commit"); + expect(disallowed).not.toContain("openwiki/ok.md"); + }); }); describe("runAgentCli security controls", () => { @@ -180,7 +199,9 @@ console.log(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "s1" test("fails docs-only runs that write outside openwiki/", async () => { const dir = await mkdtemp(path.join(tmpdir(), "openwiki-agent-cli-")); - const script = path.join(dir, "write-evil.mjs"); + // Keep the fake binary outside cwd so the boundary scan does not see it. + const binDir = await mkdtemp(path.join(tmpdir(), "openwiki-agent-bin-")); + const script = path.join(binDir, "write-evil.mjs"); await writeFile( script, @@ -252,9 +273,88 @@ console.log(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "s2" ).rejects.toThrow(/docs-only write boundary/); }); + test("fails docs-only runs that write a git hook under .git/", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "openwiki-agent-cli-")); + const binDir = await mkdtemp(path.join(tmpdir(), "openwiki-agent-bin-")); + const script = path.join(binDir, "write-git-hook.mjs"); + + await writeFile( + script, + `#!/usr/bin/env node +import { writeFileSync, mkdirSync } from "node:fs"; +import path from "node:path"; +mkdirSync(path.join(process.cwd(), "openwiki"), { recursive: true }); +mkdirSync(path.join(process.cwd(), ".git", "hooks"), { recursive: true }); +writeFileSync(path.join(process.cwd(), "openwiki", "ok.md"), "docs"); +writeFileSync( + path.join(process.cwd(), ".git", "hooks", "pre-commit"), + "#!/bin/sh\\necho pwned\\n", +); +console.log(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "s-git" })); +`, + { mode: 0o755 }, + ); + + process.env.OPENWIKI_AGENT_CLI_TIMEOUT_SECONDS = "30"; + + const adapter: AgentCliAdapter = { + id: "write-git-hook", + detectInstall() { + return Promise.resolve({ found: true, version: "0" }); + }, + buildArgs() { + return []; + }, + createParser() { + return { + parse(line: unknown) { + if ( + typeof line === "object" && + line !== null && + (line as { type?: string }).type === "end" + ) { + return [ + { type: "session", sessionId: "s-git" }, + { type: "result", ok: true }, + ]; + } + + return []; + }, + flush: () => [], + }; + }, + }; + + const providerConfig: AgentCliProviderConfig = { + kind: "agent-cli", + binaryEnvKey: "OPENWIKI_TEST_WRITE_GIT_HOOK_BINARY", + defaultBinary: script, + installHint: "n/a", + label: "Write Git Hook", + modelOptions: [], + }; + + await expect( + runAgentCli( + adapter, + providerConfig, + { + command: "init", + cwd: dir, + modelId: "x", + prompt: "init", + writeBoundary: "docs-only", + }, + {}, + ), + ).rejects.toThrow(/docs-only write boundary/); + }); + test("allows docs-only runs that only write under openwiki/", async () => { const dir = await mkdtemp(path.join(tmpdir(), "openwiki-agent-cli-")); - const script = path.join(dir, "write-ok.mjs"); + const binDir = await mkdtemp(path.join(tmpdir(), "openwiki-agent-bin-")); + const script = path.join(binDir, "write-ok.mjs"); await writeFile( script,