diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 354de7b..b4a2ac9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "specbridge", "source": "./integrations/claude-code-plugin/specbridge", "description": "Kiro-compatible spec workflows, verified interactive task execution, and deterministic drift checks.", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "keywords": [ "spec-driven-development", diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e76690..de5dbe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,92 @@ # Changelog +## 0.6.1 + +Added: + +- Gemini CLI adapter (`gemini-cli`, built-in profile `gemini-default`): + headless invocation through the frozen v0.6.0 runner contract with + bounded read-only capability detection (`--version`/`--help` token + probes; never a model request, login, or trusted-folder change). +- Capability-gated Gemini authoring, task execution, and resume: authoring + through the plan approval mode or a read-only tool allowlist; task + execution only when the installed CLI proves a bounded edit policy + (auto_edit plus tool allowlist or sandbox) without arbitrary shell + access; resume only by explicit session UUID with session-identity + verification. +- OpenAI-compatible authoring adapter (`openai-compatible`, built-in + profile `openai-compatible-local`): production stage generation and + refinement against chat-completions and responses API styles. +- Configurable structured-output modes (`json-schema`, `json-object`, + `strict-json-prompt`) with complete-response Zod validation in every + mode and an explicit, warned, opt-in-only downgrade + (`allowStructuredOutputFallback`). +- Experimental Antigravity CLI capability adapter (`antigravity-cli`, + built-in profile `antigravity`): executable/version/documented-capability + detection and transparent diagnostics only — no automation of any kind. +- Read-only MCP runner diagnostic tools: `runner_list` (paginated), + `runner_show`, `runner_doctor`, `runner_matrix` — thin adapters over the + same shared runner services the CLI uses. +- Claude Code `/specbridge:runners` Skill: list profiles, explain + categories and boundaries, diagnose one profile, and recommend + compatible profiles — driven exclusively by the MCP diagnostic tools. +- Additional provider conformance fixtures: process-level fake Gemini and + Antigravity executables and a fake OpenAI-compatible loopback server + covering authentication, quota, rate-limit, timeout, cancellation, + oversized output, malformed/prose/fenced output, protected-path writes, + resume identity, and redirect scenarios — CI needs no real provider and + no network. +- Explicit remote endpoint and redirect protections in the shared HTTP + client: opt-in bounded redirect following with cross-origin + authorization stripping, HTTPS-downgrade rejection, scheme validation, + and recorded safe redirect metadata (default behavior unchanged: + redirects rejected). + +Changed: + +- The runner capability matrix (CLI `runner matrix`, MCP `runner_matrix`, + README, docs) includes Gemini, OpenAI-compatible, and Antigravity and is + generated from one shared implementation in @specbridge/runners. +- Provider diagnostics are available through both the CLI and MCP. +- The plugin bundle includes the runner inspection workflow (nine skills). +- Network-backed authoring reports exact data boundaries (endpoint, API + style, model, structured-output mode, documents, input size, whether a + network request will occur) before execution; dry-run performs no + request. +- Additive contract extensions (no existing field, value, or code + changed): optional `AgentRunner.declaredSupportLevel` (absent = + production, the v0.6.0 behavior) and new `AgentRunnerKind` values + (`gemini-cli`, `openai-compatible`, `antigravity-cli`). All v0.6.0 + contract snapshot tests pass unchanged. + +Security: + +- Gemini YOLO mode is forbidden at three layers (config schema enum plus + config-wide fragment rejection, argv assembly, pre-spawn assertion). +- Gemini task execution requires a bounded safe edit policy; shell tools + are excluded from every allowlist and the policy is never relaxed. +- Antigravity TUI and PTY automation are forbidden (no PTY library, no + keystroke injection, no ANSI parsing — enforced by tests). +- API-key values are never stored: profiles hold an environment-variable + NAME only; the value is read at request time, redacted from every + retained byte, and never logged or passed to verification commands. +- Authorization is never forwarded across origins on redirects, and + HTTPS-to-HTTP downgrades are rejected. +- Generic API runners cannot modify source (authoring-only by capability; + task execution is rejected before any request). +- No new provider is selected implicitly: all new profiles default + disabled, network profiles require explicit selection, experimental + profiles require explicit opt-in. +- Provider claims remain non-authoritative: Git evidence and trusted + verification decide task completion, whatever runner executed. + +Deferred to v0.7: + +- templates and the template registry +- plugin SDK and runner extension SDK distribution +- analyzer SDK and verifier SDK +- extension registry and community ecosystem + ## 0.6.0 Added: diff --git a/README.md b/README.md index d8af801..ea0dda2 100644 --- a/README.md +++ b/README.md @@ -14,44 +14,54 @@ Codex, local models, or any supported coding agent. > Your `.kiro` specs remain the source of truth. -New in v0.6 — keep your existing `.kiro` specs and **choose a compatible +New in v0.6.1 — keep your existing `.kiro` specs and **choose a compatible coding agent or authoring model per operation**: ```text - Spec authoring Task execution -Claude Code ✓ ✓ -Codex CLI ✓ ✓ -Ollama ✓ — + Spec authoring Task execution +Claude Code yes yes +Codex CLI yes yes +Gemini CLI yes capability-gated +Ollama yes no +OpenAI-compatible yes no +Antigravity CLI experimental ``` ```bash specbridge runner matrix +specbridge runner doctor gemini-default + specbridge spec generate notification-preferences \ --stage requirements \ - --runner ollama-local + --runner gemini-default -specbridge spec generate notification-preferences \ +specbridge spec refine notification-preferences \ --stage design \ - --runner codex-default + --runner openai-compatible-local specbridge spec run notification-preferences \ --task 2.3 \ --runner codex-default ``` -Runner behavior is capability-driven: authoring-only model APIs (Ollama) -can never execute tasks, task execution needs a safe sandbox or tool -restriction, network-backed endpoints are never selected implicitly, and +Runner behavior is capability-driven: authoring-only model APIs (Ollama, +OpenAI-compatible endpoints) can never execute tasks, task execution needs +a proven safe boundary (Gemini task execution is enabled only when the +installed CLI proves a bounded edit policy without arbitrary shell access — +YOLO is never used), network-backed endpoints are never selected +implicitly, the Antigravity adapter is experimental detection only, and whatever runs, task completion still requires actual Git evidence plus trusted verification — never a provider claim. The live matrix above is generated from registered runner metadata (`specbridge runner matrix`); not -every installed Codex version is compatible (`specbridge runner doctor -codex-default` reports the exact capabilities). +every installed provider version is compatible (`specbridge runner doctor +` reports the exact capabilities). -SpecBridge does not include provider subscriptions, hosted models, API -usage, or authentication — you install and authenticate Claude Code, the -Codex CLI, or Ollama yourself. See [docs/runners.md](docs/runners.md). +SpecBridge does not include provider subscriptions, hosted model usage, +credentials, or authentication — you install and authenticate Claude Code, +the Codex CLI, the Gemini CLI, Ollama, or your API endpoint yourself. API +keys are referenced by environment-variable NAME only and never stored. +See [docs/runners.md](docs/runners.md). From v0.5 — a self-contained **Claude Code plugin** with a local MCP server and verified interactive task execution: @@ -251,11 +261,12 @@ Working today (fully offline, no model, no API key): | `specbridge spec affected` | **v0.4** — which specs does this change set touch (read-only) | | `specbridge spec policy init / show / validate` | **v0.4** — per-spec verification policies (impact areas, required commands, rule overrides) | | `specbridge verify rules / explain ` | **v0.4** — inspect the stable rule registry SBV001–SBV025 | -| `specbridge mcp serve / doctor / manifest / tools` | **v0.5** — local stdio MCP server (21 tools, 7 resources, 4 prompts) | +| `specbridge mcp serve / doctor / manifest / tools` | **v0.5** — local stdio MCP server (25 tools since v0.6.1, 7 resources, 4 prompts) | | `specbridge run recover-lock` | **v0.5** — diagnose and explicitly recover the interactive execution lock | | `specbridge runner list / matrix / show / doctor` | **v0.6** — profile-based runner diagnostics and the generated capability matrix (read-only) | | `specbridge runner test / conformance / models ` | **v0.6** — bounded structured-output probe (`--network`), conformance suite, provider-supported model listing | | `specbridge config doctor / migrate` | **v0.6** — configuration validation and the explicit v1 → v2 migration (dry-run by default, atomic apply with backup) | +| `specbridge runner doctor gemini-default / openai-compatible-local / antigravity` | **v0.6.1** — diagnostics for the new adapters; MCP `runner_list` / `runner_show` / `runner_doctor` / `runner_matrix` expose the same read-only services | Planned commands (`spec sync/export`) are registered, marked "(planned)" in `--help`, and exit with an honest error — see the [roadmap](docs/roadmap.md). @@ -502,13 +513,21 @@ PROFILES; the live matrix comes from `specbridge runner matrix`: |---------|---------|--------|--------|---------|--------|-------| | claude-code | production | yes | yes | yes | yes | no | | codex-default | production | yes | yes | yes | yes | no | +| gemini-default | production | yes | yes | yes | yes | no | | ollama-local | production | yes | yes | no | no | yes | +| openai-compatible-local | production | yes | yes | no | no | yes | +| antigravity | experimental | no | no | no | no | no | | mock | production | yes | yes | yes | yes | yes | -`codex-default` and `ollama-local` ship DISABLED until you enable them -explicitly. Ollama is authoring-only by capability — it can never execute -tasks or modify files. Gemini CLI, an OpenAI-compatible authoring runner, -and Antigravity are planned for v0.6.1 (no placeholders are registered). +Every non-Claude profile ships DISABLED until you enable it explicitly. +Ollama and OpenAI-compatible endpoints are authoring-only by capability — +they can never execute tasks or modify files. Gemini task execution and +resume are capability-gated: they work only when the installed Gemini CLI +proves a bounded edit policy (auto_edit plus tool allowlist or sandbox) +without arbitrary shell access — YOLO is never used, and an unsafe +installation keeps safe authoring while task execution is refused with the +exact gap. The Antigravity adapter is experimental capability detection +only; it is not a production runner and executes nothing. Configuration lives in `.specbridge/config.json` (schema 2.0.0; v1 files stay readable, migration is explicit — see @@ -530,7 +549,7 @@ stores no credentials of any kind. secrets or environment variables. - Full model: [docs/security.md](docs/security.md). -## Limitations (v0.6) +## Limitations (v0.6.1) - The MCP server is stdio-only and local-only: no HTTP/SSE/WebSocket transport, no OAuth, no cloud hosting. One server process serves one @@ -551,15 +570,21 @@ stores no credentials of any kind. references, chore-task exclusion) are labelled and never default to error. - `spec sync` and `spec export` are not implemented yet (they fail honestly). SARIF output is deferred. -- Production runners are claude-code, codex-cli, ollama (authoring-only), - and mock. Gemini CLI, OpenAI-compatible, and Antigravity are deferred to - v0.6.1 and are not registered as placeholders. Provider usage happens - under your own accounts and plans; not every installed Codex version is - compatible (the doctor reports the exact missing capabilities). -- Ollama cannot execute tasks or modify files — by capability, not by - configuration. Models are never pulled or selected automatically. -- Codex resume needs an installed version with explicit session resume; - without it, resume is reported unsupported instead of guessed. +- Production runners are claude-code, codex-cli, gemini-cli, ollama + (authoring-only), openai-compatible (authoring-only), and mock; the + antigravity-cli adapter is experimental detection only. Provider usage + happens under your own accounts and plans; not every installed provider + version is compatible (the doctor reports the exact missing + capabilities). Not every Gemini CLI version supports safe task + execution, and not every OpenAI-compatible endpoint implements JSON + Schema or the Responses API — the profile declares what its endpoint + supports. +- Ollama and OpenAI-compatible endpoints cannot execute tasks or modify + files — by capability, not by configuration. Models are never pulled or + selected automatically, and model output never proves implementation + correctness. +- Codex and Gemini resume need an installed version with explicit session + resume; without it, resume is reported unsupported instead of guessed. - Authoring fallback exists only for explicitly configured chains, only for stage generation/refinement, with bounded retries; there is no automatic provider switching anywhere else. diff --git a/docs/agent-runners.md b/docs/agent-runners.md index d08bd67..6701fa0 100644 --- a/docs/agent-runners.md +++ b/docs/agent-runners.md @@ -30,10 +30,12 @@ orchestration lives in `@specbridge/execution` and evidence evaluation in ### Runner kinds and statuses Kinds: `mock` (offline, deterministic), `claude-code` (local Claude Code -CLI), `codex-cli` (local Codex CLI, v0.6), and `ollama` (local model API, -authoring-only, v0.6). The v0.3-era `unsupported` stubs are gone — deferred -providers (Gemini CLI, OpenAI-compatible, Antigravity) exist only on the -[roadmap](roadmap.md) and are not registered as placeholders. +CLI), `codex-cli` (local Codex CLI, v0.6), `gemini-cli` (local Gemini CLI, +v0.6.1), `ollama` (local model API, authoring-only, v0.6), +`openai-compatible` (model API, authoring-only, v0.6.1), and +`antigravity-cli` (experimental detection only, v0.6.1). The v0.3-era +`unsupported` stubs are gone; the value remains in the vocabulary so +stored data stays readable. Detection statuses: `available`, `unavailable`, `unauthenticated`, `incompatible`, `misconfigured`, `error`. Only `available` permits diff --git a/docs/antigravity-cli-runner.md b/docs/antigravity-cli-runner.md new file mode 100644 index 0000000..b3f55eb --- /dev/null +++ b/docs/antigravity-cli-runner.md @@ -0,0 +1,80 @@ +# Antigravity CLI adapter (experimental) + +The `antigravity-cli` adapter (v0.6.1) is EXPERIMENTAL: it detects the +executable (default `agy`, configurable), its version, and any DOCUMENTED +headless or machine-readable capabilities, and reports them transparently. +It is not a production automation adapter, and it cannot be marked +production in v0.6.1. + +## What it never does + +- start the interactive TUI (during doctor or ever) +- allocate a pseudo-terminal, inject keystrokes, or parse ANSI screen + output (no PTY/TUI library exists anywhere in the implementation — + enforced by tests) +- automate login or workspace trust +- inspect private session files +- assume Gemini CLI flags, output formats, or session storage — + Antigravity is a different product +- claim task execution, resume, or structured output without detection +- get selected automatically (experimental profiles require explicit + opt-in, and the profile is disabled by default) + +## Profile + +```json +{ + "runnerProfiles": { + "antigravity": { + "runner": "antigravity-cli", + "enabled": false, + "command": { "executable": "agy", "args": [] }, + "experimental": true + } + } +} +``` + +`experimental` is locked to `true` by the schema. + +## Detection + +`specbridge runner doctor antigravity` runs bounded `--version` and +`--help` probes with no stdin connected. A build that hijacks these into an +interactive session simply hits the bounded timeout and is classified as +interactive-only. Where the help output documents them, these observations +are reported (never acted on): headless invocation, machine-readable +output, structured final output, sandbox/permission controls, +workspace-write controls, session identity, resume. + +Typical output: + +``` +Runner: antigravity +Support: experimental + +Detected: + executable + version + interactive workspace support + +Not proven: + stable headless mode + structured final output + bounded edit permissions + session resume contract + +Automation is disabled. +``` + +## Support rules + +- category: `experimental`; support level: `experimental` +- every capability is declared false: stage generation, refinement, task + execution, and resume are all refused by selection before any process + could start (and again defensively by the adapter) +- even when headless/structured tokens are positively detected, support + stays experimental in v0.6.1: a documented, headless, structured-output + contract must pass the applicable conformance suite before any operation + can be considered, and conformance can never confirm production for an + experimental-declared adapter diff --git a/docs/claude-code-plugin.md b/docs/claude-code-plugin.md index 453fa9e..b8084c0 100644 --- a/docs/claude-code-plugin.md +++ b/docs/claude-code-plugin.md @@ -79,6 +79,14 @@ and controlled lifecycle operations and never duplicate core logic: - `continue` finishes an interrupted interactive run honestly (never presenting a fresh run as a resumption). - `verify` runs `spec_check_drift` and asks before `spec_run_verification`. +- `runners` (v0.6.1) is read-only runner inspection: it calls + `runner_list` and `runner_matrix` (and `runner_show`/`runner_doctor` + for a named profile), explains categories and local-versus-network + boundaries, and recommends compatible profiles for an operation. It + never edits configuration, never invokes any provider or nested agent, + never sends a network request itself, and never starts a login. The + existing implementation workflow is unchanged: `task_begin` → the + current Claude Code session edits → `task_complete`. No skill uses `bypassPermissions`, `dangerously-skip-permissions`, unrestricted `Bash(*)`, or unrestricted `Write`, and no skill instructs diff --git a/docs/configuration-migration.md b/docs/configuration-migration.md index 98c25b8..0f19709 100644 --- a/docs/configuration-migration.md +++ b/docs/configuration-migration.md @@ -34,8 +34,9 @@ Guarantees (tested): - Codex and Ollama are NOT enabled; - no credential value is created; - automatic fallback stays disabled; -- unmappable v1 runner entries (e.g. `openai-compatible`) are reported and - remain in the backup (that provider is planned for v0.6.1). +- new-provider profiles (Codex, Ollama, Gemini, OpenAI-compatible, + Antigravity) are added DISABLED; unmappable custom v1 runner entries are + reported and remain in the backup. ## Safety mechanics diff --git a/docs/gemini-cli-runner.md b/docs/gemini-cli-runner.md new file mode 100644 index 0000000..803ee42 --- /dev/null +++ b/docs/gemini-cli-runner.md @@ -0,0 +1,141 @@ +# Gemini CLI runner + +The `gemini-cli` runner (v0.6.1) invokes the locally installed [Gemini +CLI](https://github.com/google-gemini/gemini-cli) in headless mode through +the frozen v0.6.0 runner adapter contract. You install and authenticate the +Gemini CLI yourself; SpecBridge never embeds Google authentication, never +triggers an interactive login, never reads OAuth or credential files, never +modifies your Gemini settings or trusted-folder state, and never enables +extensions or YOLO mode. + +## Profile + +The built-in profile is `gemini-default`, DISABLED by default: + +```json +{ + "runnerProfiles": { + "gemini-default": { + "runner": "gemini-cli", + "enabled": false, + "command": { "executable": "gemini", "args": [] }, + "model": null, + "approvalModeForAuthoring": "plan", + "approvalModeForExecution": "auto_edit", + "sandbox": true, + "allowedTools": [], + "disabledExtensions": true, + "timeoutMs": 1800000 + } + } +} +``` + +- `approvalModeForAuthoring` accepts only `plan`; `approvalModeForExecution` + accepts only `auto_edit` or `default`. `yolo` is not a value of either + enum, is rejected by the config-wide forbidden-fragment check, and is + refused again by the pre-spawn argument assertion. +- `allowedTools` may add extra tools for task execution; shell-execution + tool names are rejected by the schema. +- The command is executable plus argv array; shell strings are rejected. + +## Capability detection + +`specbridge runner doctor gemini-default` runs bounded, read-only probes +(`--version`, `--help`) — never a model request, never a login, never a +trusted-folder change. Detection does not depend on one exact help layout; +it searches for tokens: + +- headless prompt invocation (`--prompt`) — required +- machine-readable output (`--output-format` with `json`) — required +- streaming events (`stream-json`) — optional (JSON envelope fallback) +- approval-mode selection (`--approval-mode`) — required +- plan mode / auto_edit mode / sandbox / tool allowlist / extension + restriction / model selection / session listing / explicit resume — + optional, each degrading a specific capability + +Authentication cannot be verified without a model request, so the doctor +reports it as `unknown`; `specbridge runner test gemini-default --network` +performs one minimal, bounded structured-output probe. + +## Support levels for the installed version + +Capabilities are downgraded from detection — never assigned from the +provider name: + +| Situation | Result | +|-----------|--------| +| headless + JSON output + plan mode (or tool allowlist) | authoring is production | +| additionally auto_edit + (tool allowlist or sandbox) | task execution is production | +| additionally explicit `--resume ` support | task resume is available | +| no plan mode AND no tool allowlist | authoring incompatible (no read-only boundary) | +| no auto_edit, or neither allowlist nor sandbox | task execution incompatible (a bounded edit policy cannot be proven); authoring stays available | + +When task execution is incompatible the doctor explains the exact gap and +recommends compatible `claude-code` or `codex-cli` profiles. The policy is +never relaxed and YOLO is never used as a workaround. + +## Authoring boundary (read-only) + +Stage generation and refinement run with: + +- the `plan` approval mode (read-only) where supported, +- a repository-reading tool allowlist (`read_file`, `read_many_files`, + `list_directory`, `glob`, `search_file_content`) where supported, +- `--sandbox` where supported, `--extensions none` where supported, +- the prompt via stdin (never in the process list), +- JSON or stream-JSON output. + +The Gemini process never writes the `.kiro` target document. SpecBridge +receives the candidate Markdown as a strict JSON-only response, validates +the COMPLETE response with Zod (no Markdown fences, no substring +extraction, no guessing of missing fields), analyzes it, and applies it +atomically through the shared authoring logic. The candidate remains +unapproved. At most one structured-output correction retry runs, recorded +as a separate append-only attempt. + +## Task execution boundary (bounded edits, no shell) + +Task execution runs headlessly with `auto_edit`: file edits are the only +auto-approved action, and every other tool request — including +`run_shell_command` — is refused (there is no interactive approval in +headless mode, and shell tools are additionally excluded from the +allowlist). The Gemini CLI does not need shell access to satisfy the task +contract: trusted build/test/lint/typecheck commands remain SpecBridge +verification commands executed after the model exits. + +Never used or permitted: YOLO, automatic workspace trust, commits, pushes, +resets, stashes, `.kiro` or `.specbridge` edits, checkbox updates. + +Gemini tool events and reported test results are CLAIMS. Git state and +trusted verification remain authoritative: protected-path changes and +tasks.md edits are detected from snapshots and prevent verification, and a +malformed final result leaves the task unchecked with evidence preserved. + +## Resume (explicit session identity only) + +Resume happens only when the original run captured an explicit session +UUID, the installed version supports `--resume `, the project root +matches, approvals and the task fingerprint are current, and the repository +state reconciles. `latest`, indexes, ambiguous identifiers, sessions from +other projects, and completed verified tasks are never resumed. If the +provider reports a different session identity during a resume, the +discrepancy is reported, the resume is not claimed successful, run lineage +is preserved, and the task stays unchecked. + +## Events and reasoning boundary + +Stream-JSON events are normalized into the shared event model +(session/tool/file-edit/usage/result/error). `thought` events are provider +reasoning: their text is never normalized, never retained (the raw stream +keeps only a length marker), and never surfaced anywhere. + +## Known limitations + +- Installed-version capabilities vary; the doctor reports exactly what was + proven. Not every Gemini CLI version supports safe task execution. +- No native JSON-Schema-constrained output: structured output uses the + strict JSON-only response contract with complete-response validation + (the conformance-approved fallback). +- Model listing is not supported without a model request; configure + `model` explicitly. diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 371e8bb..34c01b0 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -32,6 +32,13 @@ specbridge mcp manifest [--json] # identity + capability counts specbridge mcp tools [--json] [--verbose] # tool/resource/prompt catalog ``` +v0.6.1 adds four read-only runner diagnostic tools (`runner_list`, +`runner_show`, `runner_doctor`, `runner_matrix`) — thin adapters over the +same shared runner services the CLI uses; see [mcp-tools.md](mcp-tools.md). +They keep the stdio protocol clean (logs go to stderr only), redact +credential-shaped values, never expose environment-variable values, never +make an inference request, and remain strictly read-only. + `mcp serve` defaults to stdio. `mcp doctor` validates the project root, `.kiro` availability, `.specbridge` configuration, package and protocol versions, registry integrity, stdio cleanliness, plugin bundle paths (when diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 0378a04..a514a1a 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -32,6 +32,16 @@ tool** (approval is a human CLI action — see | `spec_affected` | v0.4 affected-spec resolution over a Git comparison: affected specs with match mechanisms, ambiguous mappings, unmapped changes. | | `spec_check_drift` | The deterministic SBV rule engine over one spec / changed specs / all specs. **Never executes commands and never persists reports.** | | `spec_stage_validate` | Validate a candidate stage document in memory: deterministic analysis, proposed diff, approval-invalidation effects, and the binding `candidateHash`. Writes nothing. | +| `runner_list` | v0.6.1: paginated runner profile summaries — implementation, category, support level, enabled state, model, local/network classification, supported operations; optional read-only availability probing. | +| `runner_show` | v0.6.1: one profile in depth — redacted configuration, declared and detected capabilities, operation compatibility, invocation-free conformance summary, network boundary, limitations, remediation. | +| `runner_doctor` | v0.6.1: runner diagnostics through the same shared detection the CLI uses. Never a model request, never a login, never a configuration change, never an interactive UI. | +| `runner_matrix` | v0.6.1: the authoritative capability matrix (structured rows + Markdown), generated by the same shared implementation as `specbridge runner matrix`. | + +The four runner tools are read-only adapters over the shared runner +services: they log only to stderr, redact everything credential-shaped +(profiles cannot store credentials at all), expose no environment-variable +values or provider debug logs, and cannot execute a runner or send a +runner test request. ## State-changing tools diff --git a/docs/network-data-boundaries.md b/docs/network-data-boundaries.md index 36b8b64..e493582 100644 --- a/docs/network-data-boundaries.md +++ b/docs/network-data-boundaries.md @@ -8,9 +8,9 @@ shows the boundary before anything runs. | Boundary | Meaning | Examples | | --- | --- | --- | | `in-process` | deterministic mock; nothing leaves the process | mock | -| `local-process` | a local child process; the provider CLI handles its own connectivity with its own authentication | claude-code, codex-cli | -| `loopback-endpoint` | SpecBridge sends HTTP to 127.0.0.1/localhost/[::1]; inference stays on this machine | ollama-local | -| `network-endpoint` | SpecBridge sends spec content to a configured remote endpoint | remote Ollama profile | +| `local-process` | a local child process; the provider CLI handles its own connectivity with its own authentication | claude-code, codex-cli, gemini-cli | +| `loopback-endpoint` | SpecBridge sends HTTP to 127.0.0.1/localhost/[::1]; inference stays on this machine | ollama-local, openai-compatible-local | +| `network-endpoint` | SpecBridge sends spec content to a configured remote endpoint | remote Ollama or OpenAI-compatible profile | Attempt records store the boundary; runner plans and `runner list/show` print it. Network-backed profiles are clearly identified everywhere. @@ -39,11 +39,27 @@ the full repository contents. - bounded request timeout + AbortSignal cancellation - response size limits enforced while streaming (oversized bodies abort) -- redirects never followed (a redirect is a failure, not a hop) +- redirects rejected by default (a redirect is a failure, not a hop); + the openai-compatible adapter opts into BOUNDED redirect following + (max 3 hops) where the Authorization header — and every custom header — + is never forwarded across origins, HTTPS never downgrades to HTTP, + unsupported schemes and credential-bearing targets are rejected, and + safe redirect metadata (count, final URL, cross-origin flag) is recorded - http(s) only; no embedded credentials; loopback by default; remote needs - HTTPS or the labeled `allowInsecureHttp` development override + HTTPS or the labeled `allowInsecureHttp` development override (visible + in runner plans and diagnostics) +- API keys are referenced by environment-variable NAME + (`apiKeyEnvironmentVariable`); the value is read at request time only, + redacted from everything retained, and never stored or logged - JSON parsed defensively; unexpected content types rejected +## Network-backed authoring reports its exact boundary + +Before a network-backed authoring run (and in every dry run), SpecBridge +reports: endpoint host, API style, selected model, structured-output mode, +the documents included, the approximate input size, whether authentication +is configured (never the value), and whether a network request will occur. + ## Dry runs `--dry-run` never sends an HTTP request and never invokes provider print diff --git a/docs/openai-compatible-runner.md b/docs/openai-compatible-runner.md new file mode 100644 index 0000000..331bcc8 --- /dev/null +++ b/docs/openai-compatible-runner.md @@ -0,0 +1,119 @@ +# OpenAI-compatible runner + +The `openai-compatible` runner (v0.6.1) is a production model-API adapter +for endpoints implementing the OpenAI-style `chat-completions` or +`responses` APIs (vLLM, llama.cpp server, LM Studio, hosted gateways, …). + +**Authoring only.** Scope: stage generation, stage refinement, model listing +(only when the endpoint declares support), structured-output validation. +Explicitly unsupported: task execution, task resume, source modification, +autonomous tool loops, arbitrary function calling, shell execution, +repository writes. SpecBridge does not implement a coding agent around a +generic API — selection rejects task execution before any request. + +## Profile + +The built-in profile is `openai-compatible-local`, DISABLED by default: + +```json +{ + "runnerProfiles": { + "openai-compatible-local": { + "runner": "openai-compatible", + "enabled": false, + "baseUrl": "http://127.0.0.1:8000/v1", + "apiStyle": "chat-completions", + "model": null, + "structuredOutput": "json-schema", + "allowStructuredOutputFallback": false, + "apiKeyEnvironmentVariable": null, + "modelsEndpoint": false, + "timeoutMs": 300000, + "maximumInputCharacters": 500000, + "maximumOutputBytes": 2097152, + "allowInsecureHttp": false + } + } +} +``` + +`apiStyle` is `chat-completions` (POST /chat/completions) or `responses` +(POST /responses). Do not assume every compatible endpoint implements +OpenAI's complete API — the profile declares what its endpoint supports; +nothing is probed by paid inference. + +## Endpoint security + +Accepted: HTTP loopback endpoints, HTTPS remote endpoints. Rejected: +remote plain HTTP (unless the clearly-labeled `allowInsecureHttp` +development override is set — it defaults to false and is surfaced in +runner plans and diagnostics), `file:`/`ftp:`/other schemes, embedded URL +credentials, null bytes, malformed URLs. + +Redirect policy (v0.6.1 shared HTTP client): bounded redirect count (3), +the Authorization header (and every custom header) is never forwarded +across origins, HTTPS-to-HTTP downgrades are rejected, redirects to +unsupported schemes or credential-bearing targets are rejected, and safe +redirect metadata (count, final URL, cross-origin flag) is recorded. + +## Authentication + +Configuration may contain only `apiKeyEnvironmentVariable` — an +environment-variable NAME (schema-validated). SpecBridge never stores a +key value: at runtime it reads exactly that one variable, sends it as the +Authorization header, redacts it from every retained byte (raw bodies, +error excerpts, diagnostics), never logs it, never puts it in attempt +metadata, and never passes it to verification commands. Endpoints without +authentication remain fully supported. Credential-bearing header names +(`Authorization`, `x-api-key`, `Cookie`, …) are rejected in the custom +`headers` field so no key value can enter the configuration file. + +## Structured-output modes + +`structuredOutput` is explicit — one of: + +1. `json-schema` — native JSON Schema constraining (strict mode) via + `response_format`/`text.format`; the complete response is still + validated with Zod. +2. `json-object` — native JSON-object mode plus full Zod validation. +3. `strict-json-prompt` — JSON-only prompt contract plus complete-response + validation. + +In every mode the ENTIRE response text must be one JSON document: Markdown +fences are rejected, extra prose is rejected, JSON is never extracted from +a substring, and malformed JSON is never repaired silently. An endpoint +that rejects the configured native mode produces +`structured_output_unsupported`; a downgrade to the next weaker mode +happens ONLY when `allowStructuredOutputFallback` is explicitly true, and +it is reported as a warning. Structured-output support is never inferred +from provider branding. + +At most one correction retry runs for authoring. Authentication errors, +quota exhaustion, invalid profiles, unsupported structured output, and +user cancellation are never retried. + +## Data boundary + +Before network-backed execution, `--show-runner-plan` / dry-run reports: +endpoint host, API style, selected model, structured-output mode, included +documents, approximate input size, whether authentication is configured +(never the value), and whether a network request will occur. The default +authoring context contains only relevant steering, the current stage, +approved prerequisite stages, the explicit refinement instruction, and +bounded referenced content selected by the shared authoring logic — never +`.env`, credentials, unrelated sources, raw run logs, provider artifacts, +or the whole repository. Dry-run makes no HTTP request. + +## Doctor and model listing + +Ordinary `runner doctor` makes no inference request. When the profile +declares `modelsEndpoint: true`, the doctor may issue one safe +non-inference GET /models reachability probe; otherwise no request is made +and the diagnostics say so. `specbridge runner models +openai-compatible-local` works only with the declared endpoint, uses no +inference, never guesses model names, and reports only fields the endpoint +returns (id, owner, creation time) — model capabilities are never claimed +beyond what the endpoint reports. + +`specbridge runner test openai-compatible-local --network` performs one +bounded inference request. diff --git a/docs/plugin-security.md b/docs/plugin-security.md index cb7f0b8..6bdd530 100644 --- a/docs/plugin-security.md +++ b/docs/plugin-security.md @@ -21,7 +21,7 @@ the v0.5 threat model in [security.md](security.md). ## The skills' permission surface -- Seven of the eight skills declare **no** `allowed-tools` at all: they use +- Eight of the nine skills declare **no** `allowed-tools` at all: they use the plugin's MCP tools under Claude Code's normal permission system. - The `approve` skill declares exactly one narrow allowance — `Bash("${CLAUDE_PLUGIN_ROOT}/bin/specbridge" spec approve *)` — for the diff --git a/docs/roadmap.md b/docs/roadmap.md index faa1a75..f8cf3b0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -22,6 +22,7 @@ implemented unless marked ✅ and covered by tests. | M — Claude Code plugin | self-contained plugin (bundled CLI + MCP server, 8 namespaced skills, human-only approve skill, local marketplace, ZIP artifact, isolated-copy verification) | ✅ v0.5 | | N — Capability-driven runner platform | versioned capability/operation/event/result/error contracts (frozen for v0.6.1 with snapshot tests), runner profiles, config schema v2 + explicit migration (`config doctor/migrate`), deterministic selection, explicit bounded authoring fallback, append-only attempt records, reusable conformance framework, `runner matrix/show/test/conformance/models` | ✅ v0.6.0 | | O — Production multi-runner | Codex CLI agent runner (read-only authoring sandbox, workspace-write execution, explicit-session resume, no unrestricted modes) and Ollama authoring runner (loopback-default model API, schema-validated structured output, bounded correction retry, authoring-only by capability); Claude Code runner migrated onto the shared contract unchanged | ✅ v0.6.0 | +| P — Adapter expansion | Gemini CLI runner (plan-mode/allowlist authoring, capability-gated bounded-edit task execution, explicit-UUID resume, never YOLO), OpenAI-compatible authoring runner (chat-completions + responses, explicit structured-output modes, env-var-name credentials, safe redirects), experimental Antigravity capability adapter (detection only, no PTY/TUI automation), read-only MCP runner diagnostics (`runner_list/show/doctor/matrix`), `/specbridge:runners` plugin skill | ✅ v0.6.1 | ## Command availability @@ -34,17 +35,28 @@ implemented unless marked ✅ and covered by tests. | `mcp serve/doctor/manifest/tools`, `run recover-lock` | ✅ v0.5 | | `/specbridge:doctor·status·new·author·approve·implement·continue·verify` (plugin) | ✅ v0.5 | | `runner list/matrix/show/doctor/test/conformance/models`, `config doctor/migrate`, `spec generate/refine/run --runner `, `--show-runner-plan` | ✅ v0.6.0 — codex/ollama via your local installation; fake providers in CI | +| `--runner gemini-default / openai-compatible-local`, `runner doctor antigravity`, MCP `runner_list/show/doctor/matrix`, `/specbridge:runners` | ✅ v0.6.1 — gemini/API endpoints via your own installation and accounts; fake providers in CI | | `spec sync/export` | ❌ registered as "(planned)", exit 2 with an honest message | -## v0.6.1 (planned — not implemented) - -New adapters against the FROZEN v0.6.0 contract (no core changes expected): - -- Gemini CLI runner. -- OpenAI-compatible API authoring runner. -- Antigravity CLI capability adapter. -- MCP runner diagnostic tools. -- Claude Code `/specbridge:runners` Skill. +## v0.6.1 (✅ implemented) + +Adapter expansion against the FROZEN v0.6.0 contract (additive-only +contract changes: optional `declaredSupportLevel`, new `AgentRunnerKind` +values — every v0.6.0 snapshot test passes unchanged): + +- Gemini CLI runner: safe read-only authoring (plan mode / tool + allowlist), capability-gated task execution (bounded edit policy, no + arbitrary shell, never YOLO), explicit-UUID resume. +- OpenAI-compatible API authoring runner: chat-completions and responses + styles, explicit structured-output modes, environment-variable-name + credentials, bounded redirects with cross-origin authorization + stripping. Authoring only — no task execution through generic APIs. +- Antigravity CLI capability adapter: EXPERIMENTAL detection and + diagnostics only; no TUI/PTY automation exists. +- Read-only MCP runner diagnostic tools: `runner_list`, `runner_show`, + `runner_doctor`, `runner_matrix` over the shared runner services. +- Claude Code `/specbridge:runners` Skill (MCP-diagnostics-driven, + read-only). ## v0.7 (planned — not implemented) diff --git a/docs/runner-adapter-contract.md b/docs/runner-adapter-contract.md index 24f75a8..2607fb6 100644 --- a/docs/runner-adapter-contract.md +++ b/docs/runner-adapter-contract.md @@ -1,11 +1,23 @@ -# Runner adapter contract (v0.6 — FROZEN for v0.6.1) +# Runner adapter contract (v0.6 — FROZEN; implemented by the v0.6.1 adapters) -This document defines the public adapter contract that v0.6.1 providers -(Gemini CLI, OpenAI-compatible, Antigravity) will implement. The exported +This document defines the public adapter contract the v0.6.1 providers +(Gemini CLI, OpenAI-compatible, Antigravity) implement. The exported names below are stable; changing their shapes, discriminator values, or required methods is a breaking change and is guarded by the contract snapshot tests in `tests/runners/contracts.test.ts`. +v0.6.1 made exactly two ADDITIVE, backward-compatible extensions (no +existing field, value, meaning, or error code changed; every v0.6.0 +snapshot test passes unchanged): + +- `AgentRunner.declaredSupportLevel?` (optional) — the support level the + adapter itself declares. Absent means `production` (the v0.6.0 + behavior). Preview/experimental adapters are never selected + automatically and can never be confirmed production by conformance. +- New `AgentRunnerKind` values in `@specbridge/core`: `gemini-cli`, + `openai-compatible`, `antigravity-cli` (value additions to an existing + union; stored data referencing older kinds stays readable). + Internal implementation details (adapter file layout, private helpers, prompt assembly internals) are NOT frozen. diff --git a/docs/runner-capabilities.md b/docs/runner-capabilities.md index 5459899..d4180d9 100644 --- a/docs/runner-capabilities.md +++ b/docs/runner-capabilities.md @@ -7,12 +7,12 @@ requested operation needs. ## Categories -| Category | Meaning | v0.6.0 | +| Category | Meaning | v0.6.1 | | --- | --- | --- | -| `agent-cli` | local coding-agent CLI; may modify sources under a bounded sandbox/tool restriction | claude-code, codex-cli | -| `model-api` | model endpoint; authoring only, no repository access | ollama | +| `agent-cli` | local coding-agent CLI; may modify sources under a bounded sandbox/tool restriction | claude-code, codex-cli, gemini-cli | +| `model-api` | model endpoint; authoring only, no repository access | ollama, openai-compatible | | `mock` | deterministic in-process runner for tests/conformance | mock | -| `experimental` | reserved for detection-only integrations | (none) | +| `experimental` | detection-only integrations | antigravity-cli | ## Support levels @@ -25,6 +25,11 @@ requested operation needs. | `incompatible` | provider exists but required capabilities are unavailable | Detection only downgrades: `runner doctor` shows the effective level. +Since v0.6.1 an adapter may DECLARE a non-production level +(`AgentRunner.declaredSupportLevel`, additive and optional; absent means +production). The antigravity-cli adapter declares `experimental`: it is +never selected automatically and conformance can never confirm it +production. ## Capability keys diff --git a/docs/runner-conformance.md b/docs/runner-conformance.md index bf77cf0..1337591 100644 --- a/docs/runner-conformance.md +++ b/docs/runner-conformance.md @@ -46,3 +46,20 @@ specbridge runner conformance ollama-local --network --json - all applicable groups pass → `production` confirmed - a documented optional capability fails → at best `preview` - required production capabilities fail → `incompatible` +- an adapter that DECLARES `preview` or `experimental` (v0.6.1: + antigravity-cli) can pass its applicable checks but is NEVER confirmed + production by conformance + +## v0.6.1 adapters + +- gemini-cli runs detection, structured-output, process-control, + stage-generation, stage-refinement, and — when its declared capabilities + include them — task-execution and resume. +- openai-compatible runs detection, structured-output, HTTP process + control, stage-generation, and stage-refinement; task-execution and + resume are not applicable (authoring-only by capability). +- antigravity-cli runs detection only (side-effect-free doctor); every + other group is not applicable, automatic selection is refused, and no + PTY/TUI automation exists to test. +- CI additionally uses a fake Gemini child process, a fake Antigravity + executable, and a fake OpenAI-compatible loopback server. diff --git a/docs/runner-profiles.md b/docs/runner-profiles.md index 5ab4f39..7332563 100644 --- a/docs/runner-profiles.md +++ b/docs/runner-profiles.md @@ -38,10 +38,16 @@ implementation. Several profiles can share an implementation: ## Built-in profiles -`claude-code` (enabled), `codex-default` (disabled), `ollama-local` -(disabled), and `mock` (enabled) always exist — configuration entries -override them. New-provider profiles are DISABLED until you enable them -explicitly; nothing is ever silently enabled or selected. +`claude-code` (enabled), `codex-default` (disabled), `gemini-default` +(disabled, v0.6.1), `ollama-local` (disabled), `openai-compatible-local` +(disabled, v0.6.1), `antigravity` (disabled, experimental, v0.6.1), and +`mock` (enabled) always exist — configuration entries override them. +New-provider profiles are DISABLED until you enable them explicitly; +nothing is ever silently enabled or selected, and migration never enables +a provider profile. See [gemini-cli-runner.md](gemini-cli-runner.md), +[openai-compatible-runner.md](openai-compatible-runner.md), and +[antigravity-cli-runner.md](antigravity-cli-runner.md) for the v0.6.1 +profile fields. ## Rules diff --git a/docs/runner-security.md b/docs/runner-security.md index ffc1f6f..6a95513 100644 --- a/docs/runner-security.md +++ b/docs/runner-security.md @@ -7,10 +7,18 @@ covered by tests. ## No credentials, ever - SpecBridge stores no credential values; configuration rejects - credential-looking keys outright. + credential-looking keys outright. The openai-compatible profile holds an + environment-variable NAME only (`apiKeyEnvironmentVariable`); the value + is read at request time from exactly that variable, redacted from every + retained byte, never logged, never stored in attempt metadata, never + passed to verification commands, and never forwarded across origins on + redirects. Credential-bearing custom header names are rejected by the + schema. - No provider credential files or private auth JSON are ever read; authentication status comes only from official safe commands (`claude - auth status`, `codex login status`) and is otherwise `unknown`. + auth status`, `codex login status`) and is otherwise `unknown` (Gemini + and Antigravity report `unknown` — no safe offline command exists, and + SpecBridge never reads Google credential stores or triggers a login). - Detection summarizes authentication output; it never echoes it (probe output can contain account details or tokens). - Argv audit records redact configured sensitive values; environment @@ -26,8 +34,22 @@ covered by tests. layers. Authoring always runs `--sandbox read-only`; task execution always runs `--sandbox workspace-write` (narrowable to read-only, never broadenable). -- Ollama: no repository access exists to bypass — the adapter has no file - APIs and refuses task execution before any request. +- Gemini (v0.6.1): YOLO is forbidden at three layers — the profile schema + accepts only `plan` for authoring and `auto_edit`/`default` for + execution (`--yolo` is also a config-wide forbidden fragment), argv + assembly can never produce it, and a pre-spawn assertion refuses any + YOLO flag or approval-mode value. Authoring is read-only (plan mode plus + a repository-reading tool allowlist); task execution permits file edits + only — shell-execution tools are excluded from every allowlist and + rejected by the schema, and workspace trust is never granted. When the + installed version cannot prove this bounded edit policy, task execution + is `incompatible` — the policy is never relaxed. +- Ollama / OpenAI-compatible: no repository access exists to bypass — the + adapters have no file APIs and refuse task execution before any request. +- Antigravity (v0.6.1, experimental): detection only. No PTY, no + keystroke injection, no ANSI screen parsing, no TUI automation exists in + the implementation or its dependencies (enforced by tests); every + operation is refused before any process could start. ## No shell interpolation diff --git a/docs/runner-selection.md b/docs/runner-selection.md index 8791cd5..c85cda1 100644 --- a/docs/runner-selection.md +++ b/docs/runner-selection.md @@ -65,6 +65,17 @@ Other refusals: unknown profile (`runner_not_found`), disabled profile (`runner_disabled` — enable it explicitly), network-policy refusals (see below), and preview/experimental profiles selected implicitly. +v0.6.1 adds no new selection rules — the new providers flow through the +existing engine: `gemini-default`, `openai-compatible-local`, and +`antigravity` default to disabled and are never selected implicitly; +task execution can never select `openai-compatible` or `antigravity` +(capability-refused before any request); the experimental `antigravity` +profile requires explicit opt-in even when enabled; and authoring +fallback may include Gemini or OpenAI-compatible only when the chain +explicitly names them. There is no automatic task-execution fallback and +no fallback after repository modification, authentication failure, +permission failure, or cancellation — unchanged. + ## Network policy `runnerPolicy` defaults: diff --git a/docs/runner-troubleshooting.md b/docs/runner-troubleshooting.md index c039973..2a3c10c 100644 --- a/docs/runner-troubleshooting.md +++ b/docs/runner-troubleshooting.md @@ -74,3 +74,44 @@ trusted verification) completes a task. Inspect `specbridge run show See [configuration-migration.md](configuration-migration.md); `specbridge config migrate --dry-run` is always safe (writes nothing), and applied migrations leave `config.v1.backup.json` for rollback. + +## Gemini: `incompatible` / task execution refused (v0.6.1) + +`specbridge runner doctor gemini-default` names the exact missing +capability. Common cases: + +- No headless prompt, machine-readable output, or approval-mode support: + the installed version is incompatible; update the Gemini CLI. +- No plan mode AND no tool allowlist: a read-only authoring boundary + cannot be proven — authoring is refused (SpecBridge never weakens the + boundary and never uses YOLO). +- No auto_edit, or neither a tool allowlist nor a sandbox: file edits + cannot be permitted without also permitting arbitrary shell commands — + task execution is refused BEFORE the provider is invoked, authoring + stays available, and the doctor recommends compatible claude-code or + codex-cli profiles. +- Authentication is reported `unknown` by design (no safe offline check); + `specbridge runner test gemini-default --network` runs one bounded + authenticated probe. + +## OpenAI-compatible: `structured_output_unsupported` (v0.6.1) + +The endpoint rejected the configured native structured-output mode. +Configure a mode the endpoint supports (`json-object` or +`strict-json-prompt`), or set `allowStructuredOutputFallback: true` to +permit ONE explicit, warned downgrade. Nothing downgrades silently, and +structured-output support is never inferred from provider branding. + +## OpenAI-compatible: authentication failures (v0.6.1) + +The profile stores only `apiKeyEnvironmentVariable` — export that variable +before running. The doctor reports an unset variable explicitly; the value +itself is never stored, logged, or displayed. + +## Antigravity: "Automation is disabled" (v0.6.1) + +Working as designed: the antigravity-cli adapter is experimental and +detection-only. It reports what is detected and what is not proven (stable +headless mode, structured final output, bounded edit permissions, session +resume) and executes nothing. Use claude-code, codex-cli, or gemini-cli +profiles for execution. diff --git a/docs/runners.md b/docs/runners.md index 4a5dc5b..14b7347 100644 --- a/docs/runners.md +++ b/docs/runners.md @@ -6,19 +6,26 @@ choose a compatible coding agent or authoring model per operation. ## Two kinds of runners -**Agent CLI runners** (`claude-code`, `codex-cli`) wrap a locally installed, -independently authenticated coding-agent CLI. They may inspect the -repository, modify source files under a bounded sandbox or tool restriction, -execute approved implementation tasks, and (when supported) resume sessions. - -**Model authoring runners** (`ollama`) wrap a model API endpoint. They -generate and refine spec documents with schema-validated structured output. -They never receive repository access, never modify files, and never execute -tasks — SpecBridge validates every candidate and writes the document itself. +**Agent CLI runners** (`claude-code`, `codex-cli`, `gemini-cli`) wrap a +locally installed, independently authenticated coding-agent CLI. They may +inspect the repository, modify source files under a bounded sandbox or +tool restriction, execute approved implementation tasks, and (when +supported) resume sessions. Gemini task execution and resume are +capability-gated on the installed version proving a bounded edit policy — +never YOLO, never arbitrary shell access. + +**Model authoring runners** (`ollama`, `openai-compatible`) wrap a model +API endpoint. They generate and refine spec documents with +schema-validated structured output. They never receive repository access, +never modify files, and never execute tasks — SpecBridge validates every +candidate and writes the document itself. + +**Experimental adapters** (`antigravity-cli`, v0.6.1) provide capability +detection and diagnostics only; automation is disabled. **Mock runners** exist for deterministic tests and conformance runs. -## Operation matrix (v0.6.0) +## Operation matrix (v0.6.1) Generated from registered runner metadata — run `specbridge runner matrix` (`--json`, `--markdown`) for the live version: @@ -27,12 +34,17 @@ Generated from registered runner metadata — run `specbridge runner matrix` |---------|---------|--------|--------|---------|--------|-------| | claude-code | production | yes | yes | yes | yes | no | | codex-default | production | yes | yes | yes | yes | no | +| gemini-default | production | yes | yes | yes | yes | no | | ollama-local | production | yes | yes | no | no | yes | +| openai-compatible-local | production | yes | yes | no | no | yes | +| antigravity | experimental | no | no | no | no | no | | mock | production | yes | yes | yes | yes | yes | -"Local" means inference stays on this machine (loopback model API). Agent -CLIs run locally but talk to their own provider using their own -authentication. +Gemini's Execute/Resume columns reflect DECLARED capabilities; detection +downgrades them per installed version (`specbridge runner doctor +gemini-default` shows the effective set). "Local" means inference stays on +this machine (loopback model API). Agent CLIs run locally but talk to +their own provider using their own authentication. ## Commands @@ -49,11 +61,15 @@ specbridge runner models # provider-supported model listing Authoring and execution take `--runner `: ```bash -specbridge spec generate my-feature --stage requirements --runner ollama-local -specbridge spec generate my-feature --stage design --runner codex-default +specbridge spec generate my-feature --stage requirements --runner gemini-default +specbridge spec refine my-feature --stage design --runner openai-compatible-local specbridge spec run my-feature --task 2.3 --runner codex-default ``` +The same diagnostics are available inside MCP hosts (`runner_list`, +`runner_show`, `runner_doctor`, `runner_matrix`) and through the Claude +Code plugin's `/specbridge:runners` skill. + ## What never changes, whichever runner you pick - Generated stages stay DRAFT; nothing is auto-approved. @@ -72,6 +88,9 @@ See also: [runner-capabilities.md](runner-capabilities.md), [runner-fallback.md](runner-fallback.md), [runner-conformance.md](runner-conformance.md), [codex-cli-runner.md](codex-cli-runner.md), +[gemini-cli-runner.md](gemini-cli-runner.md), [ollama-runner.md](ollama-runner.md), +[openai-compatible-runner.md](openai-compatible-runner.md), +[antigravity-cli-runner.md](antigravity-cli-runner.md), [runner-security.md](runner-security.md), [configuration-migration.md](configuration-migration.md). diff --git a/integrations/claude-code-plugin/package.json b/integrations/claude-code-plugin/package.json index bf39c58..eb65c13 100644 --- a/integrations/claude-code-plugin/package.json +++ b/integrations/claude-code-plugin/package.json @@ -1,6 +1,6 @@ { "name": "specbridge-claude-plugin", - "version": "0.6.0", + "version": "0.6.1", "private": true, "description": "Build harness for the self-contained SpecBridge Claude Code plugin (bundled CLI + MCP server + skills).", "license": "MIT", @@ -16,6 +16,7 @@ }, "devDependencies": { "@specbridge/mcp-server": "workspace:*", + "@specbridge/runners": "workspace:*", "specbridge": "workspace:*", "tsup": "^8.3.0", "typescript": "^5.6.0" diff --git a/integrations/claude-code-plugin/specbridge/.claude-plugin/plugin.json b/integrations/claude-code-plugin/specbridge/.claude-plugin/plugin.json index 0a64b8b..9de7a57 100644 --- a/integrations/claude-code-plugin/specbridge/.claude-plugin/plugin.json +++ b/integrations/claude-code-plugin/specbridge/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "specbridge", "displayName": "SpecBridge", "description": "Continue existing Kiro specs with Claude Code: validated stage authoring, verified interactive task execution, and deterministic spec drift checks.", - "version": "0.6.0", + "version": "0.6.1", "author": { "name": "HelloThisWorld" }, diff --git a/integrations/claude-code-plugin/specbridge/dist/checksums.json b/integrations/claude-code-plugin/specbridge/dist/checksums.json index 1a4ac0e..42452a2 100644 --- a/integrations/claude-code-plugin/specbridge/dist/checksums.json +++ b/integrations/claude-code-plugin/specbridge/dist/checksums.json @@ -1,18 +1,18 @@ { "schema": "specbridge.plugin-checksums/1", - "version": "0.6.0", + "version": "0.6.1", "files": { "THIRD_PARTY_LICENSES.txt": { "sha256": "c76d5d842432eac81300734011486b23740b4588d571cb813ec3113a09873289", "bytes": 153474 }, "cli.cjs": { - "sha256": "5f9792d574e8964d8f9a76469ef3304db3da595d000382ff1d6b2815a0b05acf", - "bytes": 2392021 + "sha256": "295bb26c0d360d41bd05b5987ad540abdccf535d74dc471712cd620aee1c419e", + "bytes": 2499815 }, "mcp-server.cjs": { - "sha256": "a817ad7e791bd2a40296cc2543cb4d38e05dd6ed13be9f59396280a859ebcee0", - "bytes": 1821257 + "sha256": "5975483a1163e1fa9f7ab678487395c72e24fb78d92f667fe60de273e4fb05de", + "bytes": 2054540 } } } diff --git a/integrations/claude-code-plugin/specbridge/dist/cli.cjs b/integrations/claude-code-plugin/specbridge/dist/cli.cjs index 43f2679..7841653 100644 --- a/integrations/claude-code-plugin/specbridge/dist/cli.cjs +++ b/integrations/claude-code-plugin/specbridge/dist/cli.cjs @@ -24514,7 +24514,10 @@ var RUNNER_CONFIG_SCHEMA_VERSION = "2.0.0"; var BUILT_IN_PROFILE_NAMES = { "claude-code": "claude-code", "codex-cli": "codex-default", + "gemini-cli": "gemini-default", ollama: "ollama-local", + "openai-compatible": "openai-compatible-local", + "antigravity-cli": "antigravity", mock: "mock" }; var PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; @@ -24607,10 +24610,109 @@ var ollamaProfileSchema = external_exports.object({ var mockProfileSchema = mockRunnerConfigSchema.extend({ runner: external_exports.literal("mock") }); +var GEMINI_AUTHORING_APPROVAL_MODES = ["plan"]; +var GEMINI_EXECUTION_APPROVAL_MODES = ["auto_edit", "default"]; +var geminiProfileSchema = external_exports.object({ + runner: external_exports.literal("gemini-cli"), + enabled: external_exports.boolean().default(false), + command: commandSpecSchema.default({ executable: "gemini", args: [] }), + model: safeNonEmptyString.nullable().default(null), + /** Authoring is always read-only; only plan mode is accepted. */ + approvalModeForAuthoring: external_exports.enum(GEMINI_AUTHORING_APPROVAL_MODES).default("plan"), + /** Task execution may auto-approve EDITS only — never shell commands. */ + approvalModeForExecution: external_exports.enum(GEMINI_EXECUTION_APPROVAL_MODES).default("auto_edit"), + /** Pass --sandbox when the installed CLI supports it. */ + sandbox: external_exports.boolean().default(true), + /** + * Extra tools to allow during task execution, on top of the adapter's + * bounded read/edit set. Shell-execution tools are rejected. + */ + allowedTools: external_exports.array(safeNonEmptyString).default([]).refine( + (tools) => tools.every((tool) => !/^(run_shell_command|shell|bash|execute_command|terminal)$/i.test(tool)), + { + message: "shell-execution tools cannot be allowed: SpecBridge never grants the Gemini CLI arbitrary shell access" + } + ), + /** Pass the extension-restriction flag when supported (default on). */ + disabledExtensions: external_exports.boolean().default(true), + timeoutMs: external_exports.number().int().min(1e3).max(864e5).default(18e5), + maxStdoutBytes: external_exports.number().int().min(1024).default(10 * 1024 * 1024), + maxStderrBytes: external_exports.number().int().min(1024).default(1024 * 1024) +}).passthrough(); +var OPENAI_COMPATIBLE_API_STYLES = ["chat-completions", "responses"]; +var OPENAI_COMPATIBLE_STRUCTURED_OUTPUT_MODES = [ + "json-schema", + "json-object", + "strict-json-prompt" +]; +var environmentVariableNameSchema = external_exports.string().regex( + /^[A-Za-z_][A-Za-z0-9_]*$/, + "must be an environment-variable NAME (letters, digits, underscore); SpecBridge never stores key values" +); +var FORBIDDEN_HEADER_NAME_PATTERN = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key|api-key|x-auth-token)$/i; +var safeHeadersSchema = external_exports.record(external_exports.string().max(1024)).superRefine((headers, ctx) => { + for (const [name, value] of Object.entries(headers)) { + if (!/^[A-Za-z0-9-]+$/.test(name)) { + ctx.addIssue({ + code: external_exports.ZodIssueCode.custom, + message: `header name "${name}" is invalid (letters, digits, and "-" only)` + }); + } + if (FORBIDDEN_HEADER_NAME_PATTERN.test(name)) { + ctx.addIssue({ + code: external_exports.ZodIssueCode.custom, + message: `header "${name}" would carry a credential value. SpecBridge never stores credentials; use apiKeyEnvironmentVariable (a variable NAME) instead.` + }); + } + if (value.includes("\0") || value.includes("\n") || value.includes("\r")) { + ctx.addIssue({ + code: external_exports.ZodIssueCode.custom, + message: `header "${name}" contains control characters` + }); + } + } +}); +var openAiCompatibleProfileSchema = external_exports.object({ + runner: external_exports.literal("openai-compatible"), + enabled: external_exports.boolean().default(false), + baseUrl: safeNonEmptyString.default("http://127.0.0.1:8000/v1"), + apiStyle: external_exports.enum(OPENAI_COMPATIBLE_API_STYLES).default("chat-completions"), + model: safeNonEmptyString.nullable().default(null), + structuredOutput: external_exports.enum(OPENAI_COMPATIBLE_STRUCTURED_OUTPUT_MODES).default("json-schema"), + /** + * Explicit permission to fall back from the configured structured-output + * mode to the next weaker one when the endpoint rejects it. Off by + * default: an unsupported mode is an error, never a silent downgrade. + */ + allowStructuredOutputFallback: external_exports.boolean().default(false), + /** Name of the environment variable holding the API key (never a value). */ + apiKeyEnvironmentVariable: environmentVariableNameSchema.nullable().default(null), + /** Static capability declaration: the endpoint supports GET /models. */ + modelsEndpoint: external_exports.boolean().default(false), + /** Custom safe headers (credential-bearing header names are rejected). */ + headers: safeHeadersSchema.default({}), + temperature: external_exports.number().min(0).max(2).default(0), + timeoutMs: external_exports.number().int().min(1e3).max(864e5).default(3e5), + maximumInputCharacters: external_exports.number().int().min(1e3).default(5e5), + maximumOutputBytes: external_exports.number().int().min(1024).default(2097152), + /** Explicit development override for private plain-HTTP endpoints (INSECURE). */ + allowInsecureHttp: external_exports.boolean().default(false) +}).passthrough(); +var antigravityProfileSchema = external_exports.object({ + runner: external_exports.literal("antigravity-cli"), + enabled: external_exports.boolean().default(false), + command: commandSpecSchema.default({ executable: "agy", args: [] }), + /** Always true: the adapter is experimental and cannot be marked otherwise. */ + experimental: external_exports.literal(true).default(true), + timeoutMs: external_exports.number().int().min(1e3).max(6e5).default(3e4) +}).passthrough(); var runnerProfileSchema = external_exports.discriminatedUnion("runner", [ claudeProfileSchema, codexProfileSchema, + geminiProfileSchema, ollamaProfileSchema, + openAiCompatibleProfileSchema, + antigravityProfileSchema, mockProfileSchema ]); var runnerPolicySchema = external_exports.object({ @@ -24669,7 +24771,7 @@ var agentConfigV2Schema = external_exports.object({ } } for (const [name, profile] of Object.entries(config2.runnerProfiles)) { - if (profile.runner === "ollama") { + if (profile.runner === "ollama" || profile.runner === "openai-compatible") { const url = validateRunnerBaseUrl(profile.baseUrl, { allowInsecureHttp: profile.allowInsecureHttp }); @@ -24705,6 +24807,15 @@ function builtInCodexProfile(executable) { function builtInOllamaProfile() { return ollamaProfileSchema.parse({ runner: "ollama", enabled: false }); } +function builtInGeminiProfile() { + return geminiProfileSchema.parse({ runner: "gemini-cli", enabled: false }); +} +function builtInOpenAiCompatibleProfile() { + return openAiCompatibleProfileSchema.parse({ runner: "openai-compatible", enabled: false }); +} +function builtInAntigravityProfile() { + return antigravityProfileSchema.parse({ runner: "antigravity-cli", enabled: false }); +} function withBuiltInProfiles(profiles, options) { const result = {}; const add = (name, profile) => { @@ -24718,10 +24829,22 @@ function withBuiltInProfiles(profiles, options) { BUILT_IN_PROFILE_NAMES["codex-cli"], profiles[BUILT_IN_PROFILE_NAMES["codex-cli"]] ?? builtInCodexProfile(options?.codexExecutable) ); + add( + BUILT_IN_PROFILE_NAMES["gemini-cli"], + profiles[BUILT_IN_PROFILE_NAMES["gemini-cli"]] ?? builtInGeminiProfile() + ); add( BUILT_IN_PROFILE_NAMES.ollama, profiles[BUILT_IN_PROFILE_NAMES.ollama] ?? builtInOllamaProfile() ); + add( + BUILT_IN_PROFILE_NAMES["openai-compatible"], + profiles[BUILT_IN_PROFILE_NAMES["openai-compatible"]] ?? builtInOpenAiCompatibleProfile() + ); + add( + BUILT_IN_PROFILE_NAMES["antigravity-cli"], + profiles[BUILT_IN_PROFILE_NAMES["antigravity-cli"]] ?? builtInAntigravityProfile() + ); add(BUILT_IN_PROFILE_NAMES.mock, profiles[BUILT_IN_PROFILE_NAMES.mock] ?? builtInMockProfile()); for (const [name, profile] of Object.entries(profiles)) add(name, profile); return result; @@ -24925,10 +25048,28 @@ function migrateRunnersSection(v1, changes, warnings) { changes.push( `runnerProfiles.${BUILT_IN_PROFILE_NAMES.ollama} added DISABLED (loopback http://127.0.0.1:11434; enable it explicitly to use Ollama)` ); + profiles[BUILT_IN_PROFILE_NAMES["gemini-cli"]] = { runner: "gemini-cli", enabled: false }; + changes.push( + `runnerProfiles.${BUILT_IN_PROFILE_NAMES["gemini-cli"]} added DISABLED (enable it explicitly to use the Gemini CLI)` + ); + profiles[BUILT_IN_PROFILE_NAMES["openai-compatible"]] = { + runner: "openai-compatible", + enabled: false + }; + changes.push( + `runnerProfiles.${BUILT_IN_PROFILE_NAMES["openai-compatible"]} added DISABLED (loopback http://127.0.0.1:8000/v1; authoring only; enable it explicitly)` + ); + profiles[BUILT_IN_PROFILE_NAMES["antigravity-cli"]] = { + runner: "antigravity-cli", + enabled: false + }; + changes.push( + `runnerProfiles.${BUILT_IN_PROFILE_NAMES["antigravity-cli"]} added DISABLED (experimental detection only)` + ); for (const name of Object.keys(v1.runners)) { if (!KNOWN_V1_RUNNERS.has(name)) { warnings.push( - `runners.${name} has no v0.6 runner implementation and was not migrated (it remains in the backup file; openai-compatible and similar providers are planned for v0.6.1).` + `runners.${name} has no registered runner implementation and was not migrated (it remains in the backup file).` ); } } @@ -25605,7 +25746,7 @@ function fullCommandPath(command) { } // ../../packages/cli/src/version.ts -var VERSION = "0.6.0"; +var VERSION = "0.6.1"; // ../../packages/cli/src/commands/doctor.ts var import_node_path2 = __toESM(require("path"), 1); @@ -39706,334 +39847,1487 @@ function usageFromStream(stream, durationMs, model) { durationMs: Math.max(0, Math.round(durationMs)) }; } -function composeSignals(timeoutMs, external) { - const signals2 = [AbortSignal.timeout(timeoutMs)]; - if (external !== void 0) signals2.push(external); - return AbortSignal.any(signals2); -} -async function readBounded(response, maxBytes) { - const reader = response.body?.getReader(); - if (reader === void 0) { - const text = await response.text(); - return import_buffer3.Buffer.byteLength(text, "utf8") > maxBytes ? "too-large" : { text, bytes: import_buffer3.Buffer.byteLength(text, "utf8") }; - } - const chunks = []; - let total = 0; - for (; ; ) { - const { done, value } = await reader.read(); - if (done) break; - total += value.byteLength; - if (total > maxBytes) { - await reader.cancel(); - return "too-large"; - } - chunks.push(value); +var GEMINI_CAPABILITY_PROBES = [ + { + id: "headless", + label: "Headless prompt invocation (--prompt)", + tokens: ["--prompt"], + required: true + }, + { + id: "output-json", + label: "Machine-readable output (--output-format json)", + tokens: ["--output-format"], + required: true + }, + { + id: "output-stream-json", + label: "Streaming machine-readable events (stream-json)", + tokens: ["stream-json"], + required: false, + degradedNote: "the single JSON result envelope is used instead of streamed events" + }, + { + id: "approval-mode", + label: "Approval-mode selection (--approval-mode)", + tokens: ["--approval-mode"], + required: true + }, + { + id: "plan-mode", + label: "Read-only plan approval mode (plan)", + tokens: ["plan"], + required: false, + degradedNote: "authoring needs a read-only tool allowlist instead of plan mode" + }, + { + id: "auto-edit-mode", + label: "Edit-only approval mode (auto_edit)", + tokens: ["auto_edit"], + required: false, + degradedNote: "task execution is unavailable without a bounded edit approval mode" + }, + { + id: "sandbox", + label: "Sandboxed tool execution (--sandbox)", + tokens: ["--sandbox"], + required: false, + degradedNote: "the tool allowlist is the only execution boundary" + }, + { + id: "allowed-tools", + label: "Tool allowlist (--allowed-tools)", + tokens: ["--allowed-tools"], + required: false, + degradedNote: "the sandbox is the only execution boundary" + }, + { + id: "extension-restriction", + label: "Extension restriction (--extensions)", + tokens: ["--extensions"], + required: false, + degradedNote: "installed extensions cannot be disabled for SpecBridge runs" + }, + { + id: "model-selection", + label: "Model selection (--model)", + tokens: ["--model"], + required: false, + degradedNote: "the provider default model is used" + }, + { + id: "session-list", + label: "Session listing (--list-sessions)", + tokens: ["--list-sessions"], + required: false + }, + { + id: "resume", + label: "Explicit session resume (--resume )", + tokens: ["--resume"], + required: false, + degradedNote: "interrupted runs need a fresh attempt instead of a resume" } - return { text: import_buffer3.Buffer.concat(chunks).toString("utf8"), bytes: total }; +]; +var GEMINI_DECLARED_CAPABILITIES = capabilitySet([ + "stageGeneration", + "stageRefinement", + "taskExecution", + "taskResume", + "structuredFinalOutput", + "streamingEvents", + "repositoryRead", + "repositoryWrite", + "sandbox", + "toolRestriction", + "usageReporting", + "requiresNetwork", + "supportsCancellation" +]); +var PROBE_TIMEOUT_MS3 = 15e3; +function tokenPresent2(helpText, token) { + const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[\\s,=<[|])${escaped}(?![\\w-])`, "m").test(helpText); } -async function safeHttpRequest(request) { - const started = Date.now(); - const duration3 = () => Math.max(0, Date.now() - started); - const externalAborted = () => request.signal?.aborted === true; - let response; - try { - response = await fetch(request.url, { - method: request.method, - redirect: "manual", - signal: composeSignals(request.timeoutMs, request.signal), - ...request.body !== void 0 ? { - headers: { "content-type": "application/json" }, - body: JSON.stringify(request.body) - } : {} +async function probeGemini(config2, options) { + const diagnostics = []; + const timeoutMs = options?.timeoutMs ?? PROBE_TIMEOUT_MS3; + const base = { + executable: config2.command.executable, + commandArgs: config2.command.args + }; + const invoke = (argv2) => runSafeProcess({ + executable: config2.command.executable, + argv: [...config2.command.args, ...argv2], + cwd: process.cwd(), + timeoutMs, + ...options?.signal !== void 0 ? { signal: options.signal } : {}, + maxStdoutBytes: 1024 * 1024, + maxStderrBytes: 256 * 1024 + }); + const emptyCapabilities = () => GEMINI_CAPABILITY_PROBES.map((probe) => ({ + id: probe.id, + label: probe.label, + available: false, + required: probe.required + })); + const versionResult = await invoke(["--version"]); + if (versionResult.status === "spawn-failed") { + diagnostics.push({ + severity: "error", + code: "RUNNER_EXECUTABLE_NOT_FOUND", + message: `Gemini CLI executable "${config2.command.executable}" could not be started. Install the Gemini CLI (the user installs and authenticates it independently) or set the profile command in .specbridge/config.json.` }); - } catch (cause) { - if (externalAborted()) { - return { ok: false, kind: "cancelled", detail: "the request was cancelled", durationMs: duration3() }; - } - if (cause instanceof Error && (cause.name === "TimeoutError" || cause.name === "AbortError")) { - return { - ok: false, - kind: "timeout", - detail: `the request did not complete within ${request.timeoutMs} ms`, - durationMs: duration3() - }; - } - const message = cause instanceof Error ? cause.message : String(cause); - return { - ok: false, - kind: "unreachable", - detail: `the endpoint could not be reached (${message.slice(0, 300)})`, - durationMs: duration3() - }; - } - if (response.status >= 300 && response.status < 400) { return { - ok: false, - kind: "redirect-rejected", - status: response.status, - detail: `the endpoint answered with a redirect (${response.status}); redirects are never followed`, - durationMs: duration3() + ...base, + found: false, + authState: "unknown", + capabilities: emptyCapabilities(), + supportedTokens: /* @__PURE__ */ new Set(), + status: "unavailable", + diagnostics }; } - let body; - try { - body = await readBounded(response, request.maxResponseBytes); - } catch (cause) { - if (externalAborted()) { - return { ok: false, kind: "cancelled", detail: "the request was cancelled", durationMs: duration3() }; - } - if (cause instanceof Error && (cause.name === "TimeoutError" || cause.name === "AbortError")) { - return { - ok: false, - kind: "timeout", - detail: `the response body did not complete within ${request.timeoutMs} ms`, - durationMs: duration3() - }; - } + if (versionResult.status !== "ok") { + diagnostics.push({ + severity: "error", + code: "RUNNER_VERSION_FAILED", + message: `"${config2.command.executable} --version" ${versionResult.failureReason ?? "produced no output"}.` + }); return { - ok: false, - kind: "unreachable", - detail: `the response body could not be read (${cause instanceof Error ? cause.message.slice(0, 300) : "unknown error"})`, - durationMs: duration3() + ...base, + found: true, + authState: "unknown", + capabilities: emptyCapabilities(), + supportedTokens: /* @__PURE__ */ new Set(), + status: "error", + diagnostics }; } - if (body === "too-large") { - return { - ok: false, - kind: "response-too-large", - status: response.status, - detail: `the response exceeded the configured limit of ${request.maxResponseBytes} bytes and was aborted`, - durationMs: duration3() - }; + const version2 = versionResult.stdout.trim().split(/\r?\n/)[0]?.trim(); + const help = await invoke(["--help"]); + const helpText = `${help.stdout} +${help.stderr}`; + const helpUsable = help.status === "ok" && helpText.trim().length > 0; + if (!helpUsable) { + diagnostics.push({ + severity: "error", + code: "RUNNER_HELP_FAILED", + message: `"${config2.command.executable} --help" ${help.failureReason ?? "produced no output"}; capabilities cannot be verified.` + }); } - if (!response.ok) { + const supportedTokens = /* @__PURE__ */ new Set(); + const capabilities = GEMINI_CAPABILITY_PROBES.map((probe) => { + const available2 = helpUsable && probe.tokens.some((token) => tokenPresent2(helpText, token)); + if (available2) for (const token of probe.tokens) supportedTokens.add(token); return { - ok: false, - kind: "http-error", - status: response.status, - detail: `the endpoint answered HTTP ${response.status}`, - durationMs: duration3(), - bodyExcerpt: body.text.slice(0, 500) + id: probe.id, + label: probe.label, + available: available2, + required: probe.required, + ...available2 || probe.degradedNote === void 0 ? {} : { detail: probe.degradedNote } }; + }); + const authState = "unknown"; + if (helpUsable) { + diagnostics.push({ + severity: "info", + code: "RUNNER_AUTH_PROBE_UNSUPPORTED", + message: 'Authentication cannot be verified without a model request; it is reported as unknown (SpecBridge never reads Google credential files and never starts an interactive login). Use "specbridge runner test --network" for a minimal authenticated probe.' + }); } - if (request.expectJson === true) { - const contentType = response.headers.get("content-type") ?? ""; - if (!contentType.includes("application/json")) { - return { - ok: false, - kind: "invalid-content-type", - status: response.status, - detail: `expected application/json but the endpoint answered "${contentType.slice(0, 100) || "(none)"}"`, - durationMs: duration3() - }; + const available = (id) => capabilities.find((capability) => capability.id === id)?.available === true; + const missingRequired = capabilities.filter((c3) => c3.required && !c3.available); + const authoringBoundary = available("plan-mode") || available("allowed-tools"); + let status; + if (missingRequired.length > 0) { + status = "incompatible"; + diagnostics.push({ + severity: "error", + code: "RUNNER_MISSING_CAPABILITY", + message: `This Gemini CLI version is missing required capabilities: ${missingRequired.map((c3) => c3.label).join(", ")}. Update the Gemini CLI to a version with headless prompts, machine-readable output, and approval-mode control.` + }); + } else if (helpUsable && !authoringBoundary) { + status = "incompatible"; + diagnostics.push({ + severity: "error", + code: "RUNNER_MISSING_CAPABILITY", + message: "This Gemini CLI version offers neither a plan approval mode nor a tool allowlist, so a read-only authoring boundary cannot be established. SpecBridge never weakens the boundary (and never uses YOLO) \u2014 update the Gemini CLI." + }); + } else if (!helpUsable) { + status = "error"; + } else { + status = "available"; + for (const capability of capabilities.filter((c3) => !c3.required && !c3.available)) { + diagnostics.push({ + severity: "warning", + code: "RUNNER_DEGRADED_CAPABILITY", + message: `Optional capability unavailable: ${capability.label}${capability.detail !== void 0 ? ` \u2014 ${capability.detail}` : ""}.` + }); + } + if (!available("auto-edit-mode") || !available("allowed-tools") && !available("sandbox")) { + diagnostics.push({ + severity: "warning", + code: "RUNNER_TASK_EXECUTION_UNAVAILABLE", + message: "Task execution is unavailable for this installation: file edits cannot be permitted without also permitting arbitrary shell commands (needs auto_edit plus a tool allowlist or sandbox). Authoring remains available. Use a claude-code or codex-cli profile for task execution." + }); } } return { - ok: true, - status: response.status, - bodyText: body.text, - bodyBytes: body.bytes, - durationMs: duration3() + ...base, + found: true, + ...version2 !== void 0 && version2.length > 0 ? { version: version2 } : {}, + authState, + capabilities, + supportedTokens, + status, + diagnostics }; } -var ollamaVersionResponseSchema = external_exports.object({ version: external_exports.string() }).passthrough(); -var ollamaModelSchema = external_exports.object({ - name: external_exports.string(), - size: external_exports.number().optional(), - modified_at: external_exports.string().optional(), - details: external_exports.object({ - family: external_exports.string().optional(), - parameter_size: external_exports.string().optional(), - quantization_level: external_exports.string().optional() - }).passthrough().optional() -}).passthrough(); -var ollamaTagsResponseSchema = external_exports.object({ models: external_exports.array(ollamaModelSchema).default([]) }).passthrough(); -var ollamaChatResponseSchema = external_exports.object({ - model: external_exports.string().optional(), - message: external_exports.object({ - role: external_exports.string().optional(), - content: external_exports.string().default(""), - thinking: external_exports.string().optional() - }).passthrough(), - done: external_exports.boolean().optional(), - prompt_eval_count: external_exports.number().optional(), - eval_count: external_exports.number().optional(), - total_duration: external_exports.number().optional() -}).passthrough(); -function endpoint(config2, pathName) { - return new URL(pathName, config2.baseUrl.endsWith("/") ? config2.baseUrl : `${config2.baseUrl}/`).toString(); -} -var PROBE_TIMEOUT_MS3 = 1e4; -var PROBE_MAX_BYTES = 1024 * 1024; -function fetchOllamaVersion(config2, signal) { - return safeHttpRequest({ - method: "GET", - url: endpoint(config2, "api/version"), - timeoutMs: PROBE_TIMEOUT_MS3, - maxResponseBytes: PROBE_MAX_BYTES, - ...signal !== void 0 ? { signal } : {}, - expectJson: true - }); +function probeAvailable2(probe, id) { + return probe.capabilities.find((capability) => capability.id === id)?.available === true; } -function fetchOllamaModels(config2, signal) { - return safeHttpRequest({ - method: "GET", - url: endpoint(config2, "api/tags"), - timeoutMs: PROBE_TIMEOUT_MS3, - maxResponseBytes: PROBE_MAX_BYTES, - ...signal !== void 0 ? { signal } : {}, - expectJson: true - }); +function geminiCapabilitySet(probe) { + if (!probe.found) return capabilitySet([]); + const set = { ...GEMINI_DECLARED_CAPABILITIES }; + const headless = probeAvailable2(probe, "headless") && probeAvailable2(probe, "output-json") && probeAvailable2(probe, "approval-mode"); + const plan = probeAvailable2(probe, "plan-mode"); + const allowedTools = probeAvailable2(probe, "allowed-tools"); + const sandbox = probeAvailable2(probe, "sandbox"); + const autoEdit = probeAvailable2(probe, "auto-edit-mode"); + set.stageGeneration = headless && (plan || allowedTools); + set.stageRefinement = set.stageGeneration; + set.structuredFinalOutput = headless; + set.streamingEvents = probeAvailable2(probe, "output-stream-json"); + set.sandbox = sandbox; + set.toolRestriction = allowedTools; + set.taskExecution = headless && autoEdit && (allowedTools || sandbox); + set.repositoryWrite = set.taskExecution; + set.taskResume = set.taskExecution && probeAvailable2(probe, "resume"); + return set; } -function postOllamaChat(config2, request) { - return safeHttpRequest({ - method: "POST", - url: endpoint(config2, "api/chat"), - body: { - model: request.model, - messages: request.messages, - stream: false, - format: request.format, - options: { temperature: request.temperature } - }, - timeoutMs: request.timeoutMs, - maxResponseBytes: request.maxResponseBytes, - ...request.signal !== void 0 ? { signal: request.signal } : {}, - expectJson: true - }); +var GEMINI_FORBIDDEN_ARGUMENTS = [ + "--yolo", + "-y", + "--dangerously-skip-permissions", + "--trust-folder", + "--trust" +]; +var GEMINI_ALLOWED_APPROVAL_MODES = ["plan", "default", "auto_edit"]; +var GEMINI_READ_ONLY_TOOLS = [ + "read_file", + "read_many_files", + "list_directory", + "glob", + "search_file_content" +]; +var GEMINI_EDIT_TOOLS = ["replace", "write_file"]; +var GEMINI_FORBIDDEN_TOOLS = [ + "run_shell_command", + "shell", + "bash", + "execute_command", + "terminal" +]; +var SESSION_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +function isExplicitGeminiSessionId(value) { + return SESSION_UUID_PATTERN.test(value); } -function redactOllamaResponseForRetention(bodyText) { - try { - const parsed = JSON.parse(bodyText); - if (parsed !== null && typeof parsed === "object") { - const record2 = parsed; - const message = record2["message"]; - if (message !== null && typeof message === "object") { - const messageRecord = { ...message }; - if (typeof messageRecord["thinking"] === "string") { - messageRecord["thinking"] = `[redacted thinking: ${messageRecord["thinking"].length} chars]`; - } - record2["message"] = messageRecord; +function assertNoForbiddenGeminiArguments(argv2) { + for (const argument of argv2) { + for (const forbidden of GEMINI_FORBIDDEN_ARGUMENTS) { + if (argument === forbidden) { + throw new SpecBridgeError( + "INVALID_STATE", + `Refusing to invoke the Gemini CLI: the argument vector contains "${forbidden}". SpecBridge never uses YOLO, never skips approvals, and never auto-trusts a workspace.` + ); } - return `${JSON.stringify(record2, null, 2)} -`; } - } catch { } - return bodyText.length > 1e4 ? `${bodyText.slice(0, 1e4)}\u2026 [truncated]` : bodyText; -} -var OLLAMA_DECLARED_CAPABILITIES = capabilitySet([ - "stageGeneration", - "stageRefinement", - "structuredFinalOutput", - "usageReporting", - "localOnly", - "supportsSystemPrompt", - "supportsJsonSchema", - "supportsCancellation" -]); -function classifyHttpFailure(result) { - switch (result.kind) { - case "timeout": - return { - outcome: "timed-out", - failureReason: result.detail, - error: runnerError({ code: "timed_out", message: `The Ollama request timed out: ${result.detail}.` }) - }; - case "cancelled": - return { - outcome: "cancelled", - failureReason: result.detail, - error: runnerError({ code: "cancelled", message: "The Ollama request was cancelled." }) - }; - case "response-too-large": - return { - outcome: "failed", - failureReason: result.detail, - error: runnerError({ - code: "output_limit_exceeded", - message: `The Ollama response exceeded the configured size limit.`, - remediation: ["Raise maximumOutputBytes on the profile if this was legitimate."] - }) - }; - case "redirect-rejected": - return { - outcome: "failed", - failureReason: result.detail, - error: runnerError({ - code: "endpoint_unreachable", - message: "The Ollama endpoint answered with a redirect, which is never followed.", - remediation: ["Configure the final endpoint URL directly."], - retryable: false - }) - }; - case "invalid-content-type": - return { - outcome: "malformed-output", - failureReason: result.detail, - error: runnerError({ - code: "api_error", - message: `The Ollama endpoint returned an unexpected content type.`, - retryable: false - }) - }; - case "http-error": { - const status = result.status ?? 0; - if (status === 429) { - return { - outcome: "failed", - failureReason: result.detail, - error: runnerError({ - code: "rate_limited", - message: "The Ollama endpoint reported a rate limit (HTTP 429).", - providerCode: "429" - }) - }; - } - if (status === 401 || status === 403) { - return { - outcome: "failed", - failureReason: result.detail, - error: runnerError({ - code: "authentication_required", - message: `The Ollama endpoint refused the request (HTTP ${status}).`, - providerCode: String(status) - }) - }; - } - if (status === 404 && (result.bodyExcerpt ?? "").toLowerCase().includes("model")) { - return { - outcome: "failed", - failureReason: result.detail, - error: runnerError({ - code: "model_not_found", - message: "The configured model is not available on the Ollama endpoint.", - remediation: ['List local models with "specbridge runner models ".'], - providerCode: "404" - }) - }; + const approvalIndex = argv2.indexOf("--approval-mode"); + if (approvalIndex >= 0) { + const mode = argv2[approvalIndex + 1]; + if (!GEMINI_ALLOWED_APPROVAL_MODES.includes(mode)) { + throw new SpecBridgeError( + "INVALID_STATE", + `Refusing to invoke the Gemini CLI with approval mode "${mode ?? "(missing)"}". Only ${GEMINI_ALLOWED_APPROVAL_MODES.join(", ")} are ever used \u2014 never yolo.` + ); + } + } + const toolsIndex = argv2.indexOf("--allowed-tools"); + if (toolsIndex >= 0) { + const tools = (argv2[toolsIndex + 1] ?? "").split(","); + for (const tool of tools) { + if (GEMINI_FORBIDDEN_TOOLS.includes(tool.trim().toLowerCase())) { + throw new SpecBridgeError( + "INVALID_STATE", + `Refusing to invoke the Gemini CLI with allowed tool "${tool}". SpecBridge never grants the Gemini CLI arbitrary shell access.` + ); } - return { - outcome: "failed", - failureReason: result.detail, - error: runnerError({ - code: "api_error", - message: `The Ollama endpoint answered HTTP ${status}.`, - providerCode: String(status), - retryable: status >= 500 - }) - }; } - case "unreachable": - return { - outcome: "failed", - failureReason: result.detail, - error: runnerError({ - code: "endpoint_unreachable", - message: "The Ollama endpoint could not be reached.", - remediation: ["Start Ollama locally (`ollama serve`) or fix the profile baseUrl."] - }) - }; + } + const resumeIndex = argv2.indexOf("--resume"); + if (resumeIndex >= 0) { + const session = argv2[resumeIndex + 1]; + if (session === void 0 || !isExplicitGeminiSessionId(session)) { + throw new SpecBridgeError( + "INVALID_STATE", + `Refusing to resume the Gemini session "${session ?? "(missing)"}": resume requires an explicit session UUID \u2014 "latest", indexes, and ambiguous identifiers are never used.` + ); + } + } +} +function buildGeminiInvocation(input) { + const { config: config2, probe, execution } = input; + const argv2 = [...config2.command.args]; + const skippedFlags = []; + const supports = (token) => probe.supportedTokens.has(token); + const implementation = input.toolPolicy === "implementation"; + argv2.push("--prompt"); + const outputFormat = supports("stream-json") ? "stream-json" : "json"; + argv2.push("--output-format", outputFormat); + const approvalMode = implementation ? config2.approvalModeForExecution : config2.approvalModeForAuthoring; + argv2.push("--approval-mode", approvalMode); + let allowedTools; + if (supports("--allowed-tools")) { + allowedTools = implementation ? [ + ...GEMINI_READ_ONLY_TOOLS, + ...GEMINI_EDIT_TOOLS, + ...config2.allowedTools.filter( + (tool) => !GEMINI_FORBIDDEN_TOOLS.includes(tool.toLowerCase()) + ) + ] : [...GEMINI_READ_ONLY_TOOLS]; + argv2.push("--allowed-tools", allowedTools.join(",")); + } else { + skippedFlags.push("--allowed-tools"); + } + if (config2.sandbox) { + if (supports("--sandbox")) argv2.push("--sandbox"); + else skippedFlags.push("--sandbox"); + } + if (config2.disabledExtensions) { + if (supports("--extensions")) argv2.push("--extensions", "none"); + else skippedFlags.push("--extensions"); + } + const model = execution.model ?? config2.model; + if (model !== null && model !== void 0) { + if (supports("--model")) argv2.push("--model", model); + else skippedFlags.push("--model"); + } + if (input.resumeSessionId !== void 0) { + if (!isExplicitGeminiSessionId(input.resumeSessionId)) { + throw new SpecBridgeError( + "INVALID_ARGUMENT", + `Cannot resume Gemini session "${input.resumeSessionId}": an explicit session UUID is required ("latest", indexes, and ambiguous identifiers are never used).` + ); + } + argv2.push("--resume", input.resumeSessionId); + } + assertNoForbiddenGeminiArguments(argv2); + return { + executable: config2.command.executable, + argv: argv2, + stdin: input.prompt, + outputFormat, + approvalMode, + ...allowedTools !== void 0 ? { allowedTools } : {}, + skippedFlags + }; +} +async function runGeminiInvocation(plan, config2, execution) { + assertNoForbiddenGeminiArguments(plan.argv); + return runSafeProcess({ + executable: plan.executable, + argv: plan.argv, + cwd: execution.workspaceRoot, + timeoutMs: execution.timeoutMs, + ...execution.signal !== void 0 ? { signal: execution.signal } : {}, + stdin: plan.stdin, + maxStdoutBytes: config2.maxStdoutBytes, + maxStderrBytes: config2.maxStderrBytes + }); +} +var MAX_RETAINED_GEMINI_EVENTS = 5e3; +var geminiEventSchema = external_exports.object({ + type: external_exports.string(), + session_id: external_exports.string().optional(), + text: external_exports.string().optional(), + name: external_exports.string().optional(), + status: external_exports.string().optional(), + path: external_exports.string().optional(), + kind: external_exports.string().optional(), + command: external_exports.string().optional(), + input_tokens: external_exports.number().optional(), + output_tokens: external_exports.number().optional(), + cached_input_tokens: external_exports.number().optional(), + response: external_exports.string().optional(), + message: external_exports.string().optional() +}).passthrough(); +var geminiJsonEnvelopeSchema = external_exports.object({ + response: external_exports.string(), + stats: external_exports.object({ + session_id: external_exports.string().optional(), + input_tokens: external_exports.number().optional(), + output_tokens: external_exports.number().optional(), + cached_input_tokens: external_exports.number().optional() + }).passthrough().optional() +}).passthrough(); +function parseGeminiEventStream(stdout) { + const stream = { + events: [], + unparseableLines: 0, + truncated: false, + errors: [] + }; + let inputTokens = null; + let cachedInputTokens = null; + let outputTokens = null; + let requests = 0; + for (const line of stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || !trimmed.startsWith("{")) continue; + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch { + stream.unparseableLines += 1; + continue; + } + const event = geminiEventSchema.safeParse(parsed); + if (!event.success) { + stream.unparseableLines += 1; + continue; + } + if (stream.events.length < MAX_RETAINED_GEMINI_EVENTS) { + stream.events.push(event.data); + } else { + stream.truncated = true; + } + const data = event.data; + if (data.type === "session.started" && data.session_id !== void 0) { + stream.sessionId = data.session_id; + } + if (data.type === "usage") { + requests += 1; + inputTokens = (inputTokens ?? 0) + (data.input_tokens ?? 0); + cachedInputTokens = (cachedInputTokens ?? 0) + (data.cached_input_tokens ?? 0); + outputTokens = (outputTokens ?? 0) + (data.output_tokens ?? 0); + } + if (data.type === "result" && data.response !== void 0) { + stream.finalResponse = data.response; + } + if (data.type === "error") { + const message = data.message ?? data.text; + if (message !== void 0 && stream.errors.length < 20) { + stream.errors.push(boundedPayloadText(message, 500)); + } + } + } + if (requests > 0 || inputTokens !== null || outputTokens !== null) { + stream.usage = { + inputTokens, + cachedInputTokens, + outputTokens, + reasoningTokens: null, + requestCount: Math.max(1, requests) + }; + } + return stream; +} +function redactGeminiStdoutForRetention(stdout) { + return stdout.split(/\r?\n/).map((line) => { + const trimmed = line.trim(); + if (!trimmed.startsWith("{") || !trimmed.includes('"thought"')) return line; + try { + const parsed = JSON.parse(trimmed); + if (parsed.type === "thought" && typeof parsed.text === "string") { + parsed.text = `[redacted reasoning: ${parsed.text.length} chars]`; + return JSON.stringify(parsed); + } + } catch { + } + return line; + }).join("\n"); +} +function normalizeGeminiEvents(stream, context, timestamp) { + const normalized = []; + const push = (type, providerEventType, payload) => { + if (normalized.length >= MAX_RETAINED_GEMINI_EVENTS) return; + normalized.push( + normalizedRunnerEventSchema.parse({ + type, + timestamp: timestamp(), + runner: context.runner, + profile: context.profile, + runId: context.runId, + attemptId: context.attemptId, + ...context.providerSessionId !== void 0 || stream.sessionId !== void 0 ? { providerSessionId: context.providerSessionId ?? stream.sessionId } : {}, + providerEventType, + payload + }) + ); + }; + for (const event of stream.events) { + switch (event.type) { + case "session.started": + push("session.started", event.type, { + ...event.session_id !== void 0 ? { sessionId: event.session_id } : {} + }); + break; + case "thought": + push("message.completed", event.type, { + redacted: true, + textLength: event.text?.length ?? 0 + }); + break; + case "tool.started": + push("tool.started", event.type, { + ...event.name !== void 0 ? { tool: event.name } : {}, + ...event.path !== void 0 ? { path: boundedPayloadText(event.path, 500) } : {} + }); + break; + case "tool.completed": + push( + event.status === "failed" || event.status === "denied" ? "tool.failed" : "tool.completed", + event.type, + { + ...event.name !== void 0 ? { tool: event.name } : {}, + ...event.status !== void 0 ? { status: event.status } : {} + } + ); + break; + case "file.edited": + push("file.changed", event.type, { + ...event.path !== void 0 ? { path: boundedPayloadText(event.path, 500) } : {}, + ...event.kind !== void 0 ? { kind: event.kind } : {} + }); + break; + case "usage": + push("usage.updated", event.type, { + inputTokens: event.input_tokens ?? null, + cachedInputTokens: event.cached_input_tokens ?? null, + outputTokens: event.output_tokens ?? null + }); + break; + case "result": + push("message.completed", event.type, { + textLength: event.response?.length ?? 0 + }); + break; + case "error": + push("error", event.type, { + message: boundedPayloadText(event.message ?? event.text ?? "error", 500) + }); + break; + default: + break; + } + } + return normalized; +} +function classifyGeminiFailure(stderr, streamErrors) { + const haystack = `${stderr} +${streamErrors.join("\n")}`.toLowerCase(); + if (/please sign in|not logged in|login required|unauthorized|unauthenticated|401/.test(haystack)) { + return runnerError({ + code: "authentication_required", + message: "The Gemini CLI reported an authentication failure.", + remediation: [ + "Authenticate the Gemini CLI yourself (SpecBridge never handles credentials and never starts a login flow)." + ] + }); + } + if (/resource_exhausted|quota exceeded|out of quota|usage limit/.test(haystack)) { + return runnerError({ + code: "quota_exceeded", + message: "The provider reported an exhausted quota or usage limit.", + remediation: ["Check your provider plan and usage, then retry explicitly."] + }); + } + if (/rate limit|too many requests|429/.test(haystack)) { + return runnerError({ + code: "rate_limited", + message: "The provider reported a rate limit.", + remediation: ["Wait and retry explicitly."], + providerCode: "429" + }); + } + if (/permission denied|approval (required|denied)|call rejected|not permitted/.test(haystack)) { + return runnerError({ + code: "permission_denied", + message: "The Gemini CLI reported a permission denial.", + remediation: [ + "SpecBridge never bypasses approvals (and never uses YOLO); narrow the task so it needs only repository reads and file edits." + ] + }); + } + if (/network|connection|dns|econn|etimedout/.test(haystack)) { + return runnerError({ + code: "network_error", + message: "The Gemini CLI reported a network failure.", + remediation: ["Check connectivity and retry explicitly."] + }); + } + return runnerError({ + code: "process_failed", + message: "The Gemini CLI exited with a failure.", + remediation: ["Inspect the retained stderr and event log in the run directory."] + }); +} +var TASK_EXECUTION_REMEDIATION = [ + "Authoring may remain available through the read-only boundary.", + 'Use a claude-code or codex-cli profile for task execution ("specbridge runner list" shows compatible profiles).' +]; +var GeminiCliRunner = class { + name = "gemini-cli"; + kind = "gemini-cli"; + category = "agent-cli"; + declaredCapabilities = GEMINI_DECLARED_CAPABILITIES; + /** Orchestration may perform ONE structured-output correction retry. */ + supportsStructuredOutputCorrection = true; + config; + probePromise; + constructor(config2) { + this.config = geminiProfileSchema.parse({ runner: "gemini-cli", ...config2 ?? {} }); + } + /** Probe once per runner instance; detection is read-only but not free. */ + probe(timeoutMs) { + this.probePromise ??= probeGemini( + this.config, + timeoutMs !== void 0 ? { timeoutMs } : void 0 + ); + return this.probePromise; + } + async detect(context) { + if (!this.config.enabled) { + return { + runner: this.name, + kind: this.kind, + status: "misconfigured", + executable: this.config.command.executable, + authentication: "unknown", + capabilities: [], + diagnostics: [ + { + severity: "error", + code: "RUNNER_DISABLED", + message: "This Gemini profile is disabled in .specbridge/config.json (enabled = false). Enable it explicitly to use the Gemini CLI." + } + ], + category: this.category, + capabilitySet: this.declaredCapabilities, + supportLevel: effectiveSupportLevel("production", "misconfigured"), + networkBacked: false + }; + } + const probe = await this.probe(context.timeoutMs); + return { + runner: this.name, + kind: this.kind, + status: probe.status, + executable: probe.executable, + ...probe.version !== void 0 ? { version: probe.version } : {}, + authentication: probe.authState, + capabilities: probe.capabilities, + diagnostics: probe.diagnostics, + category: this.category, + capabilitySet: geminiCapabilitySet(probe), + supportLevel: effectiveSupportLevel("production", probe.status), + // The Gemini CLI talks to its provider itself; SpecBridge's own + // transport is a local child process. + networkBacked: false + }; + } + executionBoundaryNote(policy) { + if (policy !== "implementation") { + return "Gemini plan mode / read-only tool allowlist: repository inspection only; no file writes; YOLO is never used."; + } + return `Gemini ${this.config.approvalModeForExecution} boundary: repository reads and file edits only; no arbitrary shell access; extensions disabled where supported; YOLO is never used.`; + } + listModels(_context) { + return Promise.resolve({ + supported: false, + models: [], + detail: 'The Gemini CLI has no officially supported local model-listing command that avoids a model request; SpecBridge never guesses provider model names. Configure "model" on the profile explicitly.' + }); + } + async generateStage(input, execution) { + const started = Date.now(); + const probe = await this.probe(); + const unavailable = this.unavailableResult(probe, started); + if (unavailable !== void 0) { + const { report: _report, ...rest2 } = unavailable; + return rest2; + } + const detected = geminiCapabilitySet(probe); + if (!detected.stageGeneration) { + const { report: _refusalReport, ...refusal } = this.capabilityRefusal( + started, + "authoring needs a proven read-only boundary (plan approval mode or a tool allowlist)", + ["Update the Gemini CLI to a version with plan mode or --allowed-tools."] + ); + return refusal; + } + let prompt = input.prompt; + if (input.correction !== void 0) { + prompt = `${input.prompt} + +Your previous response was not a valid structured result. Validation problems: ${input.correction.problems}. Return ONLY one corrected JSON document matching the required schema \u2014 no prose, no code fences.`; + } + const plan = buildGeminiInvocation({ + config: this.config, + probe, + prompt, + toolPolicy: input.toolPolicy, + execution + }); + const processResult = await runGeminiInvocation(plan, this.config, execution); + const mapped = this.mapResult(processResult, plan, started, "stage"); + const { report, ...rest } = mapped; + const stageReport = report; + return { ...rest, ...stageReport !== void 0 ? { report: stageReport } : {} }; + } + async executeTask(input, execution) { + return this.runTask(input.prompt, execution, {}); + } + async resumeTask(input, execution) { + if (!isExplicitGeminiSessionId(input.sessionId)) { + return { + runner: this.name, + outcome: "failed", + failureReason: `"${input.sessionId}" is not an explicit Gemini session UUID; "latest", indexes, and ambiguous identifiers are never resumed`, + rawStdout: "", + rawStderr: "", + durationMs: 0, + warnings: [], + resumeSupported: false, + error: runnerError({ + code: "unsupported_operation", + message: "Gemini resume requires the explicit session UUID captured from the original run." + }) + }; + } + return this.runTask(input.prompt, execution, { resumeSessionId: input.sessionId }); + } + async runTask(prompt, execution, session) { + const started = Date.now(); + const probe = await this.probe(); + const unavailable = this.unavailableResult(probe, started); + if (unavailable !== void 0) { + const { report: _report, ...rest2 } = unavailable; + return { ...rest2, resumeSupported: false }; + } + const detected = geminiCapabilitySet(probe); + if (!detected.taskExecution) { + const refusal = this.capabilityRefusal( + started, + "file edits cannot be permitted without also permitting arbitrary shell commands (needs the auto_edit approval mode plus a tool allowlist or sandbox); SpecBridge never relaxes this policy and never uses YOLO", + TASK_EXECUTION_REMEDIATION + ); + const { report: _report, ...rest2 } = refusal; + return { ...rest2, resumeSupported: false }; + } + if (session.resumeSessionId !== void 0 && !detected.taskResume) { + const refusal = this.capabilityRefusal( + started, + "this Gemini CLI version does not support explicit session resume; start a fresh attempt instead", + ["Re-run the task without --resume; a new attempt is recorded append-only."] + ); + const { report: _report, ...rest2 } = refusal; + return { ...rest2, resumeSupported: false }; + } + const plan = buildGeminiInvocation({ + config: this.config, + probe, + prompt, + toolPolicy: "implementation", + ...session.resumeSessionId !== void 0 ? { resumeSessionId: session.resumeSessionId } : {}, + execution + }); + const processResult = await runGeminiInvocation(plan, this.config, execution); + const mapped = this.mapResult(processResult, plan, started, "task"); + if (session.resumeSessionId !== void 0 && mapped.sessionId !== void 0 && mapped.sessionId !== session.resumeSessionId) { + const { report: _report, ...rest2 } = mapped; + return { + ...rest2, + outcome: "failed", + failureReason: `the provider continued session ${mapped.sessionId} instead of the requested ${session.resumeSessionId}; the resume is not claimed as successful`, + error: runnerError({ + code: "api_error", + message: "The Gemini session identity changed unexpectedly during resume.", + remediation: [ + "Inspect the retained events, then start a fresh attempt (run lineage is preserved)." + ], + retryable: false, + providerCode: "session-mismatch" + }), + resumeSupported: false + }; + } + const { report, sessionId, ...rest } = mapped; + const taskReport = report; + const effectiveSession = sessionId ?? session.resumeSessionId; + return { + ...rest, + ...taskReport !== void 0 ? { report: taskReport } : {}, + ...effectiveSession !== void 0 ? { sessionId: effectiveSession } : {}, + resumeSupported: detected.taskResume && effectiveSession !== void 0 && isExplicitGeminiSessionId(effectiveSession) + }; + } + /** Minimal bounded structured-output probe (`runner test --network`). */ + async selfTest(execution) { + const probe = await this.probe(); + if (probe.status !== "available") { + return { ok: false, detail: `gemini-cli is not available (status: ${probe.status})` }; + } + const result = await this.generateStage( + { + specName: "runner-self-test", + stage: "requirements", + intent: "generate", + prompt: 'This is a connectivity self test. Do not read or modify any file. Reply with exactly one JSON document: {"schemaVersion":"1.0.0","stage":"requirements","markdown":"# Self Test","summary":"self test"} and nothing else.\n\nStage to produce: requirements\n', + promptVersion: "self-test", + toolPolicy: "read-only" + }, + { ...execution, timeoutMs: Math.min(execution.timeoutMs, 12e4) } + ); + return { + ok: result.outcome === "completed" && result.report !== void 0, + detail: result.outcome === "completed" ? "structured output validated" : result.failureReason ?? `self test failed (${result.outcome})`, + ...result.usage !== void 0 ? { usage: result.usage } : {}, + ...result.process !== void 0 ? { process: result.process } : {} + }; + } + capabilityRefusal(started, reason, remediation) { + return { + runner: this.name, + outcome: "failed", + failureReason: `the installed Gemini CLI is incompatible with this operation: ${reason}`, + rawStdout: "", + rawStderr: "", + durationMs: Math.max(0, Date.now() - started), + warnings: [], + error: runnerError({ + code: "runner_incompatible", + message: `The installed Gemini CLI lacks required safety capabilities: ${reason}.`, + remediation + }) + }; + } + unavailableResult(probe, started) { + if (probe.status === "available") return void 0; + const error2 = probe.status === "incompatible" ? runnerError({ + code: "runner_incompatible", + message: "The installed Gemini CLI version lacks required capabilities.", + remediation: ['Run "specbridge runner doctor" for the exact missing capabilities.'] + }) : probe.status === "misconfigured" ? runnerError({ + code: "runner_disabled", + message: "This Gemini profile is disabled.", + remediation: ["Enable the profile in .specbridge/config.json explicitly."] + }) : probe.status === "error" ? runnerError({ + code: "process_failed", + message: "The Gemini CLI could not be probed.", + remediation: ['Run "specbridge runner doctor" for details.'] + }) : runnerError({ + code: "executable_not_found", + message: `The Gemini CLI executable "${this.config.command.executable}" was not found.`, + remediation: ["Install the Gemini CLI or fix the profile command."] + }); + return { + runner: this.name, + outcome: "failed", + failureReason: `the gemini-cli runner is not available (status: ${probe.status}); run "specbridge runner doctor" for details`, + rawStdout: "", + rawStderr: "", + durationMs: Date.now() - started, + warnings: probe.diagnostics.filter((d) => d.severity === "error").map((d) => d.message), + error: error2 + }; + } + /** Map a finished process + machine-readable output to a structured result. */ + mapResult(processResult, plan, started, reportKind) { + const warnings = plan.skippedFlags.map( + (flag) => `flag ${flag} is unsupported by this Gemini CLI version and was skipped` + ); + let stream; + let finalText; + let sessionId; + let usage; + let retainedStdout = processResult.stdout; + if (plan.outputFormat === "stream-json") { + stream = parseGeminiEventStream(processResult.stdout); + if (stream.truncated) { + warnings.push("the provider event stream exceeded the retention limit; older events were dropped"); + } + finalText = stream.finalResponse; + sessionId = stream.sessionId; + if (stream.usage !== void 0) { + usage = { + model: this.config.model, + inputTokens: stream.usage.inputTokens, + cachedInputTokens: stream.usage.cachedInputTokens, + outputTokens: stream.usage.outputTokens, + reasoningTokens: stream.usage.reasoningTokens, + requestCount: stream.usage.requestCount, + durationMs: Math.max(0, processResult.observation.durationMs) + }; + } + retainedStdout = redactGeminiStdoutForRetention(processResult.stdout); + } else { + const envelope = geminiJsonEnvelopeSchema.safeParse(safeJson(processResult.stdout)); + if (envelope.success) { + finalText = envelope.data.response; + sessionId = envelope.data.stats?.session_id; + if (envelope.data.stats !== void 0) { + usage = { + model: this.config.model, + inputTokens: envelope.data.stats.input_tokens ?? null, + cachedInputTokens: envelope.data.stats.cached_input_tokens ?? null, + outputTokens: envelope.data.stats.output_tokens ?? null, + reasoningTokens: null, + requestCount: 1, + durationMs: Math.max(0, processResult.observation.durationMs) + }; + } + } + } + const normalizedEvents = stream !== void 0 ? normalizeGeminiEvents( + stream, + { runner: this.name, profile: this.name, runId: "pending", attemptId: "pending" }, + () => (/* @__PURE__ */ new Date()).toISOString() + ) : void 0; + const base = { + runner: this.name, + rawStdout: retainedStdout, + rawStderr: processResult.stderr, + process: processResult.observation, + durationMs: Math.max(0, Date.now() - started), + warnings, + ...normalizedEvents !== void 0 ? { normalizedEvents } : {}, + ...usage !== void 0 ? { usage } : {}, + ...sessionId !== void 0 ? { sessionId } : {} + }; + switch (processResult.status) { + case "timeout": + return { + ...base, + outcome: "timed-out", + failureReason: processResult.failureReason ?? "timeout", + error: runnerError({ + code: "timed_out", + message: "The Gemini process exceeded the configured timeout and was terminated.", + remediation: ["Increase the profile timeoutMs or narrow the task."] + }) + }; + case "cancelled": + return { + ...base, + outcome: "cancelled", + failureReason: processResult.failureReason ?? "cancelled", + error: runnerError({ + code: "cancelled", + message: "The Gemini process was cancelled and terminated." + }) + }; + case "output-limit": + return { + ...base, + outcome: "failed", + failureReason: processResult.failureReason ?? "output limit exceeded", + error: runnerError({ + code: "output_limit_exceeded", + message: "The Gemini process exceeded the configured output limit and was terminated.", + remediation: ["Raise maxStdoutBytes/maxStderrBytes on the profile if this was legitimate."] + }) + }; + case "spawn-failed": + return { + ...base, + outcome: "failed", + failureReason: processResult.failureReason ?? "spawn failed", + error: runnerError({ + code: "executable_not_found", + message: `The Gemini CLI executable could not be started: ${processResult.failureReason ?? "unknown spawn failure"}.`, + remediation: ["Install the Gemini CLI or fix the profile command."] + }) + }; + case "ok": + case "nonzero-exit": + break; + } + if (processResult.status === "nonzero-exit") { + const error2 = classifyGeminiFailure(processResult.stderr, stream?.errors ?? []); + return { + ...base, + outcome: error2.code === "permission_denied" ? "permission-denied" : "failed", + failureReason: `${error2.message} (exit ${processResult.observation.exitCode ?? "unknown"})`, + error: error2 + }; + } + if (finalText === void 0 || finalText.trim().length === 0) { + return { + ...base, + outcome: "malformed-output", + failureReason: stream !== void 0 && stream.errors.length > 0 ? `the provider reported: ${stream.errors[0]}` : "the runner returned no final structured result", + error: runnerError({ + code: "structured_output_invalid", + message: "The Gemini run produced no final structured result.", + remediation: ["Inspect the retained output in the run directory."] + }) + }; + } + const parsed = strictJsonParse2(finalText); + if (parsed === void 0) { + return { + ...base, + outcome: "malformed-output", + failureReason: "the final response is not a bare JSON document (extra prose is not accepted)", + error: runnerError({ + code: "structured_output_invalid", + message: "The final Gemini response did not parse as a JSON document." + }), + ...reportKind === "stage" ? { invalidStructuredOutput: finalText.length > 1e5 ? finalText.slice(0, 1e5) : finalText } : {} + }; + } + const schema = reportKind === "stage" ? stageRunnerReportSchema : taskRunnerReportSchema; + const validated = schema.safeParse(parsed); + if (!validated.success) { + const problems = validated.error.issues.map((issue2) => `${issue2.path.join(".") || "(root)"}: ${issue2.message}`).join("; "); + return { + ...base, + outcome: "malformed-output", + failureReason: `structured result does not match the report schema: ${problems}`, + error: runnerError({ + code: "structured_output_invalid", + message: "The final Gemini response did not match the required report schema.", + details: { problems: problems.slice(0, 2e3) } + }), + ...reportKind === "stage" ? { invalidStructuredOutput: finalText.length > 1e5 ? finalText.slice(0, 1e5) : finalText } : {} + }; + } + const report = validated.data; + const outcome = "outcome" in report ? report.outcome : "completed"; + return { + ...base, + outcome, + report, + ...outcome === "completed" || outcome === "no-change" ? {} : { failureReason: `the agent reported "${outcome}"` } + }; + } +}; +function safeJson(raw) { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{")) return void 0; + try { + return JSON.parse(trimmed); + } catch { + return void 0; + } +} +function strictJsonParse2(raw) { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return void 0; + try { + return JSON.parse(trimmed); + } catch { + return void 0; + } +} +function composeSignals(timeoutMs, external) { + const signals2 = [AbortSignal.timeout(timeoutMs)]; + if (external !== void 0) signals2.push(external); + return AbortSignal.any(signals2); +} +async function readBounded(response, maxBytes) { + const reader = response.body?.getReader(); + if (reader === void 0) { + const text = await response.text(); + return import_buffer3.Buffer.byteLength(text, "utf8") > maxBytes ? "too-large" : { text, bytes: import_buffer3.Buffer.byteLength(text, "utf8") }; + } + const chunks = []; + let total = 0; + for (; ; ) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + return "too-large"; + } + chunks.push(value); + } + return { text: import_buffer3.Buffer.concat(chunks).toString("utf8"), bytes: total }; +} +function checkRedirectTarget(current, location) { + let next; + try { + next = new URL(location, current); + } catch { + return { ok: false, detail: `the redirect target "${location.slice(0, 200)}" is not a valid URL` }; + } + if (next.protocol !== "http:" && next.protocol !== "https:") { + return { + ok: false, + detail: `the redirect target uses the unsupported scheme "${next.protocol}"` + }; + } + if (current.protocol === "https:" && next.protocol === "http:") { + return { + ok: false, + detail: "the redirect would downgrade HTTPS to plain HTTP; downgrades are never followed" + }; + } + if (next.username !== "" || next.password !== "") { + return { ok: false, detail: "the redirect target embeds credentials; it is never followed" }; + } + return { ok: true, nextUrl: next }; +} +async function safeHttpRequest(request) { + const started = Date.now(); + const duration3 = () => Math.max(0, Date.now() - started); + const externalAborted = () => request.signal?.aborted === true; + const maxRedirects = request.maxRedirects ?? 0; + const initialUrl = new URL(request.url); + const initialOrigin = initialUrl.origin; + let currentUrl = initialUrl; + let currentMethod = request.method; + let sendBody = request.body !== void 0; + let crossedOrigin = false; + let redirectCount = 0; + let response; + for (; ; ) { + const headers = {}; + if (sendBody) headers["content-type"] = "application/json"; + if (request.headers !== void 0 && !crossedOrigin) { + for (const [name, value] of Object.entries(request.headers)) headers[name] = value; + } + try { + response = await fetch(currentUrl.toString(), { + method: currentMethod, + redirect: "manual", + signal: composeSignals(request.timeoutMs, request.signal), + headers, + ...sendBody ? { body: JSON.stringify(request.body) } : {} + }); + } catch (cause) { + if (externalAborted()) { + return { ok: false, kind: "cancelled", detail: "the request was cancelled", durationMs: duration3() }; + } + if (cause instanceof Error && (cause.name === "TimeoutError" || cause.name === "AbortError")) { + return { + ok: false, + kind: "timeout", + detail: `the request did not complete within ${request.timeoutMs} ms`, + durationMs: duration3() + }; + } + const message = cause instanceof Error ? cause.message : String(cause); + return { + ok: false, + kind: "unreachable", + detail: `the endpoint could not be reached (${message.slice(0, 300)})`, + durationMs: duration3() + }; + } + if (response.status < 300 || response.status >= 400) break; + if (redirectCount >= maxRedirects) { + return { + ok: false, + kind: "redirect-rejected", + status: response.status, + detail: maxRedirects === 0 ? `the endpoint answered with a redirect (${response.status}); redirects are never followed` : `the endpoint exceeded the bounded redirect limit of ${maxRedirects}`, + durationMs: duration3() + }; + } + const location = response.headers.get("location"); + if (location === null || location.length === 0) { + return { + ok: false, + kind: "redirect-rejected", + status: response.status, + detail: `the endpoint answered with a redirect (${response.status}) without a target`, + durationMs: duration3() + }; + } + const decision = checkRedirectTarget(currentUrl, location); + if (!decision.ok || decision.nextUrl === void 0) { + return { + ok: false, + kind: "redirect-rejected", + status: response.status, + detail: decision.detail ?? "the redirect was rejected", + durationMs: duration3() + }; + } + redirectCount += 1; + if (decision.nextUrl.origin !== initialOrigin) crossedOrigin = true; + if (response.status === 303 || currentMethod === "POST" && (response.status === 301 || response.status === 302)) { + currentMethod = "GET"; + sendBody = false; + } + currentUrl = decision.nextUrl; + } + const redirects = redirectCount > 0 ? { count: redirectCount, finalUrl: currentUrl.toString(), crossOrigin: crossedOrigin } : void 0; + let body; + try { + body = await readBounded(response, request.maxResponseBytes); + } catch (cause) { + if (externalAborted()) { + return { ok: false, kind: "cancelled", detail: "the request was cancelled", durationMs: duration3() }; + } + if (cause instanceof Error && (cause.name === "TimeoutError" || cause.name === "AbortError")) { + return { + ok: false, + kind: "timeout", + detail: `the response body did not complete within ${request.timeoutMs} ms`, + durationMs: duration3() + }; + } + return { + ok: false, + kind: "unreachable", + detail: `the response body could not be read (${cause instanceof Error ? cause.message.slice(0, 300) : "unknown error"})`, + durationMs: duration3() + }; + } + if (body === "too-large") { + return { + ok: false, + kind: "response-too-large", + status: response.status, + detail: `the response exceeded the configured limit of ${request.maxResponseBytes} bytes and was aborted`, + durationMs: duration3() + }; + } + if (!response.ok) { + return { + ok: false, + kind: "http-error", + status: response.status, + detail: `the endpoint answered HTTP ${response.status}`, + durationMs: duration3(), + bodyExcerpt: body.text.slice(0, 500) + }; + } + if (request.expectJson === true) { + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + return { + ok: false, + kind: "invalid-content-type", + status: response.status, + detail: `expected application/json but the endpoint answered "${contentType.slice(0, 100) || "(none)"}"`, + durationMs: duration3() + }; + } + } + return { + ok: true, + status: response.status, + bodyText: body.text, + bodyBytes: body.bytes, + durationMs: duration3(), + ...redirects !== void 0 ? { redirects } : {} + }; +} +var ollamaVersionResponseSchema = external_exports.object({ version: external_exports.string() }).passthrough(); +var ollamaModelSchema = external_exports.object({ + name: external_exports.string(), + size: external_exports.number().optional(), + modified_at: external_exports.string().optional(), + details: external_exports.object({ + family: external_exports.string().optional(), + parameter_size: external_exports.string().optional(), + quantization_level: external_exports.string().optional() + }).passthrough().optional() +}).passthrough(); +var ollamaTagsResponseSchema = external_exports.object({ models: external_exports.array(ollamaModelSchema).default([]) }).passthrough(); +var ollamaChatResponseSchema = external_exports.object({ + model: external_exports.string().optional(), + message: external_exports.object({ + role: external_exports.string().optional(), + content: external_exports.string().default(""), + thinking: external_exports.string().optional() + }).passthrough(), + done: external_exports.boolean().optional(), + prompt_eval_count: external_exports.number().optional(), + eval_count: external_exports.number().optional(), + total_duration: external_exports.number().optional() +}).passthrough(); +function endpoint(config2, pathName) { + return new URL(pathName, config2.baseUrl.endsWith("/") ? config2.baseUrl : `${config2.baseUrl}/`).toString(); +} +var PROBE_TIMEOUT_MS4 = 1e4; +var PROBE_MAX_BYTES = 1024 * 1024; +function fetchOllamaVersion(config2, signal) { + return safeHttpRequest({ + method: "GET", + url: endpoint(config2, "api/version"), + timeoutMs: PROBE_TIMEOUT_MS4, + maxResponseBytes: PROBE_MAX_BYTES, + ...signal !== void 0 ? { signal } : {}, + expectJson: true + }); +} +function fetchOllamaModels(config2, signal) { + return safeHttpRequest({ + method: "GET", + url: endpoint(config2, "api/tags"), + timeoutMs: PROBE_TIMEOUT_MS4, + maxResponseBytes: PROBE_MAX_BYTES, + ...signal !== void 0 ? { signal } : {}, + expectJson: true + }); +} +function postOllamaChat(config2, request) { + return safeHttpRequest({ + method: "POST", + url: endpoint(config2, "api/chat"), + body: { + model: request.model, + messages: request.messages, + stream: false, + format: request.format, + options: { temperature: request.temperature } + }, + timeoutMs: request.timeoutMs, + maxResponseBytes: request.maxResponseBytes, + ...request.signal !== void 0 ? { signal: request.signal } : {}, + expectJson: true + }); +} +function redactOllamaResponseForRetention(bodyText) { + try { + const parsed = JSON.parse(bodyText); + if (parsed !== null && typeof parsed === "object") { + const record2 = parsed; + const message = record2["message"]; + if (message !== null && typeof message === "object") { + const messageRecord = { ...message }; + if (typeof messageRecord["thinking"] === "string") { + messageRecord["thinking"] = `[redacted thinking: ${messageRecord["thinking"].length} chars]`; + } + record2["message"] = messageRecord; + } + return `${JSON.stringify(record2, null, 2)} +`; + } + } catch { + } + return bodyText.length > 1e4 ? `${bodyText.slice(0, 1e4)}\u2026 [truncated]` : bodyText; +} +var OLLAMA_DECLARED_CAPABILITIES = capabilitySet([ + "stageGeneration", + "stageRefinement", + "structuredFinalOutput", + "usageReporting", + "localOnly", + "supportsSystemPrompt", + "supportsJsonSchema", + "supportsCancellation" +]); +function classifyHttpFailure(result) { + switch (result.kind) { + case "timeout": + return { + outcome: "timed-out", + failureReason: result.detail, + error: runnerError({ code: "timed_out", message: `The Ollama request timed out: ${result.detail}.` }) + }; + case "cancelled": + return { + outcome: "cancelled", + failureReason: result.detail, + error: runnerError({ code: "cancelled", message: "The Ollama request was cancelled." }) + }; + case "response-too-large": + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "output_limit_exceeded", + message: `The Ollama response exceeded the configured size limit.`, + remediation: ["Raise maximumOutputBytes on the profile if this was legitimate."] + }) + }; + case "redirect-rejected": + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "endpoint_unreachable", + message: "The Ollama endpoint answered with a redirect, which is never followed.", + remediation: ["Configure the final endpoint URL directly."], + retryable: false + }) + }; + case "invalid-content-type": + return { + outcome: "malformed-output", + failureReason: result.detail, + error: runnerError({ + code: "api_error", + message: `The Ollama endpoint returned an unexpected content type.`, + retryable: false + }) + }; + case "http-error": { + const status = result.status ?? 0; + if (status === 429) { + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "rate_limited", + message: "The Ollama endpoint reported a rate limit (HTTP 429).", + providerCode: "429" + }) + }; + } + if (status === 401 || status === 403) { + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "authentication_required", + message: `The Ollama endpoint refused the request (HTTP ${status}).`, + providerCode: String(status) + }) + }; + } + if (status === 404 && (result.bodyExcerpt ?? "").toLowerCase().includes("model")) { + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "model_not_found", + message: "The configured model is not available on the Ollama endpoint.", + remediation: ['List local models with "specbridge runner models ".'], + providerCode: "404" + }) + }; + } + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "api_error", + message: `The Ollama endpoint answered HTTP ${status}.`, + providerCode: String(status), + retryable: status >= 500 + }) + }; + } + case "unreachable": + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "endpoint_unreachable", + message: "The Ollama endpoint could not be reached.", + remediation: ["Start Ollama locally (`ollama serve`) or fix the profile baseUrl."] + }) + }; } } var OllamaRunner = class { @@ -40109,12 +41403,12 @@ var OllamaRunner = class { } capabilities.push({ id: "endpoint", label: "Endpoint reachable", available: true, required: true }); let version2; - const versionParsed = ollamaVersionResponseSchema.safeParse(safeJson(versionResult.bodyText)); + const versionParsed = ollamaVersionResponseSchema.safeParse(safeJson2(versionResult.bodyText)); if (versionParsed.success) version2 = versionParsed.data.version; const tagsResult = await fetchOllamaModels(this.config, signal); let modelNames = []; if (tagsResult.ok) { - const tags = ollamaTagsResponseSchema.safeParse(safeJson(tagsResult.bodyText)); + const tags = ollamaTagsResponseSchema.safeParse(safeJson2(tagsResult.bodyText)); if (tags.success) modelNames = tags.data.models.map((model) => model.name); capabilities.push({ id: "model-list", label: "Model listing", available: tags.success, required: false }); } else { @@ -40174,7 +41468,7 @@ var OllamaRunner = class { if (!result.ok) { return { supported: true, models: [], detail: `model listing failed: ${result.detail}` }; } - const tags = ollamaTagsResponseSchema.safeParse(safeJson(result.bodyText)); + const tags = ollamaTagsResponseSchema.safeParse(safeJson2(result.bodyText)); if (!tags.success) { return { supported: true, models: [], detail: "the endpoint returned an unexpected model list shape" }; } @@ -40261,7 +41555,7 @@ var OllamaRunner = class { return failure(classifyHttpFailure(result)); } const retained = redactOllamaResponseForRetention(result.bodyText); - const parsedBody = ollamaChatResponseSchema.safeParse(safeJson(result.bodyText)); + const parsedBody = ollamaChatResponseSchema.safeParse(safeJson2(result.bodyText)); if (!parsedBody.success) { return failure( { @@ -40278,7 +41572,7 @@ var OllamaRunner = class { } const usage = usageFromChat(parsedBody.data, model, Date.now() - started); const content = parsedBody.data.message.content; - const candidate = strictJsonParse2(content); + const candidate = strictJsonParse3(content); const report = candidate === void 0 ? void 0 : stageRunnerReportSchema.safeParse(candidate); if (report === void 0 || !report.success) { const problems = report !== void 0 && !report.success ? report.error.issues.map((issue2) => `${issue2.path.join(".") || "(root)"}: ${issue2.message}`).join("; ") : "the message content is not a bare JSON document"; @@ -40359,14 +41653,754 @@ var OllamaRunner = class { }; } }; -function safeJson(raw) { +function safeJson2(raw) { + try { + return JSON.parse(raw); + } catch { + return void 0; + } +} +function strictJsonParse3(raw) { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return void 0; + try { + return JSON.parse(trimmed); + } catch { + return void 0; + } +} +function usageFromChat(response, model, durationMs) { + return { + model, + inputTokens: response.prompt_eval_count ?? null, + cachedInputTokens: null, + outputTokens: response.eval_count ?? null, + reasoningTokens: null, + requestCount: 1, + durationMs: Math.max(0, Math.round(durationMs)) + }; +} +function buildOpenAiRequestBody(style, input) { + if (style === "chat-completions") { + const responseFormat = input.structuredOutput === "json-schema" ? { + response_format: { + type: "json_schema", + json_schema: { name: input.schemaName, strict: true, schema: input.jsonSchema } + } + } : input.structuredOutput === "json-object" ? { response_format: { type: "json_object" } } : {}; + return { + model: input.model, + messages: input.messages, + temperature: input.temperature, + stream: false, + ...responseFormat + }; + } + const textFormat = input.structuredOutput === "json-schema" ? { + text: { + format: { + type: "json_schema", + name: input.schemaName, + strict: true, + schema: input.jsonSchema + } + } + } : input.structuredOutput === "json-object" ? { text: { format: { type: "json_object" } } } : {}; + return { + model: input.model, + input: input.messages.map((message) => ({ + role: message.role, + content: [{ type: message.role === "assistant" ? "output_text" : "input_text", text: message.content }] + })), + temperature: input.temperature, + stream: false, + ...textFormat + }; +} +var chatCompletionResponseSchema = external_exports.object({ + choices: external_exports.array( + external_exports.object({ + message: external_exports.object({ content: external_exports.string().nullable() }).passthrough(), + finish_reason: external_exports.string().nullable().optional() + }).passthrough() + ).min(1), + model: external_exports.string().optional(), + usage: external_exports.object({ + prompt_tokens: external_exports.number().optional(), + completion_tokens: external_exports.number().optional(), + prompt_tokens_details: external_exports.object({ cached_tokens: external_exports.number().optional() }).passthrough().optional() + }).passthrough().optional() +}).passthrough(); +var responsesResponseSchema = external_exports.object({ + output: external_exports.array( + external_exports.object({ + type: external_exports.string().optional(), + content: external_exports.array(external_exports.object({ type: external_exports.string().optional(), text: external_exports.string().optional() }).passthrough()).optional() + }).passthrough() + ).optional(), + output_text: external_exports.string().optional(), + model: external_exports.string().optional(), + usage: external_exports.object({ + input_tokens: external_exports.number().optional(), + output_tokens: external_exports.number().optional(), + input_tokens_details: external_exports.object({ cached_tokens: external_exports.number().optional() }).passthrough().optional() + }).passthrough().optional() +}).passthrough(); +function parseOpenAiResponse(style, bodyText) { + let parsed; + try { + parsed = JSON.parse(bodyText); + } catch { + return { problem: "the endpoint response is not valid JSON" }; + } + if (style === "chat-completions") { + const result2 = chatCompletionResponseSchema.safeParse(parsed); + if (!result2.success) { + return { problem: "the endpoint response does not match the chat-completions shape" }; + } + const content = result2.data.choices[0]?.message.content; + return { + ...content !== null && content !== void 0 ? { text: content } : { problem: "the response carries no message content" }, + ...result2.data.model !== void 0 ? { model: result2.data.model } : {}, + ...result2.data.usage !== void 0 ? { + usage: { + inputTokens: result2.data.usage.prompt_tokens ?? null, + cachedInputTokens: result2.data.usage.prompt_tokens_details?.cached_tokens ?? null, + outputTokens: result2.data.usage.completion_tokens ?? null + } + } : {} + }; + } + const result = responsesResponseSchema.safeParse(parsed); + if (!result.success) { + return { problem: "the endpoint response does not match the responses shape" }; + } + let text = result.data.output_text; + if (text === void 0 && result.data.output !== void 0) { + const parts = []; + for (const item of result.data.output) { + if (item.type !== void 0 && item.type !== "message") continue; + for (const content of item.content ?? []) { + if ((content.type === void 0 || content.type === "output_text") && content.text !== void 0) { + parts.push(content.text); + } + } + } + if (parts.length > 0) text = parts.join(""); + } + return { + ...text !== void 0 ? { text } : { problem: "the response carries no output text" }, + ...result.data.model !== void 0 ? { model: result.data.model } : {}, + ...result.data.usage !== void 0 ? { + usage: { + inputTokens: result.data.usage.input_tokens ?? null, + cachedInputTokens: result.data.usage.input_tokens_details?.cached_tokens ?? null, + outputTokens: result.data.usage.output_tokens ?? null + } + } : {} + }; +} +var openAiModelsResponseSchema = external_exports.object({ + data: external_exports.array( + external_exports.object({ + id: external_exports.string(), + owned_by: external_exports.string().optional(), + created: external_exports.number().optional() + }).passthrough() + ).default([]) +}).passthrough(); +function indicatesStructuredOutputUnsupported(status, bodyExcerpt) { + if (status !== 400 && status !== 422) return false; + const text = (bodyExcerpt ?? "").toLowerCase(); + return /response_format|json_schema|json schema|structured output|text\.format/.test(text); +} +function redactSecretValue(text, secret) { + if (secret === void 0 || secret.length === 0) return text; + return text.split(secret).join(""); +} +function weakerStructuredOutputMode(mode) { + if (mode === "json-schema") return "json-object"; + if (mode === "json-object") return "strict-json-prompt"; + return void 0; +} +var OPENAI_COMPATIBLE_DECLARED_CAPABILITIES = capabilitySet([ + "stageGeneration", + "stageRefinement", + "structuredFinalOutput", + "usageReporting", + "localOnly", + "supportsSystemPrompt", + "supportsJsonSchema", + "supportsCancellation" +]); +function classifyHttpFailure2(result, redact) { + switch (result.kind) { + case "timeout": + return { + outcome: "timed-out", + failureReason: result.detail, + error: runnerError({ code: "timed_out", message: `The endpoint request timed out: ${result.detail}.` }) + }; + case "cancelled": + return { + outcome: "cancelled", + failureReason: result.detail, + error: runnerError({ code: "cancelled", message: "The endpoint request was cancelled." }) + }; + case "response-too-large": + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "output_limit_exceeded", + message: "The endpoint response exceeded the configured size limit.", + remediation: ["Raise maximumOutputBytes on the profile if this was legitimate."] + }) + }; + case "redirect-rejected": + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "endpoint_unreachable", + message: `The endpoint redirect was refused: ${result.detail}.`, + remediation: ["Configure the final endpoint URL directly."], + retryable: false + }) + }; + case "invalid-content-type": + return { + outcome: "malformed-output", + failureReason: result.detail, + error: runnerError({ + code: "api_error", + message: "The endpoint returned an unexpected content type.", + retryable: false + }) + }; + case "http-error": { + const status = result.status ?? 0; + const excerpt = redact(result.bodyExcerpt ?? "").toLowerCase(); + if (status === 401 || status === 403) { + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "authentication_required", + message: `The endpoint refused the request (HTTP ${status}).`, + remediation: [ + "Set the configured API-key environment variable before running (SpecBridge never stores key values)." + ], + providerCode: String(status) + }) + }; + } + if (status === 429 && /insufficient_quota|quota|billing/.test(excerpt)) { + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "quota_exceeded", + message: "The endpoint reported an exhausted quota.", + remediation: ["Check your provider plan and usage, then retry explicitly."], + providerCode: "429" + }) + }; + } + if (status === 429) { + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "rate_limited", + message: "The endpoint reported a rate limit (HTTP 429).", + providerCode: "429" + }) + }; + } + if (status === 404 && /model/.test(excerpt)) { + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "model_not_found", + message: "The configured model is not available on the endpoint.", + remediation: ['List models with "specbridge runner models " (when the endpoint supports it).'], + providerCode: "404" + }) + }; + } + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "api_error", + message: `The endpoint answered HTTP ${status}.`, + providerCode: String(status), + retryable: status >= 500 + }) + }; + } + case "unreachable": + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "endpoint_unreachable", + message: "The endpoint could not be reached.", + remediation: ["Start the local server or fix the profile baseUrl."] + }) + }; + } +} +var OpenAiCompatibleRunner = class { + name = "openai-compatible"; + kind = "openai-compatible"; + category = "model-api"; + declaredCapabilities; + /** Orchestration may perform ONE structured-output correction retry. */ + supportsStructuredOutputCorrection = true; + config; + constructor(config2) { + this.config = openAiCompatibleProfileSchema.parse({ + runner: "openai-compatible", + ...config2 ?? {} + }); + this.declaredCapabilities = { + ...OPENAI_COMPATIBLE_DECLARED_CAPABILITIES, + // Native JSON Schema constraining is a per-endpoint capability the + // profile declares through its structured-output mode. + supportsJsonSchema: this.config.structuredOutput === "json-schema" + }; + } + get baseUrl() { + return this.config.baseUrl; + } + urlValidation() { + return validateRunnerBaseUrl(this.config.baseUrl, { + allowInsecureHttp: this.config.allowInsecureHttp + }); + } + /** The API-key VALUE, read at request time only. Never stored, never logged. */ + apiKeyValue() { + const variable = this.config.apiKeyEnvironmentVariable; + if (variable === null) return void 0; + const value = process.env[variable]; + return value !== void 0 && value.length > 0 ? value : void 0; + } + redact(text) { + return redactSecretValue(text, this.apiKeyValue()); + } + requestHeaders() { + const headers = { ...this.config.headers }; + const key = this.apiKeyValue(); + if (key !== void 0) headers["authorization"] = `Bearer ${key}`; + return headers; + } + endpointUrl(pathSuffix) { + return `${this.config.baseUrl.replace(/\/+$/, "")}${pathSuffix}`; + } + profileCapabilities(loopback) { + return { + ...this.declaredCapabilities, + localOnly: loopback, + requiresNetwork: !loopback + }; + } + async detect(context) { + const diagnostics = []; + const url = this.urlValidation(); + const capabilities = []; + const keyVariable = this.config.apiKeyEnvironmentVariable; + const keyConfigured = keyVariable !== null; + const keyPresent = this.apiKeyValue() !== void 0; + const authentication = !keyConfigured ? "not-applicable" : keyPresent ? "unknown" : "unauthenticated"; + const base = { + runner: this.name, + kind: "openai-compatible", + executable: this.config.baseUrl, + authentication, + category: this.category, + capabilitySet: this.profileCapabilities(url.loopback), + networkBacked: !url.loopback + }; + if (!this.config.enabled) { + diagnostics.push({ + severity: "error", + code: "RUNNER_DISABLED", + message: "This openai-compatible profile is disabled in .specbridge/config.json (enabled = false). Enable it explicitly to use the endpoint for spec authoring." + }); + return { ...base, status: "misconfigured", capabilities, diagnostics, supportLevel: "production" }; + } + if (!url.ok) { + for (const problem of url.problems) { + diagnostics.push({ severity: "error", code: "RUNNER_ENDPOINT_INVALID", message: `baseUrl: ${problem}` }); + } + return { ...base, status: "misconfigured", capabilities, diagnostics, supportLevel: "production" }; + } + if (!url.loopback) { + diagnostics.push({ + severity: "warning", + code: "RUNNER_NETWORK_BACKED", + message: `The endpoint ${url.hostname ?? ""} is not loopback: requests leave this machine (network-backed). Explicit selection is required.` + }); + if (this.config.allowInsecureHttp && url.protocol === "http:") { + diagnostics.push({ + severity: "warning", + code: "RUNNER_INSECURE_HTTP", + message: "INSECURE: allowInsecureHttp permits plain HTTP to a non-loopback endpoint. Prompts and responses travel unencrypted; use HTTPS outside private development networks." + }); + } + } + if (keyConfigured && !keyPresent) { + diagnostics.push({ + severity: "error", + code: "RUNNER_API_KEY_VARIABLE_UNSET", + message: `The configured API-key environment variable "${keyVariable ?? ""}" is not set. Export it before running (SpecBridge stores only the variable NAME, never a value).` + }); + } + capabilities.push({ + id: "structured-output", + label: `Structured output (${this.config.structuredOutput})`, + available: true, + required: true, + detail: "the complete response is validated by SpecBridge with a bounded correction retry" + }); + capabilities.push({ + id: "api-style", + label: `API style: ${this.config.apiStyle}`, + available: true, + required: true + }); + if (this.config.modelsEndpoint) { + const signal = context.timeoutMs !== void 0 ? AbortSignal.timeout(context.timeoutMs) : void 0; + const models = await safeHttpRequest({ + method: "GET", + url: this.endpointUrl("/models"), + timeoutMs: Math.min(context.timeoutMs ?? 15e3, 15e3), + maxResponseBytes: 1024 * 1024, + headers: this.requestHeaders(), + maxRedirects: 3, + ...signal !== void 0 ? { signal } : {} + }); + if (!models.ok) { + if (models.kind === "http-error" && (models.status === 401 || models.status === 403)) { + diagnostics.push({ + severity: "error", + code: "RUNNER_UNAUTHENTICATED", + message: `The endpoint refused GET /models (HTTP ${models.status}). Configure and export the API-key variable yourself.` + }); + capabilities.push({ id: "endpoint", label: "Endpoint reachable", available: true, required: true }); + return { ...base, authentication: "unauthenticated", status: "unauthenticated", capabilities, diagnostics, supportLevel: "production" }; + } + diagnostics.push({ + severity: "error", + code: "RUNNER_ENDPOINT_UNREACHABLE", + message: `The endpoint is unreachable: ${this.redact(models.detail)}. Start the server or fix the profile baseUrl.` + }); + capabilities.push({ id: "endpoint", label: "Endpoint reachable", available: false, required: true }); + return { ...base, status: "unavailable", capabilities, diagnostics, supportLevel: "unavailable" }; + } + capabilities.push({ id: "endpoint", label: "Endpoint reachable (GET /models)", available: true, required: true }); + capabilities.push({ id: "model-list", label: "Model listing", available: true, required: false }); + } else { + diagnostics.push({ + severity: "info", + code: "RUNNER_REACHABILITY_NOT_PROBED", + message: 'Endpoint reachability was not probed: the profile declares no safe non-inference request (set "modelsEndpoint": true when the endpoint supports GET /models). Use "specbridge runner test --network" for a bounded inference probe.' + }); + } + let status = "available"; + if (this.config.model === null) { + status = "misconfigured"; + diagnostics.push({ + severity: "error", + code: "RUNNER_MODEL_NOT_CONFIGURED", + message: 'No model is configured for this profile. SpecBridge never selects or guesses a model \u2014 set "model" explicitly (use "specbridge runner models " when the endpoint lists models).' + }); + capabilities.push({ id: "configured-model", label: "Configured model present", available: false, required: true }); + } else { + capabilities.push({ id: "configured-model", label: "Configured model present", available: true, required: true }); + } + if (keyConfigured && !keyPresent) status = "misconfigured"; + return { ...base, status, capabilities, diagnostics, supportLevel: "production" }; + } + executionBoundaryNote(_policy) { + return "Model API (authoring only): no repository access, no tools, no shell, no source modification; the returned document is an unapproved candidate."; + } + async listModels(context) { + if (!this.config.modelsEndpoint) { + return { + supported: false, + models: [], + detail: 'This profile does not declare a supported /models endpoint (set "modelsEndpoint": true when it exists). SpecBridge never guesses model names and never lists models by inference.' + }; + } + const url = this.urlValidation(); + if (!url.ok) { + return { supported: true, models: [], detail: `baseUrl invalid: ${url.problems.join("; ")}` }; + } + const signal = context.timeoutMs !== void 0 ? AbortSignal.timeout(context.timeoutMs) : void 0; + const result = await safeHttpRequest({ + method: "GET", + url: this.endpointUrl("/models"), + timeoutMs: Math.min(context.timeoutMs ?? 15e3, 15e3), + maxResponseBytes: 1024 * 1024, + expectJson: true, + headers: this.requestHeaders(), + maxRedirects: 3, + ...signal !== void 0 ? { signal } : {} + }); + if (!result.ok) { + return { supported: true, models: [], detail: `model listing failed: ${this.redact(result.detail)}` }; + } + const parsed = openAiModelsResponseSchema.safeParse(safeJson3(result.bodyText)); + if (!parsed.success) { + return { supported: true, models: [], detail: "the endpoint returned an unexpected model list shape" }; + } + return { + supported: true, + // Only fields the endpoint actually reports — capabilities are never + // inferred from a model name or provider branding. + models: parsed.data.data.map((model) => ({ + name: model.id, + ...model.owned_by !== void 0 ? { family: model.owned_by } : {}, + ...model.created !== void 0 ? { modifiedAt: new Date(model.created * 1e3).toISOString() } : {}, + location: url.loopback ? "local" : "remote" + })) + }; + } + async generateStage(input, execution) { + const started = Date.now(); + const failure = (problem, rawStdout = "") => ({ + runner: this.name, + outcome: problem.outcome, + failureReason: problem.failureReason, + rawStdout, + rawStderr: "", + durationMs: Math.max(0, Date.now() - started), + warnings: [], + error: problem.error, + cost: { currency: null, amount: null, source: "unavailable" } + }); + const url = this.urlValidation(); + if (!url.ok) { + return failure({ + outcome: "failed", + failureReason: `the profile baseUrl is invalid: ${url.problems.join("; ")}`, + error: runnerError({ + code: "invalid_configuration", + message: `The openai-compatible profile baseUrl is invalid: ${url.problems.join("; ")}` + }) + }); + } + const model = execution.model ?? this.config.model; + if (model === null || model === void 0) { + return failure({ + outcome: "failed", + failureReason: "no model is configured for this profile", + error: runnerError({ + code: "invalid_configuration", + message: "No model is configured; SpecBridge never selects one automatically.", + remediation: ['Set "model" on the profile explicitly.'] + }) + }); + } + if (input.prompt.length > this.config.maximumInputCharacters) { + return failure({ + outcome: "failed", + failureReason: `the assembled prompt (${input.prompt.length} characters) exceeds maximumInputCharacters (${this.config.maximumInputCharacters})`, + error: runnerError({ + code: "invalid_configuration", + message: "The authoring input exceeds the configured size limit for this profile.", + remediation: ["Reduce the spec/steering context or raise maximumInputCharacters explicitly."] + }) + }); + } + const messages = [{ role: "user", content: input.prompt }]; + if (input.correction !== void 0) { + messages.push( + { role: "assistant", content: input.correction.previousOutput }, + { + role: "user", + content: `Your previous response was not a valid structured result. Validation problems: ${input.correction.problems}. Return ONLY one corrected JSON document matching the required schema \u2014 no prose, no code fences.` + } + ); + } + const attempt = await this.requestOnce(model, messages, this.config.structuredOutput, execution); + if (!attempt.ok) { + if (attempt.unsupportedMode && this.config.allowStructuredOutputFallback && weakerStructuredOutputMode(this.config.structuredOutput) !== void 0) { + const weaker = weakerStructuredOutputMode(this.config.structuredOutput); + const retry = await this.requestOnce(model, messages, weaker, execution); + if (retry.ok) { + const result = this.mapCompleted(retry.body, retry.mode, model, started); + result.warnings.push( + `the endpoint rejected structured-output mode "${this.config.structuredOutput}"; the profile explicitly allows fallback and "${weaker}" was used` + ); + return result; + } + return failure(retry.failure, retry.retained ?? ""); + } + if (attempt.unsupportedMode) { + return failure( + { + outcome: "failed", + failureReason: `the endpoint does not support structured-output mode "${this.config.structuredOutput}"`, + error: runnerError({ + code: "structured_output_unsupported", + message: `The endpoint rejected structured-output mode "${this.config.structuredOutput}".`, + remediation: [ + 'Configure a mode the endpoint supports (json-object or strict-json-prompt), or set "allowStructuredOutputFallback": true to permit the explicit downgrade.' + ] + }) + }, + attempt.retained ?? "" + ); + } + return failure(attempt.failure, attempt.retained ?? ""); + } + return this.mapCompleted(attempt.body, attempt.mode, model, started); + } + async requestOnce(model, messages, mode, execution) { + const path64 = this.config.apiStyle === "chat-completions" ? "/chat/completions" : "/responses"; + const result = await safeHttpRequest({ + method: "POST", + url: this.endpointUrl(path64), + body: buildOpenAiRequestBody(this.config.apiStyle, { + model, + messages, + temperature: this.config.temperature, + structuredOutput: mode, + jsonSchema: STAGE_RUNNER_REPORT_JSON_SCHEMA, + schemaName: "stage_runner_report" + }), + timeoutMs: execution.timeoutMs, + maxResponseBytes: this.config.maximumOutputBytes, + expectJson: true, + headers: this.requestHeaders(), + maxRedirects: 3, + ...execution.signal !== void 0 ? { signal: execution.signal } : {} + }); + if (!result.ok) { + const unsupportedMode = mode !== "strict-json-prompt" && result.kind === "http-error" && indicatesStructuredOutputUnsupported(result.status, result.bodyExcerpt); + return { + ok: false, + failure: classifyHttpFailure2(result, (text) => this.redact(text)), + unsupportedMode, + ...result.kind === "http-error" && result.bodyExcerpt !== void 0 ? { retained: this.redact(result.bodyExcerpt) } : {} + }; + } + return { ok: true, body: result.bodyText, mode }; + } + mapCompleted(bodyText, mode, model, started) { + const retained = this.redact(bodyText); + const parsed = parseOpenAiResponse(this.config.apiStyle, bodyText); + const usage = { + model: parsed.model ?? model, + inputTokens: parsed.usage?.inputTokens ?? null, + cachedInputTokens: parsed.usage?.cachedInputTokens ?? null, + outputTokens: parsed.usage?.outputTokens ?? null, + reasoningTokens: null, + requestCount: 1, + durationMs: Math.max(0, Date.now() - started) + }; + const base = { + runner: this.name, + rawStdout: retained, + rawStderr: "", + durationMs: Math.max(0, Date.now() - started), + warnings: [], + usage, + cost: { currency: null, amount: null, source: "unavailable" } + }; + if (parsed.text === void 0) { + return { + ...base, + outcome: "malformed-output", + failureReason: parsed.problem ?? "the endpoint returned no usable content", + error: runnerError({ + code: "api_error", + message: `The endpoint response could not be used: ${parsed.problem ?? "no content"}.`, + retryable: false + }) + }; + } + const candidate = strictJsonParse4(parsed.text); + const report = candidate === void 0 ? void 0 : stageRunnerReportSchema.safeParse(candidate); + if (report === void 0 || !report.success) { + const problems = report !== void 0 && !report.success ? report.error.issues.map((issue2) => `${issue2.path.join(".") || "(root)"}: ${issue2.message}`).join("; ") : "the response content is not a bare JSON document"; + return { + ...base, + outcome: "malformed-output", + failureReason: `structured output invalid (${mode}): ${problems}`, + error: runnerError({ + code: "structured_output_invalid", + message: "The model response did not validate against the stage report schema.", + details: { problems: problems.slice(0, 2e3) } + }), + // Retained for inspection and the bounded correction retry; never + // applied. (Bounded: the transport already enforces response limits.) + invalidStructuredOutput: parsed.text.length > 1e5 ? parsed.text.slice(0, 1e5) : parsed.text + }; + } + return { + ...base, + outcome: "completed", + report: report.data + }; + } + /** + * Task execution is NOT a capability of a model-API runner. Selection + * rejects the operation before any request; this defensive implementation + * exists only to satisfy the AgentRunner interface and performs no HTTP + * request and no repository access. + */ + executeTask(_input, _execution) { + return Promise.resolve({ + runner: this.name, + outcome: "failed", + failureReason: "the openai-compatible runner is authoring-only: it cannot execute implementation tasks and never modifies repository files", + rawStdout: "", + rawStderr: "", + durationMs: 0, + warnings: [], + resumeSupported: false, + error: runnerError({ + code: "unsupported_operation", + message: "Model API runners cannot execute implementation tasks.", + remediation: ["Use an agent CLI profile (claude-code or codex-cli) for task execution."] + }), + cost: { currency: null, amount: null, source: "unavailable" } + }); + } + /** Minimal bounded structured-output probe (`runner test --network`). */ + async selfTest(execution) { + const result = await this.generateStage( + { + specName: "runner-self-test", + stage: "requirements", + intent: "generate", + prompt: 'This is a connectivity self test. Reply with exactly one JSON document: {"schemaVersion":"1.0.0","stage":"requirements","markdown":"# Self Test","summary":"self test"} and nothing else.', + promptVersion: "self-test", + toolPolicy: "read-only" + }, + { ...execution, timeoutMs: Math.min(execution.timeoutMs, 6e4) } + ); + return { + ok: result.outcome === "completed" && result.report !== void 0, + detail: result.outcome === "completed" ? "structured output validated" : result.failureReason ?? `self test failed (${result.outcome})`, + ...result.usage !== void 0 ? { usage: result.usage } : {} + }; + } +}; +function safeJson3(raw) { try { return JSON.parse(raw); } catch { return void 0; } } -function strictJsonParse2(raw) { +function strictJsonParse4(raw) { const trimmed = raw.trim(); if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return void 0; try { @@ -40375,17 +42409,192 @@ function strictJsonParse2(raw) { return void 0; } } -function usageFromChat(response, model, durationMs) { - return { - model, - inputTokens: response.prompt_eval_count ?? null, - cachedInputTokens: null, - outputTokens: response.eval_count ?? null, - reasoningTokens: null, - requestCount: 1, - durationMs: Math.max(0, Math.round(durationMs)) - }; -} +var ANTIGRAVITY_DECLARED_CAPABILITIES = capabilitySet([]); +var ANTIGRAVITY_OBSERVATION_PROBES = [ + { id: "headless", label: "Documented headless invocation", tokens: ["--prompt", "--non-interactive", "--headless"] }, + { id: "machine-readable", label: "Documented machine-readable output", tokens: ["--output-format", "--json"] }, + { id: "structured-final-output", label: "Documented structured final output", tokens: ["json"] }, + { id: "sandbox", label: "Documented sandbox / permission controls", tokens: ["--sandbox", "--approval-mode"] }, + { id: "workspace-write-control", label: "Documented workspace-write controls", tokens: ["--allowed-tools", "workspace-write"] }, + { id: "session-identity", label: "Documented session identity", tokens: ["--list-sessions", "--session"] }, + { id: "resume", label: "Documented session resume", tokens: ["--resume"] } +]; +var PROBE_TIMEOUT_MS5 = 15e3; +function tokenPresent3(helpText, token) { + const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[\\s,=<[|])${escaped}(?![\\w-])`, "m").test(helpText); +} +var AntigravityCliRunner = class { + name = "antigravity-cli"; + kind = "antigravity-cli"; + category = "experimental"; + declaredCapabilities = ANTIGRAVITY_DECLARED_CAPABILITIES; + /** Experimental in v0.6.1 — never selected automatically, never production. */ + declaredSupportLevel = "experimental"; + config; + constructor(config2) { + this.config = antigravityProfileSchema.parse({ runner: "antigravity-cli", ...config2 ?? {} }); + } + async detect(context) { + const diagnostics = []; + const base = { + runner: this.name, + kind: "antigravity-cli", + executable: this.config.command.executable, + // No safe offline status command is documented; credential files and + // private session stores are never read. + authentication: "unknown", + category: this.category, + capabilitySet: ANTIGRAVITY_DECLARED_CAPABILITIES, + networkBacked: false + }; + const emptyCapabilities = () => ANTIGRAVITY_OBSERVATION_PROBES.map((probe) => ({ + id: probe.id, + label: probe.label, + available: false, + required: false + })); + if (!this.config.enabled) { + diagnostics.push({ + severity: "error", + code: "RUNNER_DISABLED", + message: "This Antigravity profile is disabled in .specbridge/config.json (enabled = false). It is experimental: enabling it only unlocks diagnostics, never automation." + }); + return { + ...base, + status: "misconfigured", + capabilities: emptyCapabilities(), + diagnostics, + supportLevel: "experimental" + }; + } + const timeoutMs = Math.min(context.timeoutMs ?? PROBE_TIMEOUT_MS5, this.config.timeoutMs); + const invoke = (argv2) => runSafeProcess({ + executable: this.config.command.executable, + argv: [...this.config.command.args, ...argv2], + cwd: process.cwd(), + timeoutMs, + maxStdoutBytes: 1024 * 1024, + maxStderrBytes: 256 * 1024 + }); + const versionResult = await invoke(["--version"]); + if (versionResult.status === "spawn-failed") { + diagnostics.push({ + severity: "error", + code: "RUNNER_EXECUTABLE_NOT_FOUND", + message: `Antigravity executable "${this.config.command.executable}" could not be started. Install it yourself or set the profile command in .specbridge/config.json.` + }); + return { + ...base, + status: "unavailable", + capabilities: emptyCapabilities(), + diagnostics, + supportLevel: "experimental" + }; + } + if (versionResult.status === "timeout") { + diagnostics.push({ + severity: "error", + code: "RUNNER_INTERACTIVE_ONLY", + message: '"--version" did not return: the executable appears to start an interactive session. SpecBridge never automates a TUI (no PTY, no keystrokes, no screen scraping) \u2014 automation stays disabled.' + }); + return { + ...base, + status: "incompatible", + capabilities: emptyCapabilities(), + diagnostics, + supportLevel: "experimental" + }; + } + if (versionResult.status !== "ok") { + diagnostics.push({ + severity: "error", + code: "RUNNER_VERSION_FAILED", + message: `"${this.config.command.executable} --version" ${versionResult.failureReason ?? "produced no output"}.` + }); + return { + ...base, + status: "error", + capabilities: emptyCapabilities(), + diagnostics, + supportLevel: "experimental" + }; + } + const version2 = versionResult.stdout.trim().split(/\r?\n/)[0]?.trim(); + const help = await invoke(["--help"]); + const helpText = `${help.stdout} +${help.stderr}`; + const helpUsable = help.status === "ok" && helpText.trim().length > 0; + const interactiveOnly = help.status === "timeout" || helpUsable && /interactive|tui/i.test(helpText) && !/--prompt|--non-interactive|--headless/i.test(helpText); + const capabilities = ANTIGRAVITY_OBSERVATION_PROBES.map((probe) => { + const available = helpUsable && probe.tokens.some((token) => tokenPresent3(helpText, token)); + return { + id: probe.id, + label: probe.label, + available, + required: false, + detail: available ? "detected in help output \u2014 automation still stays disabled in v0.6.1" : "not proven for this installation" + }; + }); + const notProven = capabilities.filter((capability) => !capability.available); + if (interactiveOnly) { + diagnostics.push({ + severity: "warning", + code: "RUNNER_INTERACTIVE_ONLY", + message: "This installation documents only an interactive workflow. SpecBridge never automates a TUI (no PTY, no keystroke injection, no ANSI screen parsing)." + }); + } + if (notProven.length > 0) { + diagnostics.push({ + severity: "info", + code: "RUNNER_CAPABILITY_NOT_PROVEN", + message: `Not proven for this installation: ${notProven.map((capability) => capability.label.toLowerCase()).join("; ")}.` + }); + } + diagnostics.push({ + severity: "info", + code: "RUNNER_EXPERIMENTAL", + message: "Antigravity support is experimental: executable and capability diagnostics only. Stage authoring, task execution, and resume are disabled until a documented, headless, structured-output contract passes the applicable conformance suite (not in v0.6.1)." + }); + const status = helpUsable || help.status === "timeout" ? "available" : "error"; + return { + ...base, + status, + ...version2 !== void 0 && version2.length > 0 ? { version: version2 } : {}, + capabilities, + diagnostics, + supportLevel: "experimental" + }; + } + executionBoundaryNote(_policy) { + return "Experimental: detection and diagnostics only; no authoring, no task execution, no automation."; + } + refusal() { + return { + runner: this.name, + outcome: "failed", + failureReason: "the antigravity-cli adapter is experimental: it detects capabilities only and never executes authoring or tasks", + rawStdout: "", + rawStderr: "", + durationMs: 0, + warnings: [], + error: runnerError({ + code: "unsupported_operation", + message: "The experimental Antigravity adapter performs detection only in v0.6.1.", + remediation: [ + "Use a claude-code, codex-cli, or gemini-cli profile for execution, or an authoring profile for spec drafting." + ] + }) + }; + } + /** Selection refuses every operation first; these are defense in depth. */ + generateStage(_input, _execution) { + return Promise.resolve(this.refusal()); + } + executeTask(_input, _execution) { + return Promise.resolve({ ...this.refusal(), resumeSupported: false }); + } +}; var RunnerRegistry = class { profiles = /* @__PURE__ */ new Map(); registerProfile(profile) { @@ -40435,8 +42644,14 @@ function instantiateRunner(config2) { return new ClaudeCodeRunner(config2); case "codex-cli": return new CodexCliRunner(config2); + case "gemini-cli": + return new GeminiCliRunner(config2); case "ollama": return new OllamaRunner(config2); + case "openai-compatible": + return new OpenAiCompatibleRunner(config2); + case "antigravity-cli": + return new AntigravityCliRunner(config2); case "mock": return new MockRunner(config2); } @@ -40606,7 +42821,7 @@ function composeNormalizedResult(context, result) { }); } function profileTransport(config2) { - if (config2.runner === "ollama") { + if (config2.runner === "ollama" || config2.runner === "openai-compatible") { const url = validateRunnerBaseUrl(config2.baseUrl, { allowInsecureHttp: config2.allowInsecureHttp }); @@ -40622,11 +42837,11 @@ function profileTransport(config2) { return { networkBacked: false, localExecution: false }; } function profileModel(config2) { - if (config2.runner === "mock") return null; + if (config2.runner === "mock" || config2.runner === "antigravity-cli") return null; return config2.model ?? null; } -function declaredSupportLevel(_profile) { - return "production"; +function declaredSupportLevel(profile) { + return profile.runner.declaredSupportLevel ?? "production"; } function constraintsFor(profile, operation) { const constraints = []; @@ -40634,9 +42849,12 @@ function constraintsFor(profile, operation) { operation === "task-execution" || operation === "task-resume" ? "implementation" : "read-only" ); if (boundary !== void 0) constraints.push(boundary); - if (profile.config.runner === "ollama") { + if (profile.config.runner === "ollama" || profile.config.runner === "openai-compatible") { constraints.push("Task execution and repository writes are not capabilities of this runner."); } + if (profile.config.runner === "openai-compatible" && profile.config.allowInsecureHttp) { + constraints.push("INSECURE development override: plain HTTP to a remote endpoint is explicitly allowed."); + } constraints.push("No commits, no pushes, no checkbox updates by the provider; evidence stays provider-independent."); return constraints; } @@ -40867,6 +43085,58 @@ function retryBackoffMs(retryIndex, jitterRatio = 0.2) { const jitter = Math.floor(base * jitterRatio * Math.random()); return base + jitter; } +function runnerMatrixRows(profiles) { + return profiles.map((profile) => { + const operations = new Set(profileOperations(profile)); + return { + profile: profile.name, + implementation: profile.runner.name, + category: profile.runner.category, + support: profile.runner.declaredSupportLevel ?? "production", + enabled: profile.config.enabled !== false, + author: operations.has("stage-generation"), + refine: operations.has("stage-refinement"), + execute: operations.has("task-execution"), + resume: operations.has("task-resume"), + local: profileTransport(profile.config).localExecution + }; + }); +} +function renderRunnerMatrixMarkdown(rows) { + const lines = [ + "| Profile | Support | Author | Refine | Execute | Resume | Local |", + "|---------|---------|--------|--------|---------|--------|-------|" + ]; + for (const row of rows) { + const yn = (value) => value ? "yes" : "no"; + lines.push( + `| ${row.profile} | ${row.support} | ${yn(row.author)} | ${yn(row.refine)} | ${yn(row.execute)} | ${yn(row.resume)} | ${yn(row.local)} |` + ); + } + return `${lines.join("\n")} +`; +} +function runnerProfileSummary(profile) { + const transport = profileTransport(profile.config); + return { + profile: profile.name, + implementation: profile.runner.name, + category: profile.runner.category, + supportLevel: profile.runner.declaredSupportLevel ?? "production", + enabled: profile.config.enabled !== false, + model: profileModel(profile.config), + networkBacked: transport.networkBacked, + localExecution: transport.localExecution, + supportedOperations: profileOperations(profile) + }; +} +function redactedRunnerProfileConfig(profile) { + const redacted = {}; + for (const [key, value] of Object.entries(profile.config)) { + redacted[key] = /key|token|secret|password|credential/i.test(key) ? "" : value; + } + return redacted; +} function conformanceStagePrompt(stage) { return [ "# SpecBridge conformance authoring request", @@ -41217,12 +43487,13 @@ async function runRunnerConformance(context, executionGroups = []) { 0 ); const skippedChecks = groups.reduce((sum, group) => sum + group.skipped, 0); + const declaredProduction = (context.profile.runner.declaredSupportLevel ?? "production") === "production"; return { runner: context.profile.runner.name, profile: context.profile.name, groups, passed: failedChecks === 0, - productionConfirmed: failedChecks === 0 && skippedChecks === 0, + productionConfirmed: failedChecks === 0 && skippedChecks === 0 && declaredProduction, skippedChecks, failedChecks }; @@ -49168,34 +51439,6 @@ var OPERATION_SHORT = { "model-list": "Models", "runner-test": "Test" }; -function matrixRows(profiles) { - return profiles.map((profile) => { - const operations = new Set(profileOperations(profile)); - return { - profile: profile.name, - support: "production", - author: operations.has("stage-generation"), - refine: operations.has("stage-refinement"), - execute: operations.has("task-execution"), - resume: operations.has("task-resume"), - local: profileTransport(profile.config).localExecution - }; - }); -} -function renderMatrixMarkdown(rows) { - const lines = [ - "| Profile | Support | Author | Refine | Execute | Resume | Local |", - "|---------|---------|--------|--------|---------|--------|-------|" - ]; - for (const row of rows) { - const yn = (value) => value ? "yes" : "no"; - lines.push( - `| ${row.profile} | ${row.support} | ${yn(row.author)} | ${yn(row.refine)} | ${yn(row.execute)} | ${yn(row.resume)} | ${yn(row.local)} |` - ); - } - return `${lines.join("\n")} -`; -} function printDoctorReport(runtime, profile, detection, verbose) { runtime.out(reportTitle(`Runner profile: ${profile.name}`)); runtime.out(`Implementation: ${detection.runner} (${detection.category})`); @@ -49339,7 +51582,7 @@ Examples: }); runner.command("matrix").description("Capability matrix generated from registered runner metadata").option("--json", "output a machine-readable JSON report").option("--markdown", "output a Markdown table (used to generate docs)").action((options) => { const { registry: registry2 } = loadExecutionContext(runtime); - const rows = matrixRows(registry2.listProfiles()); + const rows = runnerMatrixRows(registry2.listProfiles()); if (options.json === true) { runtime.outRaw( serializeJsonReport( @@ -49349,7 +51592,7 @@ Examples: return; } if (options.markdown === true) { - runtime.outRaw(renderMatrixMarkdown(rows)); + runtime.outRaw(renderRunnerMatrixMarkdown(rows)); return; } runtime.out(reportTitle("Runner Capability Matrix")); @@ -49659,8 +51902,15 @@ Examples: "production status is confirmed only when nothing is skipped (rerun with --network)" ) ); - } else { + } else if (result.productionConfirmed) { runtime.out(okLine("All applicable conformance checks passed \u2014 production confirmed.")); + } else { + runtime.out( + warnLine( + "All applicable conformance checks passed.", + "production is never confirmed for a preview/experimental adapter" + ) + ); } } runtime.exitCode = result.passed ? EXIT_CODES.ok : EXIT_CODES.gateFailure; @@ -50377,7 +52627,7 @@ Examples: }); } -// ../../packages/mcp-server/dist/chunk-YZA6C4OQ.js +// ../../packages/mcp-server/dist/chunk-GHY7GLQQ.js var import_buffer5 = require("buffer"); var import_fs30 = require("fs"); var import_path34 = __toESM(require("path"), 1); @@ -60447,13 +62697,13 @@ var McpServer = class { } return registeredPrompt; } - _createRegisteredTool(name, title, description, inputSchema17, outputSchema22, annotations, execution, _meta, handler) { + _createRegisteredTool(name, title, description, inputSchema20, outputSchema26, annotations, execution, _meta, handler) { validateAndWarnToolName(name); const registeredTool = { title, description, - inputSchema: getZodSchemaObject(inputSchema17), - outputSchema: getZodSchemaObject(outputSchema22), + inputSchema: getZodSchemaObject(inputSchema20), + outputSchema: getZodSchemaObject(outputSchema26), annotations, execution, _meta, @@ -60503,8 +62753,8 @@ var McpServer = class { throw new Error(`Tool ${name} is already registered`); } let description; - let inputSchema17; - let outputSchema22; + let inputSchema20; + let outputSchema26; let annotations; if (typeof rest[0] === "string") { description = rest.shift(); @@ -60512,7 +62762,7 @@ var McpServer = class { if (rest.length > 1) { const firstArg = rest[0]; if (isZodRawShapeCompat(firstArg)) { - inputSchema17 = rest.shift(); + inputSchema20 = rest.shift(); if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !isZodRawShapeCompat(rest[0])) { annotations = rest.shift(); } @@ -60524,7 +62774,7 @@ var McpServer = class { } } const callback = rest[0]; - return this._createRegisteredTool(name, void 0, description, inputSchema17, outputSchema22, annotations, { taskSupport: "forbidden" }, void 0, callback); + return this._createRegisteredTool(name, void 0, description, inputSchema20, outputSchema26, annotations, { taskSupport: "forbidden" }, void 0, callback); } /** * Registers a tool with a config object and callback. @@ -60533,8 +62783,8 @@ var McpServer = class { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); } - const { title, description, inputSchema: inputSchema17, outputSchema: outputSchema22, annotations, _meta } = config2; - return this._createRegisteredTool(name, title, description, inputSchema17, outputSchema22, annotations, { taskSupport: "forbidden" }, _meta, cb); + const { title, description, inputSchema: inputSchema20, outputSchema: outputSchema26, annotations, _meta } = config2; + return this._createRegisteredTool(name, title, description, inputSchema20, outputSchema26, annotations, { taskSupport: "forbidden" }, _meta, cb); } prompt(name, ...rest) { if (this._registeredPrompts[name]) { @@ -60709,10 +62959,13 @@ var EMPTY_COMPLETION_RESULT = { } }; -// ../../packages/mcp-server/dist/chunk-YZA6C4OQ.js +// ../../packages/mcp-server/dist/chunk-GHY7GLQQ.js var import_fs31 = require("fs"); var import_fs32 = require("fs"); var import_path36 = __toESM(require("path"), 1); +var import_fs33 = require("fs"); +var import_os = __toESM(require("os"), 1); +var import_path37 = __toESM(require("path"), 1); // ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.29.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js var import_node_process11 = __toESM(require("process"), 1); @@ -60806,9 +63059,9 @@ var StdioServerTransport = class { } }; -// ../../packages/mcp-server/dist/chunk-YZA6C4OQ.js +// ../../packages/mcp-server/dist/chunk-GHY7GLQQ.js var MCP_SERVER_NAME = "specbridge"; -var MCP_SERVER_VERSION = "0.6.0"; +var MCP_SERVER_VERSION = "0.6.1"; var MCP_SERVER_TITLE = "SpecBridge"; var MCP_SDK_VERSION = "1.29.0"; var MCP_PROTOCOL_BASELINE = "2025-11-25"; @@ -64033,6 +66286,376 @@ ${commandLines.join("\n")}` : "No verification commands are configured.", }) }); } +function loadRunnerToolContext(context) { + const workspace = context.requireWorkspace(); + const config2 = requireAgentConfig(workspace); + return { workspace, config: config2, registry: createDefaultRunnerRegistry(config2) }; +} +function requireProfile(registry2, name) { + if (!registry2.has(name)) { + throw new McpToolError( + "SBMCP002", + `Unknown runner profile "${name}". Configured profiles: ${registry2.listProfiles().map((profile) => profile.name).join(", ")}.`, + { remediation: ["Call runner_list to see every configured profile."] } + ); + } + return registry2.getProfile(name); +} +var RUNNER_PROBE_TIMEOUT_MS = 2e4; +var capabilitySetShape2 = external_exports.record(external_exports.boolean()).describe("Provider-independent capability keys to booleans"); +var profileSummaryShape = external_exports.object({ + profile: external_exports.string(), + implementation: external_exports.string(), + category: external_exports.string(), + supportLevel: external_exports.string().describe("Adapter-declared support level"), + enabled: external_exports.boolean(), + model: external_exports.string().nullable(), + networkBacked: external_exports.boolean().describe("True when SpecBridge itself would leave this machine"), + localExecution: external_exports.boolean(), + supportedOperations: external_exports.array(external_exports.string()) +}); +var detectionCapabilityShape = external_exports.object({ + id: external_exports.string(), + label: external_exports.string(), + available: external_exports.boolean(), + required: external_exports.boolean(), + detail: external_exports.string().optional() +}); +var runnerDiagnosticShape = external_exports.object({ + severity: external_exports.enum(["info", "warning", "error"]), + code: external_exports.string(), + message: external_exports.string() +}); +var detectionViewShape = external_exports.object({ + status: external_exports.string(), + supportLevel: external_exports.string().describe("Effective support level after detection"), + version: external_exports.string().nullable(), + authentication: external_exports.string(), + networkBacked: external_exports.boolean(), + capabilities: external_exports.array(detectionCapabilityShape), + detectedCapabilities: capabilitySetShape2, + diagnostics: external_exports.array(runnerDiagnosticShape) +}); +var MAX_DIAGNOSTICS = 50; +function toDetectionView(detection, verbose) { + const diagnostics = (verbose ? detection.diagnostics : detection.diagnostics.filter((diagnostic) => diagnostic.severity !== "info")).slice(0, MAX_DIAGNOSTICS); + return { + status: detection.status, + supportLevel: detection.supportLevel, + version: detection.version ?? null, + authentication: detection.authentication, + networkBacked: detection.networkBacked, + capabilities: detection.capabilities.map((capability) => ({ + id: String(capability.id), + label: capability.label, + available: capability.available, + required: capability.required, + ...capability.detail !== void 0 ? { detail: capability.detail } : {} + })), + detectedCapabilities: detection.capabilitySet, + diagnostics: diagnostics.map((diagnostic) => ({ + severity: diagnostic.severity, + code: diagnostic.code, + message: diagnostic.message + })) + }; +} +var conformanceSummaryShape = external_exports.object({ + passed: external_exports.boolean(), + productionConfirmed: external_exports.boolean(), + failedChecks: external_exports.number().int(), + skippedChecks: external_exports.number().int(), + groups: external_exports.array( + external_exports.object({ + group: external_exports.string(), + applicable: external_exports.boolean(), + reason: external_exports.string().optional(), + passed: external_exports.boolean(), + skipped: external_exports.number().int() + }) + ), + note: external_exports.string() +}); +async function invocationFreeConformanceSummary(profile) { + const scratch = (0, import_fs33.mkdtempSync)(import_path37.default.join(import_os.default.tmpdir(), "specbridge-mcp-conformance-")); + let result; + try { + result = await runRunnerConformance({ + profile, + workspaceRoot: scratch, + runDir: import_path37.default.join(scratch, ".specbridge-conformance-runs"), + invocationsAllowed: false, + timeoutMs: RUNNER_PROBE_TIMEOUT_MS + }); + } finally { + (0, import_fs33.rmSync)(scratch, { recursive: true, force: true }); + } + return { + passed: result.passed, + productionConfirmed: result.productionConfirmed, + failedChecks: result.failedChecks, + skippedChecks: result.skippedChecks, + groups: result.groups.map((group) => ({ + group: group.group, + applicable: group.applicable, + ...group.reason !== void 0 ? { reason: group.reason } : {}, + passed: group.passed, + skipped: group.skipped + })), + note: 'Invocation-free summary: checks that would invoke the provider are skipped here. Run "specbridge runner conformance --network" for the full suite.' + }; +} +var inputSchema17 = { + enabledOnly: external_exports.boolean().optional().describe("Only profiles that are enabled in the configuration"), + detect: external_exports.boolean().optional().describe( + "Probe availability for the returned page (read-only version/help/reachability probes; slower \u2014 default false)" + ), + limit: limitArg, + cursor: cursorArg +}; +var outputSchema22 = { + defaultRunner: external_exports.string(), + profiles: external_exports.array( + profileSummaryShape.extend({ + availability: external_exports.string().optional().describe("Detection status (present when detect=true)") + }) + ), + pagination: paginationShape +}; +function registerRunnerListTool(server, context) { + registerDefinedTool(server, context, { + name: "runner_list", + title: "List runner profiles", + description: "List configured runner profiles: implementation, category, support level, enabled state, configured model, local/network classification, and supported operations. Optionally probes availability (read-only; never a model request). Supports pagination. Read-only.", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }, + inputSchema: inputSchema17, + outputSchema: outputSchema22, + handler: async (args) => { + const { workspace, config: config2, registry: registry2 } = loadRunnerToolContext(context); + const profiles = registry2.listProfiles().filter((profile) => args.enabledOnly !== true || profile.config.enabled !== false); + const page = paginate(profiles, { + ...args.limit !== void 0 ? { limit: args.limit } : {}, + ...args.cursor !== void 0 ? { cursor: args.cursor } : {}, + token: "runner_list" + }); + const summaries = []; + for (const profile of page.items) { + const summary = runnerProfileSummary(profile); + if (args.detect === true) { + const detection = await profile.runner.detect({ + workspaceRoot: workspace.rootDir, + timeoutMs: RUNNER_PROBE_TIMEOUT_MS + }); + summaries.push({ ...summary, availability: detection.status }); + } else { + summaries.push(summary); + } + } + const lines = summaries.map( + (summary) => `- ${summary.profile} [${summary.implementation}/${summary.category}] ${summary.enabled ? "enabled" : "disabled"}, support ${summary.supportLevel}${"availability" in summary && summary.availability !== void 0 ? `, ${summary.availability}` : ""}, operations: ${summary.supportedOperations.join(", ") || "(none)"}` + ); + return { + text: `${page.totalCount} runner profile(s); default runner: ${config2.defaultRunner}. +${lines.join("\n")}`, + structured: { + defaultRunner: config2.defaultRunner, + profiles: summaries, + pagination: { + totalCount: page.totalCount, + truncated: page.truncated, + ...page.nextCursor !== void 0 ? { nextCursor: page.nextCursor } : {} + } + } + }; + } + }); +} +var inputSchema18 = { + profile: external_exports.string().min(1).max(120).describe('Runner profile name (e.g. "gemini-default")') +}; +var operationCompatibilityShape = external_exports.object({ + operation: external_exports.string(), + supported: external_exports.boolean(), + missingCapabilities: external_exports.array(external_exports.string()) +}); +var outputSchema23 = { + summary: profileSummaryShape, + configuration: external_exports.record(external_exports.unknown()).describe("Redacted profile configuration (profiles can never store credential values)"), + declaredCapabilities: capabilitySetShape2, + detection: detectionViewShape, + operationCompatibility: external_exports.array(operationCompatibilityShape).describe("Per-operation support from DETECTED capabilities"), + conformance: conformanceSummaryShape, + boundary: external_exports.object({ + networkBacked: external_exports.boolean(), + localExecution: external_exports.boolean(), + constraints: external_exports.array(external_exports.string()) + }), + limitations: external_exports.array(external_exports.string()), + remediation: external_exports.array(external_exports.string()) +}; +function registerRunnerShowTool(server, context) { + registerDefinedTool(server, context, { + name: "runner_show", + title: "Show a runner profile", + description: "Show one runner profile: redacted configuration, declared and detected capabilities, operation compatibility, invocation-free conformance summary, network boundary, known limitations, and remediation. Read-only; never sends a model request.", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }, + inputSchema: inputSchema18, + outputSchema: outputSchema23, + handler: async (args) => { + const { workspace, registry: registry2 } = loadRunnerToolContext(context); + const profile = requireProfile(registry2, args.profile); + const summary = runnerProfileSummary(profile); + const detection = await profile.runner.detect({ + workspaceRoot: workspace.rootDir, + probeCapabilities: true, + timeoutMs: RUNNER_PROBE_TIMEOUT_MS + }); + const detectionView = toDetectionView(detection, true); + const conformance = await invocationFreeConformanceSummary(profile); + const operationCompatibility = RUNNER_OPERATIONS.filter( + (operation) => operation !== "model-list" && operation !== "runner-test" + ).map((operation) => { + const support = checkOperationSupport(operation, detection.capabilitySet); + return { + operation, + supported: support.supported, + missingCapabilities: [ + ...support.missingCapabilities, + ...support.unsatisfiedBoundaries.flat() + ] + }; + }); + const boundaryNote = profile.runner.executionBoundaryNote?.("implementation"); + const limitations = detectionView.diagnostics.filter((diagnostic) => diagnostic.severity !== "error").map((diagnostic) => diagnostic.message); + const remediation = detectionView.diagnostics.filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => diagnostic.message); + const supported = operationCompatibility.filter((entry) => entry.supported).map((entry) => entry.operation); + return { + text: `Profile ${args.profile} (${summary.implementation}, ${summary.category}): ${summary.enabled ? "enabled" : "disabled"}, status ${detection.status}, support ${detection.supportLevel}. Supported operations (detected): ${supported.join(", ") || "(none)"}.`, + structured: { + summary, + configuration: redactedRunnerProfileConfig(profile), + declaredCapabilities: profile.runner.declaredCapabilities, + detection: detectionView, + operationCompatibility, + conformance, + boundary: { + networkBacked: summary.networkBacked, + localExecution: summary.localExecution, + constraints: [ + ...boundaryNote !== void 0 ? [boundaryNote] : [], + "No commits, no pushes, no checkbox updates by the provider; evidence stays provider-independent." + ] + }, + limitations, + remediation + } + }; + } + }); +} +var inputSchema19 = { + profile: external_exports.string().min(1).max(120).optional().describe("Runner profile name (default: the configured default runner)"), + verbose: external_exports.boolean().optional().describe("Include informational diagnostics") +}; +var outputSchema24 = { + profile: external_exports.string(), + implementation: external_exports.string(), + category: external_exports.string(), + enabled: external_exports.boolean(), + executable: external_exports.string().nullable().describe("Configured executable or endpoint"), + detection: detectionViewShape, + ready: external_exports.boolean().describe("True when the runner status is available") +}; +function registerRunnerDoctorTool(server, context) { + registerDefinedTool(server, context, { + name: "runner_doctor", + title: "Diagnose a runner profile", + description: "Diagnose one runner profile: executable/endpoint presence, version, authentication state (never via credential files), detected capabilities, and actionable findings. Read-only: never a model request, never a login, never a configuration change.", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }, + inputSchema: inputSchema19, + outputSchema: outputSchema24, + handler: async (args) => { + const { workspace, config: config2, registry: registry2 } = loadRunnerToolContext(context); + const profileName = args.profile ?? config2.defaultRunner; + const profile = requireProfile(registry2, profileName); + const detection = await profile.runner.detect({ + workspaceRoot: workspace.rootDir, + probeCapabilities: true, + timeoutMs: RUNNER_PROBE_TIMEOUT_MS + }); + const view = toDetectionView(detection, args.verbose === true); + const findings = view.diagnostics.map((diagnostic) => `- [${diagnostic.severity}] ${diagnostic.message}`).join("\n"); + return { + text: `Runner ${profileName} (${profile.runner.name}): status ${view.status}, support ${view.supportLevel}, authentication ${view.authentication}.${findings.length > 0 ? ` +${findings}` : ""}`, + structured: { + profile: profileName, + implementation: profile.runner.name, + category: profile.runner.category, + enabled: profile.config.enabled !== false, + executable: detection.executable ?? null, + detection: view, + ready: detection.status === "available" + } + }; + } + }); +} +var matrixRowShape = external_exports.object({ + profile: external_exports.string(), + implementation: external_exports.string(), + category: external_exports.string(), + support: external_exports.string(), + enabled: external_exports.boolean(), + author: external_exports.boolean(), + refine: external_exports.boolean(), + execute: external_exports.boolean(), + resume: external_exports.boolean(), + local: external_exports.boolean() +}); +var outputSchema25 = { + rows: external_exports.array(matrixRowShape), + markdown: external_exports.string().describe("The same matrix as a Markdown table") +}; +function registerRunnerMatrixTool(server, context) { + registerDefinedTool(server, context, { + name: "runner_matrix", + title: "Runner capability matrix", + description: 'The authoritative runner capability matrix (author/refine/execute/resume per profile), generated from registered runner metadata \u2014 identical to "specbridge runner matrix". Read-only; no probes, no processes, no network.', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }, + inputSchema: {}, + outputSchema: outputSchema25, + handler: async () => { + const { registry: registry2 } = loadRunnerToolContext(context); + const rows = runnerMatrixRows(registry2.listProfiles()); + const markdown = renderRunnerMatrixMarkdown(rows); + return { + text: markdown, + structured: { rows, markdown } + }; + } + }); +} var TOOL_CATALOG = [ { name: "workspace_detect", readOnly: true, summary: "Detect the Kiro-compatible workspace" }, { name: "steering_list", readOnly: true, summary: "List steering documents" }, @@ -64048,6 +66671,10 @@ var TOOL_CATALOG = [ { name: "run_read", readOnly: true, summary: "Safe single-run summary" }, { name: "spec_affected", readOnly: true, summary: "Affected-spec resolution for a change set" }, { name: "spec_check_drift", readOnly: true, summary: "Deterministic drift rules (no commands)" }, + { name: "runner_list", readOnly: true, summary: "Runner profiles with capabilities and availability" }, + { name: "runner_show", readOnly: true, summary: "One runner profile in depth (redacted)" }, + { name: "runner_doctor", readOnly: true, summary: "Runner diagnostics (never a model request)" }, + { name: "runner_matrix", readOnly: true, summary: "Authoritative runner capability matrix" }, { name: "spec_create", readOnly: false, summary: "Preview-first offline spec creation" }, { name: "spec_stage_validate", readOnly: true, summary: "Validate a stage candidate (no write)" }, { name: "spec_stage_apply", readOnly: false, summary: "Apply a reviewed stage candidate atomically" }, @@ -64071,6 +66698,10 @@ function registerAllTools(server, context) { registerRunReadTool(server, context); registerSpecAffectedTool(server, context); registerSpecCheckDriftTool(server, context); + registerRunnerListTool(server, context); + registerRunnerShowTool(server, context); + registerRunnerDoctorTool(server, context); + registerRunnerMatrixTool(server, context); registerSpecCreateTool(server, context); registerSpecStageValidateTool(server, context); registerSpecStageApplyTool(server, context); @@ -64241,8 +66872,8 @@ async function runMcpServe(argv2, io = { } // ../../packages/mcp-server/dist/index.js -var import_fs33 = require("fs"); -var import_path37 = __toESM(require("path"), 1); +var import_fs34 = require("fs"); +var import_path38 = __toESM(require("path"), 1); async function runMcpDoctor(options = {}) { const checks = []; const env = options.env ?? process.env; @@ -64335,7 +66966,7 @@ async function runMcpDoctor(options = {}) { const pluginRoot = env["CLAUDE_PLUGIN_ROOT"]; if (pluginRoot !== void 0 && pluginRoot.length > 0) { const missing = ["dist/mcp-server.cjs", "dist/cli.cjs"].filter( - (relative) => !(0, import_fs33.existsSync)(import_path37.default.join(pluginRoot, relative)) + (relative) => !(0, import_fs34.existsSync)(import_path38.default.join(pluginRoot, relative)) ); checks.push( missing.length === 0 ? { name: "plugin-bundle", status: "ok", detail: `Bundled executables present under ${pluginRoot}` } : { diff --git a/integrations/claude-code-plugin/specbridge/dist/mcp-server.cjs b/integrations/claude-code-plugin/specbridge/dist/mcp-server.cjs index 014624e..2317061 100644 --- a/integrations/claude-code-plugin/specbridge/dist/mcp-server.cjs +++ b/integrations/claude-code-plugin/specbridge/dist/mcp-server.cjs @@ -106,17 +106,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path17) { - const ctrl = callVisitor(key, node, visitor, path17); + function visit_(key, node, visitor, path18) { + const ctrl = callVisitor(key, node, visitor, path18); if (identity3.isNode(ctrl) || identity3.isPair(ctrl)) { - replaceNode(key, path17, ctrl); - return visit_(key, ctrl, visitor, path17); + replaceNode(key, path18, ctrl); + return visit_(key, ctrl, visitor, path18); } if (typeof ctrl !== "symbol") { if (identity3.isCollection(node)) { - path17 = Object.freeze(path17.concat(node)); + path18 = Object.freeze(path18.concat(node)); for (let i2 = 0; i2 < node.items.length; ++i2) { - const ci = visit_(i2, node.items[i2], visitor, path17); + const ci = visit_(i2, node.items[i2], visitor, path18); if (typeof ci === "number") i2 = ci - 1; else if (ci === BREAK) @@ -127,13 +127,13 @@ var require_visit = __commonJS({ } } } else if (identity3.isPair(node)) { - path17 = Object.freeze(path17.concat(node)); - const ck = visit_("key", node.key, visitor, path17); + path18 = Object.freeze(path18.concat(node)); + const ck = visit_("key", node.key, visitor, path18); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path17); + const cv = visit_("value", node.value, visitor, path18); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -154,17 +154,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path17) { - const ctrl = await callVisitor(key, node, visitor, path17); + async function visitAsync_(key, node, visitor, path18) { + const ctrl = await callVisitor(key, node, visitor, path18); if (identity3.isNode(ctrl) || identity3.isPair(ctrl)) { - replaceNode(key, path17, ctrl); - return visitAsync_(key, ctrl, visitor, path17); + replaceNode(key, path18, ctrl); + return visitAsync_(key, ctrl, visitor, path18); } if (typeof ctrl !== "symbol") { if (identity3.isCollection(node)) { - path17 = Object.freeze(path17.concat(node)); + path18 = Object.freeze(path18.concat(node)); for (let i2 = 0; i2 < node.items.length; ++i2) { - const ci = await visitAsync_(i2, node.items[i2], visitor, path17); + const ci = await visitAsync_(i2, node.items[i2], visitor, path18); if (typeof ci === "number") i2 = ci - 1; else if (ci === BREAK) @@ -175,13 +175,13 @@ var require_visit = __commonJS({ } } } else if (identity3.isPair(node)) { - path17 = Object.freeze(path17.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path17); + path18 = Object.freeze(path18.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path18); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path17); + const cv = await visitAsync_("value", node.value, visitor, path18); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -208,23 +208,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path17) { + function callVisitor(key, node, visitor, path18) { if (typeof visitor === "function") - return visitor(key, node, path17); + return visitor(key, node, path18); if (identity3.isMap(node)) - return visitor.Map?.(key, node, path17); + return visitor.Map?.(key, node, path18); if (identity3.isSeq(node)) - return visitor.Seq?.(key, node, path17); + return visitor.Seq?.(key, node, path18); if (identity3.isPair(node)) - return visitor.Pair?.(key, node, path17); + return visitor.Pair?.(key, node, path18); if (identity3.isScalar(node)) - return visitor.Scalar?.(key, node, path17); + return visitor.Scalar?.(key, node, path18); if (identity3.isAlias(node)) - return visitor.Alias?.(key, node, path17); + return visitor.Alias?.(key, node, path18); return void 0; } - function replaceNode(key, path17, node) { - const parent = path17[path17.length - 1]; + function replaceNode(key, path18, node) { + const parent = path18[path18.length - 1]; if (identity3.isCollection(parent)) { parent.items[key] = node; } else if (identity3.isPair(parent)) { @@ -834,10 +834,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity3 = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path17, value) { + function collectionFromPath(schema, path18, value) { let v = value; - for (let i2 = path17.length - 1; i2 >= 0; --i2) { - const k = path17[i2]; + for (let i2 = path18.length - 1; i2 >= 0; --i2) { + const k = path18[i2]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a2 = []; a2[k] = v; @@ -856,7 +856,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path17) => path17 == null || typeof path17 === "object" && !!path17[Symbol.iterator]().next().done; + var isEmptyPath = (path18) => path18 == null || typeof path18 === "object" && !!path18[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -886,11 +886,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path17, value) { - if (isEmptyPath(path17)) + addIn(path18, value) { + if (isEmptyPath(path18)) this.add(value); else { - const [key, ...rest] = path17; + const [key, ...rest] = path18; const node = this.get(key, true); if (identity3.isCollection(node)) node.addIn(rest, value); @@ -904,8 +904,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path17) { - const [key, ...rest] = path17; + deleteIn(path18) { + const [key, ...rest] = path18; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -919,8 +919,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path17, keepScalar) { - const [key, ...rest] = path17; + getIn(path18, keepScalar) { + const [key, ...rest] = path18; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity3.isScalar(node) ? node.value : node; @@ -938,8 +938,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path17) { - const [key, ...rest] = path17; + hasIn(path18) { + const [key, ...rest] = path18; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -949,8 +949,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path17, value) { - const [key, ...rest] = path17; + setIn(path18, value) { + const [key, ...rest] = path18; if (rest.length === 0) { this.set(key, value); } else { @@ -3465,9 +3465,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path17, value) { + addIn(path18, value) { if (assertCollection(this.contents)) - this.contents.addIn(path17, value); + this.contents.addIn(path18, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -3542,14 +3542,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path17) { - if (Collection.isEmptyPath(path17)) { + deleteIn(path18) { + if (Collection.isEmptyPath(path18)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path17) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path18) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -3564,10 +3564,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path17, keepScalar) { - if (Collection.isEmptyPath(path17)) + getIn(path18, keepScalar) { + if (Collection.isEmptyPath(path18)) return !keepScalar && identity3.isScalar(this.contents) ? this.contents.value : this.contents; - return identity3.isCollection(this.contents) ? this.contents.getIn(path17, keepScalar) : void 0; + return identity3.isCollection(this.contents) ? this.contents.getIn(path18, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -3578,10 +3578,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path17) { - if (Collection.isEmptyPath(path17)) + hasIn(path18) { + if (Collection.isEmptyPath(path18)) return this.contents !== void 0; - return identity3.isCollection(this.contents) ? this.contents.hasIn(path17) : false; + return identity3.isCollection(this.contents) ? this.contents.hasIn(path18) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -3598,13 +3598,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path17, value) { - if (Collection.isEmptyPath(path17)) { + setIn(path18, value) { + if (Collection.isEmptyPath(path18)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path17), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path18), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path17, value); + this.contents.setIn(path18, value); } } /** @@ -5564,9 +5564,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path17) => { + visit.itemAtPath = (cst, path18) => { let item = cst; - for (const [field, index] of path17) { + for (const [field, index] of path18) { const tok = item?.[field]; if (tok && "items" in tok) { item = tok.items[index]; @@ -5575,23 +5575,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path17) => { - const parent = visit.itemAtPath(cst, path17.slice(0, -1)); - const field = path17[path17.length - 1][0]; + visit.parentCollection = (cst, path18) => { + const parent = visit.itemAtPath(cst, path18.slice(0, -1)); + const field = path18[path18.length - 1][0]; const coll = parent?.[field]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path17, item, visitor) { - let ctrl = visitor(item, path17); + function _visit(path18, item, visitor) { + let ctrl = visitor(item, path18); if (typeof ctrl === "symbol") return ctrl; for (const field of ["key", "value"]) { const token = item[field]; if (token && "items" in token) { for (let i2 = 0; i2 < token.items.length; ++i2) { - const ci = _visit(Object.freeze(path17.concat([[field, i2]])), token.items[i2], visitor); + const ci = _visit(Object.freeze(path18.concat([[field, i2]])), token.items[i2], visitor); if (typeof ci === "number") i2 = ci - 1; else if (ci === BREAK) @@ -5602,10 +5602,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field === "key") - ctrl = ctrl(item, path17); + ctrl = ctrl(item, path18); } } - return typeof ctrl === "function" ? ctrl(item, path17) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path18) : ctrl; } exports2.visit = visit; } @@ -10552,8 +10552,8 @@ var require_utils = __commonJS({ } return ind; } - function removeDotSegments(path17) { - let input = path17; + function removeDotSegments(path18) { + let input = path18; const output = []; let nextSlash = -1; let len = 0; @@ -10805,8 +10805,8 @@ var require_schemes = __commonJS({ wsComponent.secure = void 0; } if (wsComponent.resourceName) { - const [path17, query] = wsComponent.resourceName.split("?"); - wsComponent.path = path17 && path17 !== "/" ? path17 : void 0; + const [path18, query] = wsComponent.resourceName.split("?"); + wsComponent.path = path18 && path18 !== "/" ? path18 : void 0; wsComponent.query = query; wsComponent.resourceName = void 0; } @@ -14219,7 +14219,7 @@ var require_windows = __commonJS({ module2.exports = isexe; isexe.sync = sync; var fs = require("fs"); - function checkPathExt(path17, options) { + function checkPathExt(path18, options) { var pathext = options.pathExt !== void 0 ? options.pathExt : process.env.PATHEXT; if (!pathext) { return true; @@ -14230,25 +14230,25 @@ var require_windows = __commonJS({ } for (var i2 = 0; i2 < pathext.length; i2++) { var p = pathext[i2].toLowerCase(); - if (p && path17.substr(-p.length).toLowerCase() === p) { + if (p && path18.substr(-p.length).toLowerCase() === p) { return true; } } return false; } - function checkStat(stat, path17, options) { + function checkStat(stat, path18, options) { if (!stat.isSymbolicLink() && !stat.isFile()) { return false; } - return checkPathExt(path17, options); + return checkPathExt(path18, options); } - function isexe(path17, options, cb) { - fs.stat(path17, function(er, stat) { - cb(er, er ? false : checkStat(stat, path17, options)); + function isexe(path18, options, cb) { + fs.stat(path18, function(er, stat) { + cb(er, er ? false : checkStat(stat, path18, options)); }); } - function sync(path17, options) { - return checkStat(fs.statSync(path17), path17, options); + function sync(path18, options) { + return checkStat(fs.statSync(path18), path18, options); } } }); @@ -14260,13 +14260,13 @@ var require_mode = __commonJS({ module2.exports = isexe; isexe.sync = sync; var fs = require("fs"); - function isexe(path17, options, cb) { - fs.stat(path17, function(er, stat) { + function isexe(path18, options, cb) { + fs.stat(path18, function(er, stat) { cb(er, er ? false : checkStat(stat, options)); }); } - function sync(path17, options) { - return checkStat(fs.statSync(path17), options); + function sync(path18, options) { + return checkStat(fs.statSync(path18), options); } function checkStat(stat, options) { return stat.isFile() && checkMode(stat, options); @@ -14300,7 +14300,7 @@ var require_isexe = __commonJS({ } module2.exports = isexe; isexe.sync = sync; - function isexe(path17, options, cb) { + function isexe(path18, options, cb) { if (typeof options === "function") { cb = options; options = {}; @@ -14310,7 +14310,7 @@ var require_isexe = __commonJS({ throw new TypeError("callback not provided"); } return new Promise(function(resolve, reject) { - isexe(path17, options || {}, function(er, is) { + isexe(path18, options || {}, function(er, is) { if (er) { reject(er); } else { @@ -14319,7 +14319,7 @@ var require_isexe = __commonJS({ }); }); } - core(path17, options || {}, function(er, is) { + core(path18, options || {}, function(er, is) { if (er) { if (er.code === "EACCES" || options && options.ignoreErrors) { er = null; @@ -14329,9 +14329,9 @@ var require_isexe = __commonJS({ cb(er, is); }); } - function sync(path17, options) { + function sync(path18, options) { try { - return core.sync(path17, options || {}); + return core.sync(path18, options || {}); } catch (er) { if (options && options.ignoreErrors || er.code === "EACCES") { return false; @@ -14348,7 +14348,7 @@ var require_which = __commonJS({ "../../node_modules/.pnpm/which@2.0.2/node_modules/which/which.js"(exports2, module2) { "use strict"; var isWindows = process.platform === "win32" || process.env.OSTYPE === "cygwin" || process.env.OSTYPE === "msys"; - var path17 = require("path"); + var path18 = require("path"); var COLON = isWindows ? ";" : ":"; var isexe = require_isexe(); var getNotFoundError = (cmd) => Object.assign(new Error(`not found: ${cmd}`), { code: "ENOENT" }); @@ -14386,7 +14386,7 @@ var require_which = __commonJS({ return opt.all && found.length ? resolve(found) : reject(getNotFoundError(cmd)); const ppRaw = pathEnv[i2]; const pathPart = /^".*"$/.test(ppRaw) ? ppRaw.slice(1, -1) : ppRaw; - const pCmd = path17.join(pathPart, cmd); + const pCmd = path18.join(pathPart, cmd); const p = !pathPart && /^\.[\\\/]/.test(cmd) ? cmd.slice(0, 2) + pCmd : pCmd; resolve(subStep(p, i2, 0)); }); @@ -14413,7 +14413,7 @@ var require_which = __commonJS({ for (let i2 = 0; i2 < pathEnv.length; i2++) { const ppRaw = pathEnv[i2]; const pathPart = /^".*"$/.test(ppRaw) ? ppRaw.slice(1, -1) : ppRaw; - const pCmd = path17.join(pathPart, cmd); + const pCmd = path18.join(pathPart, cmd); const p = !pathPart && /^\.[\\\/]/.test(cmd) ? cmd.slice(0, 2) + pCmd : pCmd; for (let j = 0; j < pathExt.length; j++) { const cur = p + pathExt[j]; @@ -14461,7 +14461,7 @@ var require_path_key = __commonJS({ var require_resolveCommand = __commonJS({ "../../node_modules/.pnpm/cross-spawn@7.0.6/node_modules/cross-spawn/lib/util/resolveCommand.js"(exports2, module2) { "use strict"; - var path17 = require("path"); + var path18 = require("path"); var which = require_which(); var getPathKey = require_path_key(); function resolveCommandAttempt(parsed, withoutPathExt) { @@ -14479,7 +14479,7 @@ var require_resolveCommand = __commonJS({ try { resolved = which.sync(parsed.command, { path: env[getPathKey({ env })], - pathExt: withoutPathExt ? path17.delimiter : void 0 + pathExt: withoutPathExt ? path18.delimiter : void 0 }); } catch (e) { } finally { @@ -14488,7 +14488,7 @@ var require_resolveCommand = __commonJS({ } } if (resolved) { - resolved = path17.resolve(hasCustomCwd ? parsed.options.cwd : "", resolved); + resolved = path18.resolve(hasCustomCwd ? parsed.options.cwd : "", resolved); } return resolved; } @@ -14542,8 +14542,8 @@ var require_shebang_command = __commonJS({ if (!match) { return null; } - const [path17, argument] = match[0].replace(/#! ?/, "").split(" "); - const binary = path17.split("/").pop(); + const [path18, argument] = match[0].replace(/#! ?/, "").split(" "); + const binary = path18.split("/").pop(); if (binary === "env") { return argument; } @@ -14578,7 +14578,7 @@ var require_readShebang = __commonJS({ var require_parse = __commonJS({ "../../node_modules/.pnpm/cross-spawn@7.0.6/node_modules/cross-spawn/lib/parse.js"(exports2, module2) { "use strict"; - var path17 = require("path"); + var path18 = require("path"); var resolveCommand = require_resolveCommand(); var escape2 = require_escape(); var readShebang = require_readShebang(); @@ -14603,7 +14603,7 @@ var require_parse = __commonJS({ const needsShell = !isExecutableRegExp.test(commandFile); if (parsed.options.forceShell || needsShell) { const needsDoubleEscapeMetaChars = isCmdShimRegExp.test(commandFile); - parsed.command = path17.normalize(parsed.command); + parsed.command = path18.normalize(parsed.command); parsed.command = escape2.command(parsed.command); parsed.args = parsed.args.map((arg) => escape2.argument(arg, needsDoubleEscapeMetaChars)); const shellCommand = [parsed.command].concat(parsed.args).join(" "); @@ -14968,8 +14968,8 @@ var require_utils2 = __commonJS({ } return output; }; - exports2.basename = (path17, { windows } = {}) => { - const segs = path17.split(windows ? /[\\/]/ : "/"); + exports2.basename = (path18, { windows } = {}) => { + const segs = path18.split(windows ? /[\\/]/ : "/"); const last = segs[segs.length - 1]; if (last === "") { return segs[segs.length - 2]; @@ -16714,10 +16714,10 @@ function assignProp(target, prop, value) { configurable: true }); } -function getElementAtPath(obj, path17) { - if (!path17) +function getElementAtPath(obj, path18) { + if (!path18) return obj; - return path17.reduce((acc, key) => acc?.[key], obj); + return path18.reduce((acc, key) => acc?.[key], obj); } function promiseAllObject(promisesObj) { const keys = Object.keys(promisesObj); @@ -17037,11 +17037,11 @@ function aborted(x, startIndex = 0) { } return false; } -function prefixIssues(path17, issues) { +function prefixIssues(path18, issues) { return issues.map((iss) => { var _a; (_a = iss).path ?? (_a.path = []); - iss.path.unshift(path17); + iss.path.unshift(path18); return iss; }); } @@ -20431,7 +20431,7 @@ var ZodType = /* @__PURE__ */ $constructor("ZodType", (inst, def) => { inst.parseAsync = async (data, params) => parseAsync2(inst, data, params, { callee: inst.parseAsync }); inst.safeParseAsync = async (data, params) => safeParseAsync2(inst, data, params); inst.spa = inst.safeParseAsync; - inst.refine = (check2, params) => inst.check(refine(check2, params)); + inst.refine = (check3, params) => inst.check(refine(check3, params)); inst.superRefine = (refinement) => inst.check(superRefine(refinement)); inst.overwrite = (fn) => inst.check(_overwrite(fn)); inst.optional = () => optional(inst); @@ -23129,8 +23129,8 @@ function getErrorMap() { // ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.js var makeIssue = (params) => { - const { data, path: path17, errorMaps, issueData } = params; - const fullPath = [...path17, ...issueData.path || []]; + const { data, path: path18, errorMaps, issueData } = params; + const fullPath = [...path18, ...issueData.path || []]; const fullIssue = { ...issueData, path: fullPath @@ -23246,11 +23246,11 @@ var errorUtil; // ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.js var ParseInputLazyPath = class { - constructor(parent, value, path17, key) { + constructor(parent, value, path18, key) { this._cachedPath = []; this.parent = parent; this.data = value; - this._path = path17; + this._path = path18; this._key = key; } get path() { @@ -23428,7 +23428,7 @@ var ZodType2 = class { const result = await (isAsync(maybeAsyncResult) ? maybeAsyncResult : Promise.resolve(maybeAsyncResult)); return handleResult(ctx, result); } - refine(check2, message) { + refine(check3, message) { const getIssueProperties = (val) => { if (typeof message === "string" || typeof message === "undefined") { return { message }; @@ -23439,7 +23439,7 @@ var ZodType2 = class { } }; return this._refinement((val, ctx) => { - const result = check2(val); + const result = check3(val); const setError = () => ctx.addIssue({ code: ZodIssueCode.custom, ...getIssueProperties(val) @@ -23462,9 +23462,9 @@ var ZodType2 = class { } }); } - refinement(check2, refinementData) { + refinement(check3, refinementData) { return this._refinement((val, ctx) => { - if (!check2(val)) { + if (!check3(val)) { ctx.addIssue(typeof refinementData === "function" ? refinementData(val, ctx) : refinementData); return false; } else { @@ -23686,70 +23686,70 @@ var ZodString2 = class _ZodString2 extends ZodType2 { } const status = new ParseStatus(); let ctx = void 0; - for (const check2 of this._def.checks) { - if (check2.kind === "min") { - if (input.data.length < check2.value) { + for (const check3 of this._def.checks) { + if (check3.kind === "min") { + if (input.data.length < check3.value) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_small, - minimum: check2.value, + minimum: check3.value, type: "string", inclusive: true, exact: false, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "max") { - if (input.data.length > check2.value) { + } else if (check3.kind === "max") { + if (input.data.length > check3.value) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_big, - maximum: check2.value, + maximum: check3.value, type: "string", inclusive: true, exact: false, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "length") { - const tooBig = input.data.length > check2.value; - const tooSmall = input.data.length < check2.value; + } else if (check3.kind === "length") { + const tooBig = input.data.length > check3.value; + const tooSmall = input.data.length < check3.value; if (tooBig || tooSmall) { ctx = this._getOrReturnCtx(input, ctx); if (tooBig) { addIssueToContext(ctx, { code: ZodIssueCode.too_big, - maximum: check2.value, + maximum: check3.value, type: "string", inclusive: true, exact: true, - message: check2.message + message: check3.message }); } else if (tooSmall) { addIssueToContext(ctx, { code: ZodIssueCode.too_small, - minimum: check2.value, + minimum: check3.value, type: "string", inclusive: true, exact: true, - message: check2.message + message: check3.message }); } status.dirty(); } - } else if (check2.kind === "email") { + } else if (check3.kind === "email") { if (!emailRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "email", code: ZodIssueCode.invalid_string, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "emoji") { + } else if (check3.kind === "emoji") { if (!emojiRegex) { emojiRegex = new RegExp(_emojiRegex, "u"); } @@ -23758,61 +23758,61 @@ var ZodString2 = class _ZodString2 extends ZodType2 { addIssueToContext(ctx, { validation: "emoji", code: ZodIssueCode.invalid_string, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "uuid") { + } else if (check3.kind === "uuid") { if (!uuidRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "uuid", code: ZodIssueCode.invalid_string, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "nanoid") { + } else if (check3.kind === "nanoid") { if (!nanoidRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "nanoid", code: ZodIssueCode.invalid_string, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "cuid") { + } else if (check3.kind === "cuid") { if (!cuidRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "cuid", code: ZodIssueCode.invalid_string, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "cuid2") { + } else if (check3.kind === "cuid2") { if (!cuid2Regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "cuid2", code: ZodIssueCode.invalid_string, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "ulid") { + } else if (check3.kind === "ulid") { if (!ulidRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "ulid", code: ZodIssueCode.invalid_string, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "url") { + } else if (check3.kind === "url") { try { new URL(input.data); } catch { @@ -23820,153 +23820,153 @@ var ZodString2 = class _ZodString2 extends ZodType2 { addIssueToContext(ctx, { validation: "url", code: ZodIssueCode.invalid_string, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "regex") { - check2.regex.lastIndex = 0; - const testResult = check2.regex.test(input.data); + } else if (check3.kind === "regex") { + check3.regex.lastIndex = 0; + const testResult = check3.regex.test(input.data); if (!testResult) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "regex", code: ZodIssueCode.invalid_string, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "trim") { + } else if (check3.kind === "trim") { input.data = input.data.trim(); - } else if (check2.kind === "includes") { - if (!input.data.includes(check2.value, check2.position)) { + } else if (check3.kind === "includes") { + if (!input.data.includes(check3.value, check3.position)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, - validation: { includes: check2.value, position: check2.position }, - message: check2.message + validation: { includes: check3.value, position: check3.position }, + message: check3.message }); status.dirty(); } - } else if (check2.kind === "toLowerCase") { + } else if (check3.kind === "toLowerCase") { input.data = input.data.toLowerCase(); - } else if (check2.kind === "toUpperCase") { + } else if (check3.kind === "toUpperCase") { input.data = input.data.toUpperCase(); - } else if (check2.kind === "startsWith") { - if (!input.data.startsWith(check2.value)) { + } else if (check3.kind === "startsWith") { + if (!input.data.startsWith(check3.value)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, - validation: { startsWith: check2.value }, - message: check2.message + validation: { startsWith: check3.value }, + message: check3.message }); status.dirty(); } - } else if (check2.kind === "endsWith") { - if (!input.data.endsWith(check2.value)) { + } else if (check3.kind === "endsWith") { + if (!input.data.endsWith(check3.value)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, - validation: { endsWith: check2.value }, - message: check2.message + validation: { endsWith: check3.value }, + message: check3.message }); status.dirty(); } - } else if (check2.kind === "datetime") { - const regex = datetimeRegex(check2); + } else if (check3.kind === "datetime") { + const regex = datetimeRegex(check3); if (!regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, validation: "datetime", - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "date") { + } else if (check3.kind === "date") { const regex = dateRegex; if (!regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, validation: "date", - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "time") { - const regex = timeRegex(check2); + } else if (check3.kind === "time") { + const regex = timeRegex(check3); if (!regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, validation: "time", - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "duration") { + } else if (check3.kind === "duration") { if (!durationRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "duration", code: ZodIssueCode.invalid_string, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "ip") { - if (!isValidIP(input.data, check2.version)) { + } else if (check3.kind === "ip") { + if (!isValidIP(input.data, check3.version)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "ip", code: ZodIssueCode.invalid_string, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "jwt") { - if (!isValidJWT2(input.data, check2.alg)) { + } else if (check3.kind === "jwt") { + if (!isValidJWT2(input.data, check3.alg)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "jwt", code: ZodIssueCode.invalid_string, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "cidr") { - if (!isValidCidr(input.data, check2.version)) { + } else if (check3.kind === "cidr") { + if (!isValidCidr(input.data, check3.version)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "cidr", code: ZodIssueCode.invalid_string, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "base64") { + } else if (check3.kind === "base64") { if (!base64Regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "base64", code: ZodIssueCode.invalid_string, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "base64url") { + } else if (check3.kind === "base64url") { if (!base64urlRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "base64url", code: ZodIssueCode.invalid_string, - message: check2.message + message: check3.message }); status.dirty(); } } else { - util.assertNever(check2); + util.assertNever(check3); } } return { status: status.value, value: input.data }; @@ -23978,10 +23978,10 @@ var ZodString2 = class _ZodString2 extends ZodType2 { ...errorUtil.errToObj(message) }); } - _addCheck(check2) { + _addCheck(check3) { return new _ZodString2({ ...this._def, - checks: [...this._def.checks, check2] + checks: [...this._def.checks, check3] }); } email(message) { @@ -24246,67 +24246,67 @@ var ZodNumber2 = class _ZodNumber extends ZodType2 { } let ctx = void 0; const status = new ParseStatus(); - for (const check2 of this._def.checks) { - if (check2.kind === "int") { + for (const check3 of this._def.checks) { + if (check3.kind === "int") { if (!util.isInteger(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: "integer", received: "float", - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "min") { - const tooSmall = check2.inclusive ? input.data < check2.value : input.data <= check2.value; + } else if (check3.kind === "min") { + const tooSmall = check3.inclusive ? input.data < check3.value : input.data <= check3.value; if (tooSmall) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_small, - minimum: check2.value, + minimum: check3.value, type: "number", - inclusive: check2.inclusive, + inclusive: check3.inclusive, exact: false, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "max") { - const tooBig = check2.inclusive ? input.data > check2.value : input.data >= check2.value; + } else if (check3.kind === "max") { + const tooBig = check3.inclusive ? input.data > check3.value : input.data >= check3.value; if (tooBig) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_big, - maximum: check2.value, + maximum: check3.value, type: "number", - inclusive: check2.inclusive, + inclusive: check3.inclusive, exact: false, - message: check2.message + message: check3.message }); status.dirty(); } - } else if (check2.kind === "multipleOf") { - if (floatSafeRemainder2(input.data, check2.value) !== 0) { + } else if (check3.kind === "multipleOf") { + if (floatSafeRemainder2(input.data, check3.value) !== 0) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.not_multiple_of, - multipleOf: check2.value, - message: check2.message + multipleOf: check3.value, + message: check3.message }); status.dirty(); } - } else if (check2.kind === "finite") { + } else if (check3.kind === "finite") { if (!Number.isFinite(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.not_finite, - message: check2.message + message: check3.message }); status.dirty(); } } else { - util.assertNever(check2); + util.assertNever(check3); } } return { status: status.value, value: input.data }; @@ -24337,10 +24337,10 @@ var ZodNumber2 = class _ZodNumber extends ZodType2 { ] }); } - _addCheck(check2) { + _addCheck(check3) { return new _ZodNumber({ ...this._def, - checks: [...this._def.checks, check2] + checks: [...this._def.checks, check3] }); } int(message) { @@ -24475,45 +24475,45 @@ var ZodBigInt = class _ZodBigInt extends ZodType2 { } let ctx = void 0; const status = new ParseStatus(); - for (const check2 of this._def.checks) { - if (check2.kind === "min") { - const tooSmall = check2.inclusive ? input.data < check2.value : input.data <= check2.value; + for (const check3 of this._def.checks) { + if (check3.kind === "min") { + const tooSmall = check3.inclusive ? input.data < check3.value : input.data <= check3.value; if (tooSmall) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_small, type: "bigint", - minimum: check2.value, - inclusive: check2.inclusive, - message: check2.message + minimum: check3.value, + inclusive: check3.inclusive, + message: check3.message }); status.dirty(); } - } else if (check2.kind === "max") { - const tooBig = check2.inclusive ? input.data > check2.value : input.data >= check2.value; + } else if (check3.kind === "max") { + const tooBig = check3.inclusive ? input.data > check3.value : input.data >= check3.value; if (tooBig) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_big, type: "bigint", - maximum: check2.value, - inclusive: check2.inclusive, - message: check2.message + maximum: check3.value, + inclusive: check3.inclusive, + message: check3.message }); status.dirty(); } - } else if (check2.kind === "multipleOf") { - if (input.data % check2.value !== BigInt(0)) { + } else if (check3.kind === "multipleOf") { + if (input.data % check3.value !== BigInt(0)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.not_multiple_of, - multipleOf: check2.value, - message: check2.message + multipleOf: check3.value, + message: check3.message }); status.dirty(); } } else { - util.assertNever(check2); + util.assertNever(check3); } } return { status: status.value, value: input.data }; @@ -24553,10 +24553,10 @@ var ZodBigInt = class _ZodBigInt extends ZodType2 { ] }); } - _addCheck(check2) { + _addCheck(check3) { return new _ZodBigInt({ ...this._def, - checks: [...this._def.checks, check2] + checks: [...this._def.checks, check3] }); } positive(message) { @@ -24676,35 +24676,35 @@ var ZodDate = class _ZodDate extends ZodType2 { } const status = new ParseStatus(); let ctx = void 0; - for (const check2 of this._def.checks) { - if (check2.kind === "min") { - if (input.data.getTime() < check2.value) { + for (const check3 of this._def.checks) { + if (check3.kind === "min") { + if (input.data.getTime() < check3.value) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_small, - message: check2.message, + message: check3.message, inclusive: true, exact: false, - minimum: check2.value, + minimum: check3.value, type: "date" }); status.dirty(); } - } else if (check2.kind === "max") { - if (input.data.getTime() > check2.value) { + } else if (check3.kind === "max") { + if (input.data.getTime() > check3.value) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_big, - message: check2.message, + message: check3.message, inclusive: true, exact: false, - maximum: check2.value, + maximum: check3.value, type: "date" }); status.dirty(); } } else { - util.assertNever(check2); + util.assertNever(check3); } } return { @@ -24712,10 +24712,10 @@ var ZodDate = class _ZodDate extends ZodType2 { value: new Date(input.data.getTime()) }; } - _addCheck(check2) { + _addCheck(check3) { return new _ZodDate({ ...this._def, - checks: [...this._def.checks, check2] + checks: [...this._def.checks, check3] }); } min(minDate, message) { @@ -26576,10 +26576,10 @@ function cleanParams(params, data) { const p2 = typeof p === "string" ? { message: p } : p; return p2; } -function custom2(check2, _params = {}, fatal) { - if (check2) +function custom2(check3, _params = {}, fatal) { + if (check3) return ZodAny.create().superRefine((data, ctx) => { - const r = check2(data); + const r = check3(data); if (r instanceof Promise) { return r.then((r2) => { if (!r2) { @@ -27161,6 +27161,59 @@ var TASK_RUNNER_REPORT_JSON_SCHEMA = { recommendedNextActions: { type: "array", items: { type: "string" } } } }; +var STAGE_RUNNER_REPORT_JSON_SCHEMA = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + additionalProperties: false, + required: ["schemaVersion", "stage", "markdown", "summary"], + properties: { + schemaVersion: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" }, + stage: { type: "string", enum: ["requirements", "bugfix", "design", "tasks"] }, + markdown: { type: "string", minLength: 1 }, + summary: { type: "string", minLength: 1 }, + assumptions: { type: "array", items: { type: "string" } }, + openQuestions: { type: "array", items: { type: "string" } }, + referencedFiles: { type: "array", items: { type: "string" } } + } +}; +function parseTaskRunnerReport(raw) { + return parseReport(raw, taskRunnerReportSchema); +} +function parseStageRunnerReport(raw) { + return parseReport(raw, stageRunnerReportSchema); +} +function parseReport(raw, schema) { + const candidate = extractJsonCandidate(raw); + if (candidate === void 0) { + return { ok: false, reason: "no JSON document found in the runner output" }; + } + let parsed; + try { + parsed = JSON.parse(candidate); + } catch (cause) { + return { + ok: false, + reason: `runner output is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}` + }; + } + const result = schema.safeParse(parsed); + if (!result.success) { + const issues = result.error.issues.map((issue2) => `${issue2.path.join(".") || "(root)"}: ${issue2.message}`).join("; "); + return { ok: false, reason: `runner output does not match the report schema: ${issues}` }; + } + return { ok: true, report: result.data }; +} +var FENCED_JSON = /```(?:json)?\s*\n([\s\S]*?)```/g; +function extractJsonCandidate(raw) { + const trimmed = raw.trim(); + if (trimmed.length === 0) return void 0; + if (trimmed.startsWith("{") || trimmed.startsWith("[")) return trimmed; + let lastBlock; + for (const match of trimmed.matchAll(FENCED_JSON)) { + if (match[1] !== void 0) lastBlock = match[1].trim(); + } + return lastBlock; +} var VERIFICATION_DIAGNOSTIC_SCHEMA_VERSION = "1.0.0"; var VERIFICATION_REPORT_SCHEMA_VERSION = "1.0.0"; var VERIFICATION_SEVERITIES = ["error", "warning", "info"]; @@ -27509,7 +27562,10 @@ var RUNNER_CONFIG_SCHEMA_VERSION = "2.0.0"; var BUILT_IN_PROFILE_NAMES = { "claude-code": "claude-code", "codex-cli": "codex-default", + "gemini-cli": "gemini-default", ollama: "ollama-local", + "openai-compatible": "openai-compatible-local", + "antigravity-cli": "antigravity", mock: "mock" }; var PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; @@ -27602,10 +27658,109 @@ var ollamaProfileSchema = external_exports.object({ var mockProfileSchema = mockRunnerConfigSchema.extend({ runner: external_exports.literal("mock") }); +var GEMINI_AUTHORING_APPROVAL_MODES = ["plan"]; +var GEMINI_EXECUTION_APPROVAL_MODES = ["auto_edit", "default"]; +var geminiProfileSchema = external_exports.object({ + runner: external_exports.literal("gemini-cli"), + enabled: external_exports.boolean().default(false), + command: commandSpecSchema.default({ executable: "gemini", args: [] }), + model: safeNonEmptyString.nullable().default(null), + /** Authoring is always read-only; only plan mode is accepted. */ + approvalModeForAuthoring: external_exports.enum(GEMINI_AUTHORING_APPROVAL_MODES).default("plan"), + /** Task execution may auto-approve EDITS only — never shell commands. */ + approvalModeForExecution: external_exports.enum(GEMINI_EXECUTION_APPROVAL_MODES).default("auto_edit"), + /** Pass --sandbox when the installed CLI supports it. */ + sandbox: external_exports.boolean().default(true), + /** + * Extra tools to allow during task execution, on top of the adapter's + * bounded read/edit set. Shell-execution tools are rejected. + */ + allowedTools: external_exports.array(safeNonEmptyString).default([]).refine( + (tools) => tools.every((tool) => !/^(run_shell_command|shell|bash|execute_command|terminal)$/i.test(tool)), + { + message: "shell-execution tools cannot be allowed: SpecBridge never grants the Gemini CLI arbitrary shell access" + } + ), + /** Pass the extension-restriction flag when supported (default on). */ + disabledExtensions: external_exports.boolean().default(true), + timeoutMs: external_exports.number().int().min(1e3).max(864e5).default(18e5), + maxStdoutBytes: external_exports.number().int().min(1024).default(10 * 1024 * 1024), + maxStderrBytes: external_exports.number().int().min(1024).default(1024 * 1024) +}).passthrough(); +var OPENAI_COMPATIBLE_API_STYLES = ["chat-completions", "responses"]; +var OPENAI_COMPATIBLE_STRUCTURED_OUTPUT_MODES = [ + "json-schema", + "json-object", + "strict-json-prompt" +]; +var environmentVariableNameSchema = external_exports.string().regex( + /^[A-Za-z_][A-Za-z0-9_]*$/, + "must be an environment-variable NAME (letters, digits, underscore); SpecBridge never stores key values" +); +var FORBIDDEN_HEADER_NAME_PATTERN = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key|api-key|x-auth-token)$/i; +var safeHeadersSchema = external_exports.record(external_exports.string().max(1024)).superRefine((headers, ctx) => { + for (const [name, value] of Object.entries(headers)) { + if (!/^[A-Za-z0-9-]+$/.test(name)) { + ctx.addIssue({ + code: external_exports.ZodIssueCode.custom, + message: `header name "${name}" is invalid (letters, digits, and "-" only)` + }); + } + if (FORBIDDEN_HEADER_NAME_PATTERN.test(name)) { + ctx.addIssue({ + code: external_exports.ZodIssueCode.custom, + message: `header "${name}" would carry a credential value. SpecBridge never stores credentials; use apiKeyEnvironmentVariable (a variable NAME) instead.` + }); + } + if (value.includes("\0") || value.includes("\n") || value.includes("\r")) { + ctx.addIssue({ + code: external_exports.ZodIssueCode.custom, + message: `header "${name}" contains control characters` + }); + } + } +}); +var openAiCompatibleProfileSchema = external_exports.object({ + runner: external_exports.literal("openai-compatible"), + enabled: external_exports.boolean().default(false), + baseUrl: safeNonEmptyString.default("http://127.0.0.1:8000/v1"), + apiStyle: external_exports.enum(OPENAI_COMPATIBLE_API_STYLES).default("chat-completions"), + model: safeNonEmptyString.nullable().default(null), + structuredOutput: external_exports.enum(OPENAI_COMPATIBLE_STRUCTURED_OUTPUT_MODES).default("json-schema"), + /** + * Explicit permission to fall back from the configured structured-output + * mode to the next weaker one when the endpoint rejects it. Off by + * default: an unsupported mode is an error, never a silent downgrade. + */ + allowStructuredOutputFallback: external_exports.boolean().default(false), + /** Name of the environment variable holding the API key (never a value). */ + apiKeyEnvironmentVariable: environmentVariableNameSchema.nullable().default(null), + /** Static capability declaration: the endpoint supports GET /models. */ + modelsEndpoint: external_exports.boolean().default(false), + /** Custom safe headers (credential-bearing header names are rejected). */ + headers: safeHeadersSchema.default({}), + temperature: external_exports.number().min(0).max(2).default(0), + timeoutMs: external_exports.number().int().min(1e3).max(864e5).default(3e5), + maximumInputCharacters: external_exports.number().int().min(1e3).default(5e5), + maximumOutputBytes: external_exports.number().int().min(1024).default(2097152), + /** Explicit development override for private plain-HTTP endpoints (INSECURE). */ + allowInsecureHttp: external_exports.boolean().default(false) +}).passthrough(); +var antigravityProfileSchema = external_exports.object({ + runner: external_exports.literal("antigravity-cli"), + enabled: external_exports.boolean().default(false), + command: commandSpecSchema.default({ executable: "agy", args: [] }), + /** Always true: the adapter is experimental and cannot be marked otherwise. */ + experimental: external_exports.literal(true).default(true), + timeoutMs: external_exports.number().int().min(1e3).max(6e5).default(3e4) +}).passthrough(); var runnerProfileSchema = external_exports.discriminatedUnion("runner", [ claudeProfileSchema, codexProfileSchema, + geminiProfileSchema, ollamaProfileSchema, + openAiCompatibleProfileSchema, + antigravityProfileSchema, mockProfileSchema ]); var runnerPolicySchema = external_exports.object({ @@ -27664,7 +27819,7 @@ var agentConfigV2Schema = external_exports.object({ } } for (const [name, profile] of Object.entries(config2.runnerProfiles)) { - if (profile.runner === "ollama") { + if (profile.runner === "ollama" || profile.runner === "openai-compatible") { const url = validateRunnerBaseUrl(profile.baseUrl, { allowInsecureHttp: profile.allowInsecureHttp }); @@ -27700,6 +27855,15 @@ function builtInCodexProfile(executable) { function builtInOllamaProfile() { return ollamaProfileSchema.parse({ runner: "ollama", enabled: false }); } +function builtInGeminiProfile() { + return geminiProfileSchema.parse({ runner: "gemini-cli", enabled: false }); +} +function builtInOpenAiCompatibleProfile() { + return openAiCompatibleProfileSchema.parse({ runner: "openai-compatible", enabled: false }); +} +function builtInAntigravityProfile() { + return antigravityProfileSchema.parse({ runner: "antigravity-cli", enabled: false }); +} function withBuiltInProfiles(profiles, options) { const result = {}; const add = (name, profile) => { @@ -27713,10 +27877,22 @@ function withBuiltInProfiles(profiles, options) { BUILT_IN_PROFILE_NAMES["codex-cli"], profiles[BUILT_IN_PROFILE_NAMES["codex-cli"]] ?? builtInCodexProfile(options?.codexExecutable) ); + add( + BUILT_IN_PROFILE_NAMES["gemini-cli"], + profiles[BUILT_IN_PROFILE_NAMES["gemini-cli"]] ?? builtInGeminiProfile() + ); add( BUILT_IN_PROFILE_NAMES.ollama, profiles[BUILT_IN_PROFILE_NAMES.ollama] ?? builtInOllamaProfile() ); + add( + BUILT_IN_PROFILE_NAMES["openai-compatible"], + profiles[BUILT_IN_PROFILE_NAMES["openai-compatible"]] ?? builtInOpenAiCompatibleProfile() + ); + add( + BUILT_IN_PROFILE_NAMES["antigravity-cli"], + profiles[BUILT_IN_PROFILE_NAMES["antigravity-cli"]] ?? builtInAntigravityProfile() + ); add(BUILT_IN_PROFILE_NAMES.mock, profiles[BUILT_IN_PROFILE_NAMES.mock] ?? builtInMockProfile()); for (const [name, profile] of Object.entries(profiles)) add(name, profile); return result; @@ -29407,35 +29583,35 @@ function extractPathReferences(document) { for (const match of text.matchAll(BACKTICK_SPAN)) { const raw = match[1]; if (raw === void 0) continue; - const path53 = normalizePathCandidate(raw); - if (path53 === void 0) continue; - const key = `${path53} ${i2}`; + const path54 = normalizePathCandidate(raw); + if (path54 === void 0) continue; + const key = `${path54} ${i2}`; if (seen.has(key)) continue; seen.add(key); references.push({ raw, - path: path53, + path: path54, line: i2, method: "backtick-path", confidence: "deterministic", - isGlob: GLOB_CHARS.test(path53) + isGlob: GLOB_CHARS.test(path54) }); } for (const match of text.matchAll(MARKDOWN_LINK)) { const raw = match[1]; if (raw === void 0) continue; - const path53 = normalizePathCandidate(raw); - if (path53 === void 0) continue; - const key = `${path53} ${i2}`; + const path54 = normalizePathCandidate(raw); + if (path54 === void 0) continue; + const key = `${path54} ${i2}`; if (seen.has(key)) continue; seen.add(key); references.push({ raw, - path: path53, + path: path54, line: i2, method: "markdown-link", confidence: "deterministic", - isGlob: GLOB_CHARS.test(path53) + isGlob: GLOB_CHARS.test(path54) }); } } @@ -29994,38 +30170,38 @@ function parseBigintDef(def, refs) { }; if (!def.checks) return res; - for (const check2 of def.checks) { - switch (check2.kind) { + for (const check3 of def.checks) { + switch (check3.kind) { case "min": if (refs.target === "jsonSchema7") { - if (check2.inclusive) { - setResponseValueAndErrors(res, "minimum", check2.value, check2.message, refs); + if (check3.inclusive) { + setResponseValueAndErrors(res, "minimum", check3.value, check3.message, refs); } else { - setResponseValueAndErrors(res, "exclusiveMinimum", check2.value, check2.message, refs); + setResponseValueAndErrors(res, "exclusiveMinimum", check3.value, check3.message, refs); } } else { - if (!check2.inclusive) { + if (!check3.inclusive) { res.exclusiveMinimum = true; } - setResponseValueAndErrors(res, "minimum", check2.value, check2.message, refs); + setResponseValueAndErrors(res, "minimum", check3.value, check3.message, refs); } break; case "max": if (refs.target === "jsonSchema7") { - if (check2.inclusive) { - setResponseValueAndErrors(res, "maximum", check2.value, check2.message, refs); + if (check3.inclusive) { + setResponseValueAndErrors(res, "maximum", check3.value, check3.message, refs); } else { - setResponseValueAndErrors(res, "exclusiveMaximum", check2.value, check2.message, refs); + setResponseValueAndErrors(res, "exclusiveMaximum", check3.value, check3.message, refs); } } else { - if (!check2.inclusive) { + if (!check3.inclusive) { res.exclusiveMaximum = true; } - setResponseValueAndErrors(res, "maximum", check2.value, check2.message, refs); + setResponseValueAndErrors(res, "maximum", check3.value, check3.message, refs); } break; case "multipleOf": - setResponseValueAndErrors(res, "multipleOf", check2.value, check2.message, refs); + setResponseValueAndErrors(res, "multipleOf", check3.value, check3.message, refs); break; } } @@ -30081,15 +30257,15 @@ var integerDateParser = (def, refs) => { if (refs.target === "openApi3") { return res; } - for (const check2 of def.checks) { - switch (check2.kind) { + for (const check3 of def.checks) { + switch (check3.kind) { case "min": setResponseValueAndErrors( res, "minimum", - check2.value, + check3.value, // This is in milliseconds - check2.message, + check3.message, refs ); break; @@ -30097,9 +30273,9 @@ var integerDateParser = (def, refs) => { setResponseValueAndErrors( res, "maximum", - check2.value, + check3.value, // This is in milliseconds - check2.message, + check3.message, refs ); break; @@ -30245,118 +30421,118 @@ function parseStringDef(def, refs) { type: "string" }; if (def.checks) { - for (const check2 of def.checks) { - switch (check2.kind) { + for (const check3 of def.checks) { + switch (check3.kind) { case "min": - setResponseValueAndErrors(res, "minLength", typeof res.minLength === "number" ? Math.max(res.minLength, check2.value) : check2.value, check2.message, refs); + setResponseValueAndErrors(res, "minLength", typeof res.minLength === "number" ? Math.max(res.minLength, check3.value) : check3.value, check3.message, refs); break; case "max": - setResponseValueAndErrors(res, "maxLength", typeof res.maxLength === "number" ? Math.min(res.maxLength, check2.value) : check2.value, check2.message, refs); + setResponseValueAndErrors(res, "maxLength", typeof res.maxLength === "number" ? Math.min(res.maxLength, check3.value) : check3.value, check3.message, refs); break; case "email": switch (refs.emailStrategy) { case "format:email": - addFormat(res, "email", check2.message, refs); + addFormat(res, "email", check3.message, refs); break; case "format:idn-email": - addFormat(res, "idn-email", check2.message, refs); + addFormat(res, "idn-email", check3.message, refs); break; case "pattern:zod": - addPattern(res, zodPatterns.email, check2.message, refs); + addPattern(res, zodPatterns.email, check3.message, refs); break; } break; case "url": - addFormat(res, "uri", check2.message, refs); + addFormat(res, "uri", check3.message, refs); break; case "uuid": - addFormat(res, "uuid", check2.message, refs); + addFormat(res, "uuid", check3.message, refs); break; case "regex": - addPattern(res, check2.regex, check2.message, refs); + addPattern(res, check3.regex, check3.message, refs); break; case "cuid": - addPattern(res, zodPatterns.cuid, check2.message, refs); + addPattern(res, zodPatterns.cuid, check3.message, refs); break; case "cuid2": - addPattern(res, zodPatterns.cuid2, check2.message, refs); + addPattern(res, zodPatterns.cuid2, check3.message, refs); break; case "startsWith": - addPattern(res, RegExp(`^${escapeLiteralCheckValue(check2.value, refs)}`), check2.message, refs); + addPattern(res, RegExp(`^${escapeLiteralCheckValue(check3.value, refs)}`), check3.message, refs); break; case "endsWith": - addPattern(res, RegExp(`${escapeLiteralCheckValue(check2.value, refs)}$`), check2.message, refs); + addPattern(res, RegExp(`${escapeLiteralCheckValue(check3.value, refs)}$`), check3.message, refs); break; case "datetime": - addFormat(res, "date-time", check2.message, refs); + addFormat(res, "date-time", check3.message, refs); break; case "date": - addFormat(res, "date", check2.message, refs); + addFormat(res, "date", check3.message, refs); break; case "time": - addFormat(res, "time", check2.message, refs); + addFormat(res, "time", check3.message, refs); break; case "duration": - addFormat(res, "duration", check2.message, refs); + addFormat(res, "duration", check3.message, refs); break; case "length": - setResponseValueAndErrors(res, "minLength", typeof res.minLength === "number" ? Math.max(res.minLength, check2.value) : check2.value, check2.message, refs); - setResponseValueAndErrors(res, "maxLength", typeof res.maxLength === "number" ? Math.min(res.maxLength, check2.value) : check2.value, check2.message, refs); + setResponseValueAndErrors(res, "minLength", typeof res.minLength === "number" ? Math.max(res.minLength, check3.value) : check3.value, check3.message, refs); + setResponseValueAndErrors(res, "maxLength", typeof res.maxLength === "number" ? Math.min(res.maxLength, check3.value) : check3.value, check3.message, refs); break; case "includes": { - addPattern(res, RegExp(escapeLiteralCheckValue(check2.value, refs)), check2.message, refs); + addPattern(res, RegExp(escapeLiteralCheckValue(check3.value, refs)), check3.message, refs); break; } case "ip": { - if (check2.version !== "v6") { - addFormat(res, "ipv4", check2.message, refs); + if (check3.version !== "v6") { + addFormat(res, "ipv4", check3.message, refs); } - if (check2.version !== "v4") { - addFormat(res, "ipv6", check2.message, refs); + if (check3.version !== "v4") { + addFormat(res, "ipv6", check3.message, refs); } break; } case "base64url": - addPattern(res, zodPatterns.base64url, check2.message, refs); + addPattern(res, zodPatterns.base64url, check3.message, refs); break; case "jwt": - addPattern(res, zodPatterns.jwt, check2.message, refs); + addPattern(res, zodPatterns.jwt, check3.message, refs); break; case "cidr": { - if (check2.version !== "v6") { - addPattern(res, zodPatterns.ipv4Cidr, check2.message, refs); + if (check3.version !== "v6") { + addPattern(res, zodPatterns.ipv4Cidr, check3.message, refs); } - if (check2.version !== "v4") { - addPattern(res, zodPatterns.ipv6Cidr, check2.message, refs); + if (check3.version !== "v4") { + addPattern(res, zodPatterns.ipv6Cidr, check3.message, refs); } break; } case "emoji": - addPattern(res, zodPatterns.emoji(), check2.message, refs); + addPattern(res, zodPatterns.emoji(), check3.message, refs); break; case "ulid": { - addPattern(res, zodPatterns.ulid, check2.message, refs); + addPattern(res, zodPatterns.ulid, check3.message, refs); break; } case "base64": { switch (refs.base64Strategy) { case "format:binary": { - addFormat(res, "binary", check2.message, refs); + addFormat(res, "binary", check3.message, refs); break; } case "contentEncoding:base64": { - setResponseValueAndErrors(res, "contentEncoding", "base64", check2.message, refs); + setResponseValueAndErrors(res, "contentEncoding", "base64", check3.message, refs); break; } case "pattern:zod": { - addPattern(res, zodPatterns.base64, check2.message, refs); + addPattern(res, zodPatterns.base64, check3.message, refs); break; } } break; } case "nanoid": { - addPattern(res, zodPatterns.nanoid, check2.message, refs); + addPattern(res, zodPatterns.nanoid, check3.message, refs); } case "toLowerCase": case "toUpperCase": @@ -30364,7 +30540,7 @@ function parseStringDef(def, refs) { break; default: /* @__PURE__ */ ((_) => { - })(check2); + })(check3); } } } @@ -30734,42 +30910,42 @@ function parseNumberDef(def, refs) { }; if (!def.checks) return res; - for (const check2 of def.checks) { - switch (check2.kind) { + for (const check3 of def.checks) { + switch (check3.kind) { case "int": res.type = "integer"; - addErrorMessage(res, "type", check2.message, refs); + addErrorMessage(res, "type", check3.message, refs); break; case "min": if (refs.target === "jsonSchema7") { - if (check2.inclusive) { - setResponseValueAndErrors(res, "minimum", check2.value, check2.message, refs); + if (check3.inclusive) { + setResponseValueAndErrors(res, "minimum", check3.value, check3.message, refs); } else { - setResponseValueAndErrors(res, "exclusiveMinimum", check2.value, check2.message, refs); + setResponseValueAndErrors(res, "exclusiveMinimum", check3.value, check3.message, refs); } } else { - if (!check2.inclusive) { + if (!check3.inclusive) { res.exclusiveMinimum = true; } - setResponseValueAndErrors(res, "minimum", check2.value, check2.message, refs); + setResponseValueAndErrors(res, "minimum", check3.value, check3.message, refs); } break; case "max": if (refs.target === "jsonSchema7") { - if (check2.inclusive) { - setResponseValueAndErrors(res, "maximum", check2.value, check2.message, refs); + if (check3.inclusive) { + setResponseValueAndErrors(res, "maximum", check3.value, check3.message, refs); } else { - setResponseValueAndErrors(res, "exclusiveMaximum", check2.value, check2.message, refs); + setResponseValueAndErrors(res, "exclusiveMaximum", check3.value, check3.message, refs); } } else { - if (!check2.inclusive) { + if (!check3.inclusive) { res.exclusiveMaximum = true; } - setResponseValueAndErrors(res, "maximum", check2.value, check2.message, refs); + setResponseValueAndErrors(res, "maximum", check3.value, check3.message, refs); } break; case "multipleOf": - setResponseValueAndErrors(res, "multipleOf", check2.value, check2.message, refs); + setResponseValueAndErrors(res, "multipleOf", check3.value, check3.message, refs); break; } } @@ -33704,13 +33880,13 @@ var McpServer = class { } return registeredPrompt; } - _createRegisteredTool(name, title, description, inputSchema17, outputSchema22, annotations, execution, _meta, handler) { + _createRegisteredTool(name, title, description, inputSchema20, outputSchema26, annotations, execution, _meta, handler) { validateAndWarnToolName(name); const registeredTool = { title, description, - inputSchema: getZodSchemaObject(inputSchema17), - outputSchema: getZodSchemaObject(outputSchema22), + inputSchema: getZodSchemaObject(inputSchema20), + outputSchema: getZodSchemaObject(outputSchema26), annotations, execution, _meta, @@ -33760,8 +33936,8 @@ var McpServer = class { throw new Error(`Tool ${name} is already registered`); } let description; - let inputSchema17; - let outputSchema22; + let inputSchema20; + let outputSchema26; let annotations; if (typeof rest[0] === "string") { description = rest.shift(); @@ -33769,7 +33945,7 @@ var McpServer = class { if (rest.length > 1) { const firstArg = rest[0]; if (isZodRawShapeCompat(firstArg)) { - inputSchema17 = rest.shift(); + inputSchema20 = rest.shift(); if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !isZodRawShapeCompat(rest[0])) { annotations = rest.shift(); } @@ -33781,7 +33957,7 @@ var McpServer = class { } } const callback = rest[0]; - return this._createRegisteredTool(name, void 0, description, inputSchema17, outputSchema22, annotations, { taskSupport: "forbidden" }, void 0, callback); + return this._createRegisteredTool(name, void 0, description, inputSchema20, outputSchema26, annotations, { taskSupport: "forbidden" }, void 0, callback); } /** * Registers a tool with a config object and callback. @@ -33790,8 +33966,8 @@ var McpServer = class { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); } - const { title, description, inputSchema: inputSchema17, outputSchema: outputSchema22, annotations, _meta } = config2; - return this._createRegisteredTool(name, title, description, inputSchema17, outputSchema22, annotations, { taskSupport: "forbidden" }, _meta, cb); + const { title, description, inputSchema: inputSchema20, outputSchema: outputSchema26, annotations, _meta } = config2; + return this._createRegisteredTool(name, title, description, inputSchema20, outputSchema26, annotations, { taskSupport: "forbidden" }, _meta, cb); } prompt(name, ...rest) { if (this._registeredPrompts[name]) { @@ -34123,9 +34299,9 @@ function registerAllPrompts(server, context) { } // ../../packages/evidence/dist/index.js -var import_crypto2 = require("crypto"); -var import_fs10 = require("fs"); -var import_path9 = __toESM(require("path"), 1); +var import_crypto4 = require("crypto"); +var import_fs14 = require("fs"); +var import_path13 = __toESM(require("path"), 1); // ../../packages/runners/dist/index.js var import_buffer = require("buffer"); @@ -38513,13 +38689,13 @@ var logOutputSync = ({ serializedResult, fdNumber, state, verboseInfo, encoding, } }; var writeToFiles = (serializedResult, stdioItems, outputFiles) => { - for (const { path: path17, append } of stdioItems.filter(({ type }) => FILE_TYPES.has(type))) { - const pathString = typeof path17 === "string" ? path17 : path17.toString(); + for (const { path: path18, append } of stdioItems.filter(({ type }) => FILE_TYPES.has(type))) { + const pathString = typeof path18 === "string" ? path18 : path18.toString(); if (append || outputFiles.has(pathString)) { - (0, import_node_fs5.appendFileSync)(path17, serializedResult); + (0, import_node_fs5.appendFileSync)(path18, serializedResult); } else { outputFiles.add(pathString); - (0, import_node_fs5.writeFileSync)(path17, serializedResult); + (0, import_node_fs5.writeFileSync)(path18, serializedResult); } } }; @@ -40908,7 +41084,18 @@ var { } = getIpcExport(); // ../../packages/runners/dist/index.js +var import_crypto2 = require("crypto"); +var import_fs10 = require("fs"); +var import_path9 = __toESM(require("path"), 1); +var import_fs11 = require("fs"); +var import_path10 = __toESM(require("path"), 1); +var import_fs12 = require("fs"); +var import_path11 = __toESM(require("path"), 1); var import_buffer2 = require("buffer"); +var import_buffer3 = require("buffer"); +var import_crypto3 = require("crypto"); +var import_fs13 = require("fs"); +var import_path12 = __toESM(require("path"), 1); var DEFAULT_MAX_STDOUT_BYTES = 10 * 1024 * 1024; var DEFAULT_MAX_STDERR_BYTES = 1024 * 1024; function assertSafeToken(value, what) { @@ -41089,6 +41276,37 @@ function capabilitySet(enabled) { for (const key of enabled) set[key] = true; return set; } +function missingCapabilities(required2, available) { + return required2.filter((key) => !available[key]); +} +function effectiveSupportLevel(declared, status) { + switch (status) { + case "available": + case "unauthenticated": + case "misconfigured": + return declared; + case "unavailable": + case "error": + return "unavailable"; + case "incompatible": + return "incompatible"; + } +} +function digest(...parts) { + const hash = (0, import_crypto2.createHash)("sha256"); + for (const part of parts) hash.update(part).update("\0"); + return hash.digest("hex").slice(0, 12); +} +var MOCK_CAPABILITIES = [ + { id: "non-interactive", label: "Non-interactive print mode", required: true }, + { id: "json-output", label: "JSON output", required: true }, + { id: "structured-output", label: "Structured output", required: false }, + { id: "session-id", label: "Session IDs", required: false }, + { id: "resume", label: "Resume support", required: false }, + { id: "tool-restriction", label: "Tool restrictions", required: true }, + { id: "permission-modes", label: "Permission modes", required: true }, + { id: "max-turns", label: "Maximum turn limit", required: true } +]; var MOCK_CAPABILITY_SET = capabilitySet([ "stageGeneration", "stageRefinement", @@ -41104,6 +41322,470 @@ var MOCK_CAPABILITY_SET = capabilitySet([ "supportsJsonSchema", "supportsCancellation" ]); +var MockRunner = class { + name = "mock"; + kind = "mock"; + category = "mock"; + declaredCapabilities = MOCK_CAPABILITY_SET; + config; + constructor(config2) { + this.config = mockRunnerConfigSchema.parse(config2 ?? {}); + } + get scenario() { + return this.config.scenario; + } + detect(_context) { + return Promise.resolve({ + runner: this.name, + kind: this.kind, + status: "available", + executable: "(in-process)", + version: "mock/0.3.0", + authentication: "not-applicable", + capabilities: MOCK_CAPABILITIES.map((capability) => ({ + ...capability, + available: true + })), + diagnostics: [ + { + severity: "info", + code: "MOCK_RUNNER", + message: `Deterministic offline mock runner (scenario: ${this.config.scenario}).` + } + ], + category: this.category, + capabilitySet: this.declaredCapabilities, + supportLevel: "production", + networkBacked: false + }); + } + executionBoundaryNote(_policy) { + return "Mock runner: deterministic in-process scenarios; no external process, no network."; + } + selfTest(_execution) { + return Promise.resolve({ + ok: true, + detail: `mock structured-output self test passed (scenario: ${this.config.scenario})` + }); + } + generateStage(input, _execution) { + const scenario = this.config.scenario; + const base = { + runner: this.name, + rawStderr: scenario === "stderr-noise" ? "mock: simulated stderr diagnostics\n" : "", + durationMs: 0, + warnings: [] + }; + switch (scenario) { + case "malformed-output": + return Promise.resolve({ + ...base, + outcome: "malformed-output", + failureReason: 'runner output is not valid JSON (mock scenario "malformed-output")', + rawStdout: "this is not a JSON document {" + }); + case "timeout": + return Promise.resolve({ + ...base, + outcome: "timed-out", + failureReason: 'mock scenario "timeout": the simulated agent exceeded its time limit', + rawStdout: "" + }); + case "cancelled": + return Promise.resolve({ + ...base, + outcome: "cancelled", + failureReason: 'mock scenario "cancelled": the simulated run was cancelled', + rawStdout: "" + }); + case "permission-denied": + return Promise.resolve({ + ...base, + outcome: "permission-denied", + failureReason: 'mock scenario "permission-denied": the simulated agent was denied a tool permission', + rawStdout: "" + }); + case "failed": + return Promise.resolve({ + ...base, + outcome: "failed", + failureReason: 'mock scenario "failed": the simulated agent reported a failure', + rawStdout: "" + }); + case "blocked": { + const report = { + schemaVersion: RUNNER_OUTPUT_SCHEMA_VERSION, + stage: input.stage, + markdown: `# Blocked + +Generation blocked (mock scenario). +`, + summary: 'Blocked: required input is missing (mock scenario "blocked").', + assumptions: [], + openQuestions: ["What should happen when the upstream service is unavailable?"], + referencedFiles: [] + }; + return Promise.resolve({ + ...base, + outcome: "blocked", + failureReason: 'mock scenario "blocked": the simulated agent reported open questions', + report, + rawStdout: `${JSON.stringify(report, null, 2)} +` + }); + } + default: { + const markdown = scenario === "invalid-markdown" ? invalidStageMarkdown(input.stage) : validStageMarkdown(input.stage, input.specName, digest(input.prompt)); + const report = { + schemaVersion: RUNNER_OUTPUT_SCHEMA_VERSION, + stage: input.stage, + markdown, + summary: `Mock ${input.intent === "refine" ? "refinement" : "generation"} of the ${input.stage} stage for "${input.specName}".`, + assumptions: ["Mock content is deterministic and produced without a model."], + openQuestions: [], + referencedFiles: [] + }; + return Promise.resolve({ + ...base, + outcome: "completed", + report, + rawStdout: `${JSON.stringify(report, null, 2)} +`, + sessionId: `mock-session-${digest(input.specName, input.stage, input.prompt)}` + }); + } + } + } + executeTask(input, execution) { + return Promise.resolve(this.runTaskScenario(input.specName, input.taskId, execution, false)); + } + resumeTask(input, execution) { + if (this.config.scenario === "resume-failure") { + return Promise.resolve({ + runner: this.name, + outcome: "failed", + failureReason: 'mock scenario "resume-failure": the resumed session failed again', + rawStdout: "", + rawStderr: "", + sessionId: input.sessionId, + resumeSupported: true, + durationMs: 0, + warnings: [] + }); + } + const result = this.runTaskScenario(input.specName, input.taskId, execution, true); + return Promise.resolve({ ...result, sessionId: input.sessionId }); + } + runTaskScenario(specName, taskId, execution, resumed) { + const scenario = this.config.scenario; + const sessionId = `mock-session-${digest(specName, taskId)}`; + const base = { + runner: this.name, + rawStderr: scenario === "stderr-noise" ? "mock: simulated stderr diagnostics\n" : "", + sessionId, + resumeSupported: true, + durationMs: 0, + warnings: [] + }; + const failure = (outcome, reason) => ({ + ...base, + outcome, + failureReason: reason, + rawStdout: "" + }); + switch (scenario) { + case "malformed-output": + return { + ...base, + outcome: "malformed-output", + failureReason: 'runner output is not valid JSON (mock scenario "malformed-output")', + rawStdout: '{"outcome": "completed", "summary": unterminated' + }; + case "timeout": + return failure("timed-out", 'mock scenario "timeout": the simulated agent exceeded its time limit'); + case "cancelled": + return failure("cancelled", 'mock scenario "cancelled": the simulated run was cancelled'); + case "permission-denied": + return failure( + "permission-denied", + 'mock scenario "permission-denied": the simulated agent was denied a tool permission' + ); + case "failed": + return failure("failed", 'mock scenario "failed": the simulated agent reported a failure'); + case "blocked": { + const report = { + schemaVersion: RUNNER_OUTPUT_SCHEMA_VERSION, + outcome: "blocked", + summary: `Blocked on task ${taskId}: required information is missing (mock scenario).`, + changedFiles: [], + commandsReported: [], + testsReported: [], + remainingRisks: [], + blockingQuestions: ["Which storage backend should the implementation target?"], + recommendedNextActions: ["Answer the blocking question, then resume the run."] + }; + return { + ...base, + outcome: "blocked", + report, + rawStdout: `${JSON.stringify(report, null, 2)} +` + }; + } + case "no-change": { + const report = { + schemaVersion: RUNNER_OUTPUT_SCHEMA_VERSION, + outcome: "completed", + summary: `Task ${taskId} reported complete without changing any file (mock scenario "no-change").`, + changedFiles: [], + commandsReported: [], + testsReported: [], + remainingRisks: [], + blockingQuestions: [], + recommendedNextActions: [] + }; + return { + ...base, + outcome: "completed", + report, + rawStdout: `${JSON.stringify(report, null, 2)} +` + }; + } + case "protected-path": { + const target = assertInsideWorkspace( + execution.workspaceRoot, + import_path9.default.join(execution.workspaceRoot, ".kiro", "mock-rogue-write.txt") + ); + writeFileAtomic(target, `rogue write for ${specName}/${taskId} +`); + const report = { + schemaVersion: RUNNER_OUTPUT_SCHEMA_VERSION, + outcome: "completed", + summary: `Task ${taskId} complete (mock scenario "protected-path" wrote inside .kiro).`, + changedFiles: [".kiro/mock-rogue-write.txt"], + commandsReported: [], + testsReported: [], + remainingRisks: [], + blockingQuestions: [], + recommendedNextActions: [] + }; + return { + ...base, + outcome: "completed", + report, + rawStdout: `${JSON.stringify(report, null, 2)} +` + }; + } + case "modify-tasks-doc": { + const tasksPath = assertInsideWorkspace( + execution.workspaceRoot, + import_path9.default.join(execution.workspaceRoot, ".kiro", "specs", specName, "tasks.md") + ); + if ((0, import_fs10.existsSync)(tasksPath)) { + const content = (0, import_fs10.readFileSync)(tasksPath, "utf8"); + writeFileAtomic(tasksPath, content.replace("- [ ]", "- [x]")); + } + const report = { + schemaVersion: RUNNER_OUTPUT_SCHEMA_VERSION, + outcome: "completed", + summary: `Task ${taskId} complete (mock scenario "modify-tasks-doc" edited tasks.md directly).`, + changedFiles: [`.kiro/specs/${specName}/tasks.md`], + commandsReported: [], + testsReported: [], + remainingRisks: [], + blockingQuestions: [], + recommendedNextActions: [] + }; + return { + ...base, + outcome: "completed", + report, + rawStdout: `${JSON.stringify(report, null, 2)} +` + }; + } + default: { + const relativeChangeFile = this.config.changeFile; + this.assertNotProtected(relativeChangeFile); + const target = assertInsideWorkspace( + execution.workspaceRoot, + import_path9.default.join(execution.workspaceRoot, relativeChangeFile) + ); + const previous = (0, import_fs10.existsSync)(target) ? (0, import_fs10.readFileSync)(target, "utf8") : ""; + writeFileAtomic( + target, + `${previous}mock implementation for ${specName} task ${taskId}${resumed ? " (resumed)" : ""} +` + ); + const claimsUntested = scenario === "claims-untested"; + const report = { + schemaVersion: RUNNER_OUTPUT_SCHEMA_VERSION, + outcome: "completed", + summary: `${resumed ? "Resumed and completed" : "Implemented"} task ${taskId} for "${specName}" by updating ${relativeChangeFile}.`, + changedFiles: [relativeChangeFile.split(import_path9.default.sep).join("/")], + commandsReported: claimsUntested ? ["pnpm test"] : [], + testsReported: claimsUntested ? [{ name: "unit tests (claimed, never executed)", status: "passed" }] : [], + remainingRisks: [], + blockingQuestions: [], + recommendedNextActions: [] + }; + return { + ...base, + outcome: "completed", + report, + rawStdout: `${JSON.stringify(report, null, 2)} +` + }; + } + } + } + assertNotProtected(relative) { + const normalized = relative.split(import_path9.default.sep).join("/"); + if (normalized === ".kiro" || normalized.startsWith(".kiro/") || normalized === ".specbridge" || normalized.startsWith(".specbridge/") || normalized === ".git" || normalized.startsWith(".git/")) { + throw new SpecBridgeError( + "INVALID_ARGUMENT", + `The mock runner change file must not live under a protected directory (got "${relative}"). Use the "protected-path" scenario to simulate a protected-path violation deliberately.` + ); + } + } +}; +function validStageMarkdown(stage, specName, seed) { + const title = specName.split(/[-_]/).map((word) => word.length > 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word).join(" "); + switch (stage) { + case "requirements": + return [ + "# Requirements Document", + "", + "## Introduction", + "", + `This document specifies the requirements for the ${title} feature.`, + `It was produced by the deterministic mock runner (input digest ${seed}).`, + "", + "## Requirements", + "", + `### Requirement 1: Persist ${title} settings`, + "", + "**User Story:** As a user, I want my settings to be saved, so that they survive a restart.", + "", + "#### Acceptance Criteria", + "", + "1. WHEN the user saves a setting, THE SYSTEM SHALL persist it before confirming success.", + "2. IF the persistence layer is unavailable, THEN THE SYSTEM SHALL report an error and keep the previous value.", + "", + "## Out of Scope", + "", + "- Real-time synchronization across devices is excluded from this feature.", + "", + "## Non-Functional Requirements", + "", + "- Saving a setting SHALL complete within 200 ms on the reference environment.", + "" + ].join("\n"); + case "bugfix": + return [ + "# Bugfix Report", + "", + "## Current Behavior", + "", + `The ${title} flow rejects valid input with a generic error (mock digest ${seed}).`, + "", + "## Expected Behavior", + "", + "Valid input is accepted and processed without an error.", + "", + "## Unchanged Behavior", + "", + "- Invalid input continues to be rejected with a specific message.", + "", + "## Reproduction", + "", + "1. Submit the documented valid payload.", + "2. Observe the generic error response.", + "", + "## Evidence", + "", + '- Error log entry: "unexpected validation failure" in the request handler.', + "", + "## Regression Protection", + "", + "- A regression test SHALL cover the previously rejected valid payload.", + "" + ].join("\n"); + case "design": + return [ + "# Design Document", + "", + "## Overview", + "", + `Design for ${title}, produced deterministically by the mock runner (digest ${seed}).`, + "", + "## Architecture", + "", + "A small persistence module is added behind the existing service interface.", + "", + "## Components and Interfaces", + "", + "- Settings store: read and write operations with optimistic validation.", + "", + "## Error Handling", + "", + "Failures propagate as typed errors; the previous value is always preserved.", + "", + "## Security Considerations", + "", + "No new authentication surface; input validation happens before persistence.", + "", + "## Testing Strategy", + "", + "Unit tests cover the store; an integration test covers the end-to-end flow.", + "", + "## Risks and Trade-offs", + "", + "- A simple file-backed store trades throughput for operational simplicity.", + "" + ].join("\n"); + case "tasks": + return [ + "# Implementation Plan", + "", + `- [ ] 1. Implement the settings store for ${title}`, + " - Create the persistence module and wire it behind the service interface.", + " - _Requirements: 1.1_", + "", + "- [ ] 2. Add automated tests for save and failure paths", + " - Cover the success path and the unavailable-persistence error path.", + " - _Requirements: 1.1, 1.2_", + "", + "- [ ] 3. Verify the full workflow end to end", + " - Run the project test suite and confirm the acceptance criteria.", + " - _Requirements: 1.2_", + "", + `Produced by the deterministic mock runner (digest ${seed}).`, + "" + ].join("\n"); + } +} +function invalidStageMarkdown(stage) { + switch (stage) { + case "requirements": + return [ + "# Requirements Document", + "", + "## Introduction", + "", + "As a , I want , so that .", + "" + ].join("\n"); + case "bugfix": + return ["# Bugfix Report", "", "## Notes", "", "Describe the bug here.", ""].join("\n"); + case "design": + return ["# Design Document", "", "TODO: design pending.", ""].join("\n"); + case "tasks": + return ["# Implementation Plan", "", "Add tasks here.", ""].join("\n"); + } +} var runnerUsageSchema = external_exports.object({ model: external_exports.string().nullable().default(null), inputTokens: external_exports.number().int().nonnegative().nullable().default(null), @@ -41123,6 +41805,76 @@ var runnerCostSchema = external_exports.object({ amount: external_exports.number().nonnegative().nullable().default(null), source: external_exports.enum(RUNNER_COST_SOURCES) }).strict(); +function emptyUsage(durationMs) { + return runnerUsageSchema.parse({ durationMs: Math.max(0, Math.round(durationMs)) }); +} +var CLAUDE_CAPABILITY_FLAGS = [ + { + id: "non-interactive", + label: "Non-interactive print mode", + flags: ["--print", "-p"], + required: true + }, + { + id: "json-output", + label: "JSON output", + flags: ["--output-format"], + required: true + }, + { + id: "structured-output", + label: "Structured output (JSON Schema)", + flags: ["--json-schema"], + required: false, + degradedNote: "final output will be validated JSON extracted from the result text (degraded compatibility)" + }, + { + id: "session-id", + label: "Session IDs", + flags: ["--session-id"], + required: false, + degradedNote: "runs cannot be resumed later" + }, + { + id: "resume", + label: "Resume support", + flags: ["--resume"], + required: false, + degradedNote: "interrupted runs need a fresh attempt instead of a resume" + }, + { + id: "tool-restriction", + label: "Tool restrictions", + flags: ["--allowedTools", "--allowed-tools", "--disallowedTools"], + required: true + }, + { + id: "permission-modes", + label: "Permission modes", + flags: ["--permission-mode"], + required: true + }, + { + id: "max-turns", + label: "Maximum turn limit", + flags: ["--max-turns"], + required: false, + degradedNote: "SpecBridge still enforces its own process timeout" + }, + { + id: "max-budget", + label: "Maximum budget limit", + flags: ["--max-budget-usd"], + required: false, + degradedNote: "budget limits are unavailable; use turn limits and timeouts" + } +]; +var OPTIONAL_FLAGS = [ + "--model", + "--effort", + "--append-system-prompt-file", + "--setting-sources" +]; var CLAUDE_DECLARED_CAPABILITIES = capabilitySet([ "stageGeneration", "stageRefinement", @@ -41139,6 +41891,293 @@ var CLAUDE_DECLARED_CAPABILITIES = capabilitySet([ "supportsJsonSchema", "supportsCancellation" ]); +function claudeCapabilitySet(probe) { + if (!probe.found) { + return capabilitySet([]); + } + const has = (id) => probe.capabilities.find((capability) => capability.id === id)?.available === true; + const set = { ...CLAUDE_DECLARED_CAPABILITIES }; + const executionReady = has("non-interactive") && has("json-output") && has("tool-restriction") && has("permission-modes"); + set.taskExecution = executionReady; + set.taskResume = executionReady && has("resume"); + set.toolRestriction = has("tool-restriction"); + set.supportsJsonSchema = has("structured-output"); + set.structuredFinalOutput = has("json-output"); + set.stageGeneration = has("non-interactive") && has("json-output"); + set.stageRefinement = set.stageGeneration; + return set; +} +var PROBE_TIMEOUT_MS = 15e3; +function capabilityFromHelp(flag, helpText) { + const available = flag.flags.some((token) => helpTokenPresent(helpText, token)); + return { + id: flag.id, + label: flag.label, + available, + required: flag.required, + ...available || flag.degradedNote === void 0 ? {} : { detail: flag.degradedNote } + }; +} +function helpTokenPresent(helpText, token) { + const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[\\s,])${escaped}(?![\\w-])`, "m").test(helpText); +} +async function probeClaude(config2, options) { + const diagnostics = []; + const timeoutMs = options?.timeoutMs ?? PROBE_TIMEOUT_MS; + const base = { + executable: config2.command, + commandArgs: config2.commandArgs + }; + const invoke = (argv) => runSafeProcess({ + executable: config2.command, + argv: [...config2.commandArgs, ...argv], + cwd: process.cwd(), + timeoutMs, + ...options?.signal !== void 0 ? { signal: options.signal } : {}, + maxStdoutBytes: 1024 * 1024, + maxStderrBytes: 256 * 1024 + }); + const versionResult = await invoke(["--version"]); + if (versionResult.status === "spawn-failed") { + diagnostics.push({ + severity: "error", + code: "RUNNER_EXECUTABLE_NOT_FOUND", + message: `Claude Code executable "${config2.command}" could not be started. Install Claude Code or set runners.claude-code.command in .specbridge/config.json.` + }); + return { + ...base, + found: false, + authState: "unknown", + capabilities: CLAUDE_CAPABILITY_FLAGS.map((flag) => ({ + id: flag.id, + label: flag.label, + available: false, + required: flag.required + })), + supportedFlags: /* @__PURE__ */ new Set(), + status: "unavailable", + diagnostics + }; + } + if (versionResult.status === "timeout") { + diagnostics.push({ + severity: "error", + code: "RUNNER_VERSION_TIMEOUT", + message: `"${config2.command} --version" did not finish within ${timeoutMs} ms.` + }); + return { + ...base, + found: true, + authState: "unknown", + capabilities: [], + supportedFlags: /* @__PURE__ */ new Set(), + status: "error", + diagnostics + }; + } + const version2 = versionResult.stdout.trim().split(/\r?\n/)[0]?.trim(); + if (versionResult.status !== "ok" || version2 === void 0 || version2.length === 0) { + diagnostics.push({ + severity: "error", + code: "RUNNER_VERSION_FAILED", + message: `"${config2.command} --version" ${versionResult.failureReason ?? "produced no output"}.` + }); + return { + ...base, + found: true, + authState: "unknown", + capabilities: [], + supportedFlags: /* @__PURE__ */ new Set(), + status: "error", + diagnostics + }; + } + const helpResult = await invoke(["--help"]); + const helpText = `${helpResult.stdout} +${helpResult.stderr}`; + const helpUsable = helpResult.status === "ok" && helpText.trim().length > 0; + if (!helpUsable) { + diagnostics.push({ + severity: "error", + code: "RUNNER_HELP_FAILED", + message: `"${config2.command} --help" ${helpResult.failureReason ?? "produced no output"}; capabilities cannot be verified.` + }); + } + const capabilities = CLAUDE_CAPABILITY_FLAGS.map( + (flag) => helpUsable ? capabilityFromHelp(flag, helpText) : { id: flag.id, label: flag.label, available: false, required: flag.required } + ); + const supportedFlags = /* @__PURE__ */ new Set(); + if (helpUsable) { + for (const flag of CLAUDE_CAPABILITY_FLAGS) { + for (const token of flag.flags) { + if (helpTokenPresent(helpText, token)) supportedFlags.add(token); + } + } + for (const token of OPTIONAL_FLAGS) { + if (helpTokenPresent(helpText, token)) supportedFlags.add(token); + } + } + let authState = "unknown"; + if (helpUsable && /\bauth\b/.test(helpText)) { + const authResult = await invoke(["auth", "status"]); + if (authResult.status === "ok") { + authState = "authenticated"; + } else if (authResult.status === "nonzero-exit") { + authState = "unauthenticated"; + diagnostics.push({ + severity: "error", + code: "RUNNER_UNAUTHENTICATED", + message: 'Claude Code is installed but not authenticated. Run "claude auth login" (SpecBridge never handles credentials), then verify with "specbridge runner doctor claude-code".' + }); + } else { + diagnostics.push({ + severity: "warning", + code: "RUNNER_AUTH_PROBE_FAILED", + message: `Authentication could not be verified (${authResult.failureReason ?? authResult.status}).` + }); + } + } else if (helpUsable) { + diagnostics.push({ + severity: "info", + code: "RUNNER_AUTH_PROBE_UNSUPPORTED", + message: 'This Claude Code version exposes no "auth status" command; authentication will surface at execution time instead.' + }); + } + const missingRequired = capabilities.filter((c3) => c3.required && !c3.available); + let status; + if (authState === "unauthenticated") { + status = "unauthenticated"; + } else if (missingRequired.length > 0) { + status = "incompatible"; + diagnostics.push({ + severity: "error", + code: "RUNNER_MISSING_CAPABILITY", + message: `This Claude Code version is missing required capabilities: ${missingRequired.map((c3) => c3.label).join(", ")}. Update Claude Code to a version that supports non-interactive JSON output with tool restrictions.` + }); + } else if (!helpUsable) { + status = "error"; + } else { + status = "available"; + const degraded = capabilities.filter((c3) => !c3.required && !c3.available); + for (const capability of degraded) { + diagnostics.push({ + severity: "warning", + code: "RUNNER_DEGRADED_CAPABILITY", + message: `Optional capability unavailable: ${capability.label}${capability.detail !== void 0 ? ` \u2014 ${capability.detail}` : ""}.` + }); + } + } + return { + ...base, + found: true, + version: version2, + authState, + capabilities, + supportedFlags, + status, + diagnostics + }; +} +var FORBIDDEN_ARGUMENTS = [ + "--dangerously-skip-permissions", + "--allow-dangerously-skip-permissions", + "bypassPermissions" +]; +var READ_ONLY_TOOLS = ["Read", "Glob", "Grep"]; +function allowedToolsValue(config2, policy) { + if (policy !== "implementation") { + return READ_ONLY_TOOLS.join(","); + } + const tools = config2.tools.filter((tool) => tool !== "Bash"); + const bashConfigured = config2.tools.includes("Bash"); + const rules = bashConfigured ? config2.allowedBashRules : []; + return [...tools, ...rules].join(","); +} +function buildClaudeInvocation(input) { + const { config: config2, probe, execution } = input; + const argv = [...config2.commandArgs]; + const tempFiles = []; + const skippedFlags = []; + const supports = (flag) => probe.supportedFlags.has(flag); + const pushIfSupported = (flag, ...values) => { + if (supports(flag)) argv.push(flag, ...values); + else skippedFlags.push(flag); + }; + argv.push(supports("--print") ? "--print" : "-p"); + argv.push("--output-format", "json"); + if (supports("--json-schema")) { + const schemaPath = import_path10.default.join(execution.runDir, "tmp", "output-schema.json"); + if (input.materializeTempFiles !== false) { + (0, import_fs11.mkdirSync)(import_path10.default.dirname(schemaPath), { recursive: true }); + writeFileAtomic(schemaPath, `${JSON.stringify(input.outputJsonSchema, null, 2)} +`); + tempFiles.push(schemaPath); + } + argv.push("--json-schema", schemaPath); + } else { + skippedFlags.push("--json-schema"); + } + const maxTurns = execution.maxTurns ?? config2.maxTurns; + pushIfSupported("--max-turns", String(maxTurns)); + const permissionMode = input.toolPolicy === "implementation" ? config2.permissionMode : "default"; + pushIfSupported("--permission-mode", permissionMode); + const toolsFlag = supports("--allowedTools") ? "--allowedTools" : "--allowed-tools"; + argv.push(toolsFlag, allowedToolsValue(config2, input.toolPolicy)); + if (input.resumeSessionId !== void 0) { + argv.push("--resume", input.resumeSessionId); + } else if (input.sessionId !== void 0 && supports("--session-id")) { + argv.push("--session-id", input.sessionId); + } + const model = execution.model ?? config2.model; + if (model !== null && model !== void 0) pushIfSupported("--model", model); + if (config2.effort !== null) pushIfSupported("--effort", config2.effort); + const maxBudget = execution.maxBudgetUsd ?? config2.maxBudgetUsd; + if (maxBudget !== null && maxBudget !== void 0) { + pushIfSupported("--max-budget-usd", String(maxBudget)); + } + if (!config2.loadProjectConfiguration) { + pushIfSupported("--setting-sources", "user"); + } + assertNoForbiddenArguments(argv); + return { + executable: config2.command, + argv, + stdin: input.prompt, + tempFiles, + skippedFlags + }; +} +function assertNoForbiddenArguments(argv) { + for (const argument of argv) { + for (const forbidden of FORBIDDEN_ARGUMENTS) { + if (argument.includes(forbidden)) { + throw new SpecBridgeError( + "INVALID_STATE", + `Refusing to invoke Claude Code: the argument vector contains "${forbidden}". SpecBridge never skips or bypasses runner permissions.` + ); + } + } + } +} +async function runClaudeInvocation(plan, config2, execution) { + assertNoForbiddenArguments(plan.argv); + return runSafeProcess({ + executable: plan.executable, + argv: plan.argv, + cwd: execution.workspaceRoot, + timeoutMs: execution.timeoutMs, + ...execution.signal !== void 0 ? { signal: execution.signal } : {}, + stdin: plan.stdin, + maxStdoutBytes: config2.maxStdoutBytes, + maxStderrBytes: config2.maxStderrBytes + }); +} +function cleanupTempFiles(plan) { + for (const file of plan.tempFiles) { + (0, import_fs11.rmSync)(file, { force: true }); + } +} var claudeEnvelopeSchema = external_exports.object({ type: external_exports.string().optional(), subtype: external_exports.string().optional(), @@ -41148,6 +42187,352 @@ var claudeEnvelopeSchema = external_exports.object({ structured_result: external_exports.unknown().optional(), permission_denials: external_exports.array(external_exports.unknown()).optional() }).passthrough(); +function parseClaudeEnvelope(stdout) { + const trimmed = stdout.trim(); + if (trimmed.length === 0) { + return { problem: "the runner produced no output" }; + } + const candidates = []; + candidates.push(trimmed); + const lines = trimmed.split(/\r?\n/); + for (let i2 = lines.length - 1; i2 >= 0; i2 -= 1) { + const line = lines[i2]?.trim() ?? ""; + if (line.startsWith("{")) candidates.push(line); + } + for (const candidate of candidates) { + let parsed; + try { + parsed = JSON.parse(candidate); + } catch { + continue; + } + const envelope = claudeEnvelopeSchema.safeParse(parsed); + if (!envelope.success) continue; + const data = envelope.data; + if (data.structured_result !== void 0) { + return { envelope: data, structuredResult: data.structured_result }; + } + if (data.result !== void 0) { + return { envelope: data, reportText: data.result }; + } + return { envelope: data }; + } + return { problem: "no JSON result envelope found in the runner output" }; +} +var ClaudeCodeRunner = class { + name = "claude-code"; + kind = "claude-code"; + category = "agent-cli"; + declaredCapabilities = CLAUDE_DECLARED_CAPABILITIES; + config; + probePromise; + constructor(config2) { + this.config = claudeRunnerConfigSchema.parse(config2 ?? {}); + } + /** Probe once per runner instance; detection is read-only but not free. */ + probe(timeoutMs) { + this.probePromise ??= probeClaude( + this.config, + timeoutMs !== void 0 ? { timeoutMs } : void 0 + ); + return this.probePromise; + } + async detect(context) { + if (!this.config.enabled) { + return { + runner: this.name, + kind: this.kind, + status: "misconfigured", + executable: this.config.command, + authentication: "unknown", + capabilities: [], + diagnostics: [ + { + severity: "error", + code: "RUNNER_DISABLED", + message: "The claude-code runner is disabled in .specbridge/config.json (runners.claude-code.enabled = false)." + } + ], + category: this.category, + capabilitySet: this.declaredCapabilities, + supportLevel: effectiveSupportLevel("production", "misconfigured"), + networkBacked: false + }; + } + const probe = await this.probe(context.timeoutMs); + return { + runner: this.name, + kind: this.kind, + status: probe.status, + executable: probe.executable, + ...probe.version !== void 0 ? { version: probe.version } : {}, + authentication: probe.authState, + capabilities: probe.capabilities, + diagnostics: probe.diagnostics, + category: this.category, + capabilitySet: claudeCapabilitySet(probe), + supportLevel: effectiveSupportLevel("production", probe.status), + // The Claude Code CLI talks to its provider itself; SpecBridge's own + // transport is a local child process. + networkBacked: false + }; + } + executionBoundaryNote(policy) { + if (policy !== "implementation") { + return "Allowed tools: Read, Glob, Grep (read-only repository access). Permission bypasses are never used."; + } + return `Allowed tools: ${this.config.tools.join(", ")} (Bash limited to the configured allow rules); permission mode: ${this.config.permissionMode}. Permission bypasses are never used.`; + } + /** Minimal bounded structured-output probe (`runner test --network`). */ + async selfTest(execution) { + const probe = await this.probe(); + if (probe.status !== "available") { + return { ok: false, detail: `claude-code is not available (status: ${probe.status})` }; + } + const plan = buildClaudeInvocation({ + config: this.config, + probe, + prompt: 'This is a connectivity self test. Do not read or modify any file. Reply with exactly one JSON document: {"schemaVersion":"1.0.0","stage":"requirements","markdown":"# Self Test","summary":"self test"} and nothing else.', + toolPolicy: "read-only", + outputJsonSchema: STAGE_RUNNER_REPORT_JSON_SCHEMA, + execution + }); + const result = await runClaudeInvocation(plan, this.config, execution); + cleanupTempFiles(plan); + if (result.status !== "ok") { + return { + ok: false, + detail: result.failureReason ?? `self test failed (${result.status})`, + process: result.observation + }; + } + const parsed = parseClaudeEnvelope(result.stdout); + const report = parsed.structuredResult !== void 0 ? stageRunnerReportSchema.safeParse(parsed.structuredResult) : parsed.reportText !== void 0 ? stageRunnerReportSchema.safeParse(safeJsonParse(parsed.reportText)) : void 0; + const usage = usageFromEnvelope(parsed.envelope, result.observation.durationMs); + return report !== void 0 && report.success ? { + ok: true, + detail: "structured output validated", + process: result.observation, + ...usage !== void 0 ? { usage } : {} + } : { + ok: false, + detail: "the runner responded but did not return a valid structured result", + process: result.observation + }; + } + async generateStage(input, execution) { + const started = Date.now(); + const probe = await this.probe(); + const unavailable = this.unavailableResult(probe, started); + if (unavailable !== void 0) { + const { report: _report, ...rest2 } = unavailable; + return rest2; + } + const plan = buildClaudeInvocation({ + config: this.config, + probe, + prompt: input.prompt, + toolPolicy: input.toolPolicy, + outputJsonSchema: STAGE_RUNNER_REPORT_JSON_SCHEMA, + execution + }); + const processResult = await runClaudeInvocation(plan, this.config, execution); + const mapped = this.mapResult(processResult, plan, started, "stage"); + if (mapped.outcome === "completed" || mapped.outcome === "no-change") { + cleanupTempFiles(plan); + } + const { report, ...rest } = mapped; + const stageReport = report; + return { ...rest, ...stageReport !== void 0 ? { report: stageReport } : {} }; + } + async executeTask(input, execution) { + return this.runTask(input.prompt, execution, { + ...input.sessionId !== void 0 ? { sessionId: input.sessionId } : {} + }); + } + async resumeTask(input, execution) { + return this.runTask(input.prompt, execution, { resumeSessionId: input.sessionId }); + } + async runTask(prompt, execution, session) { + const started = Date.now(); + const probe = await this.probe(); + const unavailable = this.unavailableResult(probe, started); + if (unavailable !== void 0) { + const { report: _report, ...rest2 } = unavailable; + return { ...rest2, resumeSupported: false }; + } + const plan = buildClaudeInvocation({ + config: this.config, + probe, + prompt, + toolPolicy: "implementation", + outputJsonSchema: TASK_RUNNER_REPORT_JSON_SCHEMA, + ...session.sessionId !== void 0 ? { sessionId: session.sessionId } : {}, + ...session.resumeSessionId !== void 0 ? { resumeSessionId: session.resumeSessionId } : {}, + execution + }); + const processResult = await runClaudeInvocation(plan, this.config, execution); + const mapped = this.mapResult(processResult, plan, started, "task"); + if (mapped.outcome === "completed" || mapped.outcome === "no-change") { + cleanupTempFiles(plan); + } + const resumeCapable = probe.capabilities.find((c3) => c3.id === "resume")?.available === true; + const { report, sessionId, ...rest } = mapped; + const taskReport = report; + const effectiveSession = sessionId ?? session.sessionId ?? session.resumeSessionId; + return { + ...rest, + ...taskReport !== void 0 ? { report: taskReport } : {}, + ...effectiveSession !== void 0 ? { sessionId: effectiveSession } : {}, + resumeSupported: resumeCapable && effectiveSession !== void 0 + }; + } + unavailableResult(probe, started) { + if (probe.status === "available") return void 0; + return { + runner: this.name, + outcome: "failed", + failureReason: `the claude-code runner is not available (status: ${probe.status}); run "specbridge runner doctor claude-code" for details`, + rawStdout: "", + rawStderr: "", + durationMs: Date.now() - started, + warnings: probe.diagnostics.filter((d) => d.severity === "error").map((d) => d.message) + }; + } + /** Map a finished process to a structured runner result. */ + mapResult(processResult, plan, started, reportKind) { + const warnings = plan.skippedFlags.map( + (flag) => `flag ${flag} is unsupported by this Claude Code version and was skipped` + ); + const base = { + runner: this.name, + rawStdout: processResult.stdout, + rawStderr: processResult.stderr, + process: processResult.observation, + durationMs: Math.max(0, Date.now() - started), + warnings + }; + switch (processResult.status) { + case "timeout": + return { ...base, outcome: "timed-out", failureReason: processResult.failureReason ?? "timeout" }; + case "cancelled": + return { ...base, outcome: "cancelled", failureReason: processResult.failureReason ?? "cancelled" }; + case "output-limit": + case "spawn-failed": + return { ...base, outcome: "failed", failureReason: processResult.failureReason ?? processResult.status }; + case "ok": + case "nonzero-exit": + break; + } + const parsed = parseClaudeEnvelope(processResult.stdout); + const sessionId = parsed.envelope?.session_id; + const usage = usageFromEnvelope(parsed.envelope, processResult.observation.durationMs); + const cost = costFromEnvelope(parsed.envelope); + const withSession = { + ...base, + ...sessionId !== void 0 ? { sessionId } : {}, + ...usage !== void 0 ? { usage } : {}, + ...cost !== void 0 ? { cost } : {} + }; + if (this.looksPermissionDenied(processResult, parsed.envelope?.subtype, parsed.envelope)) { + return { + ...withSession, + outcome: "permission-denied", + failureReason: "Claude Code reported a permission denial. SpecBridge never bypasses permissions; adjust runners.claude-code.tools / allowedBashRules if the denied action should be allowed." + }; + } + if (processResult.status === "nonzero-exit") { + return { + ...withSession, + outcome: "failed", + failureReason: processResult.failureReason ?? "nonzero exit" + }; + } + let report; + let parseProblem = parsed.problem; + if (parsed.structuredResult !== void 0) { + const schema = reportKind === "stage" ? stageRunnerReportSchema : taskRunnerReportSchema; + const validated = schema.safeParse(parsed.structuredResult); + if (validated.success) report = validated.data; + else { + parseProblem = `structured result does not match the report schema: ${validated.error.issues.map((issue2) => `${issue2.path.join(".") || "(root)"}: ${issue2.message}`).join("; ")}`; + } + } else if (parsed.reportText !== void 0) { + const result = reportKind === "stage" ? parseStageRunnerReport(parsed.reportText) : parseTaskRunnerReport(parsed.reportText); + if (result.ok) report = result.report; + else parseProblem = result.reason; + } + if (report === void 0) { + if (parsed.envelope?.is_error === true) { + return { + ...withSession, + outcome: "failed", + failureReason: `Claude Code reported an error result${parsed.envelope.subtype !== void 0 ? ` (${parsed.envelope.subtype})` : ""}` + }; + } + return { + ...withSession, + outcome: "malformed-output", + failureReason: parseProblem ?? "the runner returned no parseable structured result" + }; + } + const outcome = "outcome" in report && report.outcome !== void 0 ? mapReportedOutcome(report.outcome) : "completed"; + return { + ...withSession, + outcome, + report, + ...outcome === "completed" || outcome === "no-change" ? {} : { failureReason: `the agent reported "${outcome}"` } + }; + } + looksPermissionDenied(processResult, subtype, envelope) { + if (subtype !== void 0 && /permission/i.test(subtype)) return true; + if (envelope?.permission_denials !== void 0 && envelope.permission_denials.length > 0) { + return processResult.status === "nonzero-exit"; + } + if (processResult.status === "nonzero-exit" && /permission[^\n]{0,40}denied|denied[^\n]{0,40}permission/i.test(processResult.stderr)) { + return true; + } + return false; + } +}; +function mapReportedOutcome(reported) { + return reported; +} +function safeJsonParse(raw) { + try { + return JSON.parse(raw); + } catch { + return void 0; + } +} +function tolerantCount(value) { + return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null; +} +function usageFromEnvelope(envelope, durationMs) { + if (envelope === void 0) return void 0; + const usage = envelope["usage"]; + const numTurns = tolerantCount(envelope["num_turns"]); + if (usage === null || typeof usage !== "object") { + if (numTurns === null) return void 0; + return { ...emptyUsage(durationMs), requestCount: numTurns }; + } + const record2 = usage; + return { + model: null, + inputTokens: tolerantCount(record2["input_tokens"]), + cachedInputTokens: tolerantCount(record2["cache_read_input_tokens"]), + outputTokens: tolerantCount(record2["output_tokens"]), + reasoningTokens: null, + requestCount: numTurns, + durationMs: Math.max(0, Math.round(durationMs)) + }; +} +function costFromEnvelope(envelope) { + if (envelope === void 0) return void 0; + const cost = envelope["total_cost_usd"]; + if (typeof cost !== "number" || !Number.isFinite(cost) || cost < 0) return void 0; + return { currency: "USD", amount: cost, source: "provider-reported" }; +} var RUNNER_ERROR_SCHEMA_VERSION = "1.0.0"; var RUNNER_ERROR_CODES = [ "runner_not_found", @@ -41189,6 +42574,103 @@ var normalizedRunnerErrorSchema = external_exports.object({ /** Redacted structured details (never raw provider payloads). */ details: external_exports.record(external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.null()])).optional() }).strict(); +var NON_RETRYABLE_ERROR_CODES = [ + "runner_not_found", + "runner_disabled", + "runner_incompatible", + "executable_not_found", + "authentication_required", + "permission_denied", + "sandbox_unavailable", + "structured_output_unsupported", + "model_not_found", + "quota_exceeded", + "cancelled", + "repository_diverged", + "protected_path_modified", + "invalid_configuration", + "unsupported_operation" +]; +function runnerError(input) { + return normalizedRunnerErrorSchema.parse({ + schemaVersion: RUNNER_ERROR_SCHEMA_VERSION, + code: input.code, + message: input.message, + remediation: input.remediation ?? [], + retryable: input.retryable ?? !NON_RETRYABLE_ERROR_CODES.includes(input.code), + ...input.providerCode !== void 0 ? { providerCode: input.providerCode } : {}, + ...input.details !== void 0 ? { details: input.details } : {} + }); +} +var CODEX_CAPABILITY_PROBES = [ + { + id: "non-interactive", + label: "Non-interactive execution (exec)", + tokens: ["exec"], + source: "root", + required: true + }, + { + id: "json-events", + label: "Machine-readable event output (--json)", + tokens: ["--json"], + source: "exec", + required: true + }, + { + id: "output-schema", + label: "JSON Schema constrained output (--output-schema)", + tokens: ["--output-schema"], + source: "exec", + required: false, + degradedNote: "the final agent message is validated against the schema instead" + }, + { + id: "output-last-message", + label: "Final message to file (--output-last-message)", + tokens: ["--output-last-message"], + source: "exec", + required: false, + degradedNote: "the final message is extracted from the event stream instead" + }, + { + id: "sandbox-read-only", + label: "Read-only sandbox (--sandbox read-only)", + tokens: ["--sandbox"], + source: "exec", + required: true + }, + { + id: "sandbox-workspace-write", + label: "Workspace-write sandbox (--sandbox workspace-write)", + tokens: ["workspace-write"], + source: "exec", + required: false, + degradedNote: "task execution is unavailable without a workspace-write sandbox" + }, + { + id: "resume", + label: "Session resume (exec resume )", + tokens: ["resume"], + source: "exec", + required: false, + degradedNote: "interrupted runs need a fresh attempt instead of a resume" + }, + { + id: "model-selection", + label: "Model selection (--model)", + tokens: ["--model", "-m,"], + source: "exec", + required: false, + degradedNote: "the provider default model is used" + } +]; +var CODEX_FORBIDDEN_ARGUMENTS = [ + "danger-full-access", + "--dangerously-bypass-approvals-and-sandbox", + "--yolo", + "--skip-git-repo-check" +]; var CODEX_DECLARED_CAPABILITIES = capabilitySet([ "stageGeneration", "stageRefinement", @@ -41204,6 +42686,277 @@ var CODEX_DECLARED_CAPABILITIES = capabilitySet([ "supportsJsonSchema", "supportsCancellation" ]); +var PROBE_TIMEOUT_MS2 = 15e3; +function tokenPresent(helpText, token) { + const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[\\s,=<[])${escaped}(?![\\w-])`, "m").test(helpText); +} +async function probeCodex(config2, options) { + const diagnostics = []; + const timeoutMs = options?.timeoutMs ?? PROBE_TIMEOUT_MS2; + const base = { + executable: config2.command.executable, + commandArgs: config2.command.args + }; + const invoke = (argv) => runSafeProcess({ + executable: config2.command.executable, + argv: [...config2.command.args, ...argv], + cwd: process.cwd(), + timeoutMs, + ...options?.signal !== void 0 ? { signal: options.signal } : {}, + maxStdoutBytes: 1024 * 1024, + maxStderrBytes: 256 * 1024 + }); + const emptyCapabilities = () => CODEX_CAPABILITY_PROBES.map((probe) => ({ + id: probe.id, + label: probe.label, + available: false, + required: probe.required + })); + const versionResult = await invoke(["--version"]); + if (versionResult.status === "spawn-failed") { + diagnostics.push({ + severity: "error", + code: "RUNNER_EXECUTABLE_NOT_FOUND", + message: `Codex CLI executable "${config2.command.executable}" could not be started. Install the Codex CLI (the user installs and authenticates it independently) or set the profile command in .specbridge/config.json.` + }); + return { + ...base, + found: false, + authState: "unknown", + capabilities: emptyCapabilities(), + supportedTokens: /* @__PURE__ */ new Set(), + status: "unavailable", + diagnostics + }; + } + if (versionResult.status !== "ok") { + diagnostics.push({ + severity: "error", + code: "RUNNER_VERSION_FAILED", + message: `"${config2.command.executable} --version" ${versionResult.failureReason ?? "produced no output"}.` + }); + return { + ...base, + found: true, + authState: "unknown", + capabilities: emptyCapabilities(), + supportedTokens: /* @__PURE__ */ new Set(), + status: "error", + diagnostics + }; + } + const version2 = versionResult.stdout.trim().split(/\r?\n/)[0]?.trim(); + const rootHelp = await invoke(["--help"]); + const rootText = `${rootHelp.stdout} +${rootHelp.stderr}`; + const rootUsable = rootHelp.status === "ok" && rootText.trim().length > 0; + const execHelp = rootUsable && tokenPresent(rootText, "exec") ? await invoke(["exec", "--help"]) : void 0; + const execText = execHelp !== void 0 ? `${execHelp.stdout} +${execHelp.stderr}` : ""; + const execUsable = execHelp !== void 0 && execHelp.status === "ok" && execText.trim().length > 0; + if (!rootUsable) { + diagnostics.push({ + severity: "error", + code: "RUNNER_HELP_FAILED", + message: `"${config2.command.executable} --help" ${rootHelp.failureReason ?? "produced no output"}; capabilities cannot be verified.` + }); + } + const supportedTokens = /* @__PURE__ */ new Set(); + const capabilities = CODEX_CAPABILITY_PROBES.map((probe) => { + const text = probe.source === "root" ? rootText : execText; + const usable = probe.source === "root" ? rootUsable : execUsable; + const available = usable && probe.tokens.some((token) => tokenPresent(text, token)); + if (available) for (const token of probe.tokens) supportedTokens.add(token); + return { + id: probe.id, + label: probe.label, + available, + required: probe.required, + ...available || probe.degradedNote === void 0 ? {} : { detail: probe.degradedNote } + }; + }); + let authState = "unknown"; + if (rootUsable && tokenPresent(rootText, "login")) { + const loginStatus = await invoke(["login", "status"]); + if (loginStatus.status === "ok") { + authState = "authenticated"; + } else if (loginStatus.status === "nonzero-exit") { + authState = "unauthenticated"; + diagnostics.push({ + severity: "error", + code: "RUNNER_UNAUTHENTICATED", + message: 'The Codex CLI is installed but not authenticated. Run "codex login" yourself (SpecBridge never handles or stores credentials), then verify with "specbridge runner doctor".' + }); + } else { + diagnostics.push({ + severity: "warning", + code: "RUNNER_AUTH_PROBE_FAILED", + message: `Authentication could not be verified (${loginStatus.failureReason ?? loginStatus.status}). It will surface at execution time.` + }); + } + } else if (rootUsable) { + diagnostics.push({ + severity: "info", + code: "RUNNER_AUTH_PROBE_UNSUPPORTED", + message: 'This Codex CLI version exposes no safe authentication status command; authentication is reported as unknown (SpecBridge never reads provider credential files). Use "specbridge runner test --network" for a minimal authenticated probe.' + }); + } + const missingRequired = capabilities.filter((c3) => c3.required && !c3.available); + let status; + if (authState === "unauthenticated") { + status = "unauthenticated"; + } else if (missingRequired.length > 0) { + status = "incompatible"; + diagnostics.push({ + severity: "error", + code: "RUNNER_MISSING_CAPABILITY", + message: `This Codex CLI version is missing required capabilities: ${missingRequired.map((c3) => c3.label).join(", ")}. Update the Codex CLI to a version with non-interactive exec, machine-readable output, and sandbox control.` + }); + } else if (!rootUsable || !execUsable) { + status = "error"; + } else { + status = "available"; + for (const capability of capabilities.filter((c3) => !c3.required && !c3.available)) { + diagnostics.push({ + severity: "warning", + code: "RUNNER_DEGRADED_CAPABILITY", + message: `Optional capability unavailable: ${capability.label}${capability.detail !== void 0 ? ` \u2014 ${capability.detail}` : ""}.` + }); + } + } + return { + ...base, + found: true, + ...version2 !== void 0 && version2.length > 0 ? { version: version2 } : {}, + authState, + capabilities, + supportedTokens, + status, + diagnostics + }; +} +function probeAvailable(probe, id) { + return probe.capabilities.find((capability) => capability.id === id)?.available === true; +} +function codexCapabilitySet(probe) { + if (!probe.found) return capabilitySet([]); + const set = { ...CODEX_DECLARED_CAPABILITIES }; + const execReady = probeAvailable(probe, "non-interactive") && probeAvailable(probe, "json-events"); + const readOnlySandbox = probeAvailable(probe, "sandbox-read-only"); + const workspaceWrite = probeAvailable(probe, "sandbox-workspace-write"); + set.stageGeneration = execReady && readOnlySandbox; + set.stageRefinement = set.stageGeneration; + set.sandbox = readOnlySandbox; + set.taskExecution = execReady && workspaceWrite; + set.taskResume = set.taskExecution && probeAvailable(probe, "resume"); + set.structuredFinalOutput = execReady; + set.streamingEvents = execReady; + set.supportsJsonSchema = probeAvailable(probe, "output-schema"); + return set; +} +function assertNoForbiddenCodexArguments(argv) { + for (const argument of argv) { + for (const forbidden of CODEX_FORBIDDEN_ARGUMENTS) { + if (argument.includes(forbidden)) { + throw new SpecBridgeError( + "INVALID_STATE", + `Refusing to invoke the Codex CLI: the argument vector contains "${forbidden}". SpecBridge never disables sandboxing, approvals, or repository safety checks.` + ); + } + } + } + const sandboxIndex = argv.indexOf("--sandbox"); + if (sandboxIndex >= 0) { + const mode = argv[sandboxIndex + 1]; + if (mode !== "read-only" && mode !== "workspace-write") { + throw new SpecBridgeError( + "INVALID_STATE", + `Refusing to invoke the Codex CLI with sandbox mode "${mode ?? "(missing)"}". Only read-only and workspace-write are ever used.` + ); + } + } +} +function buildCodexInvocation(input) { + const { config: config2, probe, execution } = input; + const argv = [...config2.command.args]; + const tempFiles = []; + const skippedFlags = []; + const supports = (token) => probe.supportedTokens.has(token); + argv.push("exec"); + if (input.resumeSessionId !== void 0) { + argv.push("resume", input.resumeSessionId); + } + argv.push("--json"); + const sandbox = input.toolPolicy === "implementation" ? config2.sandbox === "read-only" ? "read-only" : "workspace-write" : "read-only"; + argv.push("--sandbox", sandbox); + const tmpDir = import_path11.default.join(execution.runDir, "tmp"); + if (supports("--output-schema")) { + const schemaPath = import_path11.default.join(tmpDir, "codex-output-schema.json"); + if (input.materializeTempFiles !== false) { + (0, import_fs12.mkdirSync)(tmpDir, { recursive: true }); + writeFileAtomic(schemaPath, `${JSON.stringify(input.outputJsonSchema, null, 2)} +`); + tempFiles.push(schemaPath); + } + argv.push("--output-schema", schemaPath); + } else { + skippedFlags.push("--output-schema"); + } + let lastMessagePath; + if (supports("--output-last-message")) { + lastMessagePath = import_path11.default.join(tmpDir, "codex-last-message.txt"); + if (input.materializeTempFiles !== false) { + (0, import_fs12.mkdirSync)(tmpDir, { recursive: true }); + } + argv.push("--output-last-message", lastMessagePath); + tempFiles.push(lastMessagePath); + } else { + skippedFlags.push("--output-last-message"); + } + const model = execution.model ?? config2.model; + if (model !== null && model !== void 0) { + if (supports("--model")) argv.push("--model", model); + else skippedFlags.push("--model"); + } + argv.push("-"); + assertNoForbiddenCodexArguments(argv); + return { + executable: config2.command.executable, + argv, + stdin: input.prompt, + sandbox, + ...lastMessagePath !== void 0 ? { lastMessagePath } : {}, + tempFiles, + skippedFlags + }; +} +async function runCodexInvocation(plan, config2, execution) { + assertNoForbiddenCodexArguments(plan.argv); + return runSafeProcess({ + executable: plan.executable, + argv: plan.argv, + cwd: execution.workspaceRoot, + timeoutMs: execution.timeoutMs, + ...execution.signal !== void 0 ? { signal: execution.signal } : {}, + stdin: plan.stdin, + maxStdoutBytes: config2.maxStdoutBytes, + maxStderrBytes: config2.maxStderrBytes + }); +} +function readLastMessage(plan) { + if (plan.lastMessagePath === void 0 || !(0, import_fs12.existsSync)(plan.lastMessagePath)) return void 0; + try { + return (0, import_fs12.readFileSync)(plan.lastMessagePath, "utf8"); + } catch { + return void 0; + } +} +function cleanupCodexTempFiles(plan) { + for (const file of plan.tempFiles) { + (0, import_fs12.rmSync)(file, { force: true }); + } +} var RUNNER_EVENT_SCHEMA_VERSION = "1.0.0"; var NORMALIZED_RUNNER_EVENT_TYPES = [ "runner.started", @@ -41251,6 +43004,10 @@ var normalizedRunnerEventSchema = external_exports.object({ }); } }); +function boundedPayloadText(value, maxChars = 2e3) { + return value.length <= maxChars ? value : `${value.slice(0, maxChars)}\u2026 [truncated]`; +} +var MAX_RETAINED_EVENTS = 5e3; var codexItemSchema = external_exports.object({ id: external_exports.string().optional(), type: external_exports.string().optional(), @@ -41273,49 +43030,3492 @@ var codexEventSchema = external_exports.object({ error: external_exports.object({ message: external_exports.string().optional() }).passthrough().optional(), message: external_exports.string().optional() }).passthrough(); -var ollamaVersionResponseSchema = external_exports.object({ version: external_exports.string() }).passthrough(); -var ollamaModelSchema = external_exports.object({ - name: external_exports.string(), - size: external_exports.number().optional(), - modified_at: external_exports.string().optional(), - details: external_exports.object({ - family: external_exports.string().optional(), - parameter_size: external_exports.string().optional(), - quantization_level: external_exports.string().optional() - }).passthrough().optional() -}).passthrough(); -var ollamaTagsResponseSchema = external_exports.object({ models: external_exports.array(ollamaModelSchema).default([]) }).passthrough(); -var ollamaChatResponseSchema = external_exports.object({ - model: external_exports.string().optional(), - message: external_exports.object({ - role: external_exports.string().optional(), - content: external_exports.string().default(""), - thinking: external_exports.string().optional() - }).passthrough(), - done: external_exports.boolean().optional(), - prompt_eval_count: external_exports.number().optional(), - eval_count: external_exports.number().optional(), - total_duration: external_exports.number().optional() -}).passthrough(); -var PROBE_MAX_BYTES = 1024 * 1024; -var OLLAMA_DECLARED_CAPABILITIES = capabilitySet([ +function parseCodexEventStream(stdout) { + const stream = { + events: [], + unparseableLines: 0, + truncated: false, + errors: [] + }; + let inputTokens = null; + let cachedInputTokens = null; + let outputTokens = null; + let reasoningTokens = null; + let turns = 0; + for (const line of stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || !trimmed.startsWith("{")) continue; + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch { + stream.unparseableLines += 1; + continue; + } + const event = codexEventSchema.safeParse(parsed); + if (!event.success) { + stream.unparseableLines += 1; + continue; + } + if (stream.events.length < MAX_RETAINED_EVENTS) { + stream.events.push(event.data); + } else { + stream.truncated = true; + } + const data = event.data; + if (data.type === "thread.started" && data.thread_id !== void 0) { + stream.threadId = data.thread_id; + } + if (data.type === "turn.completed") { + turns += 1; + const usage = data.usage; + if (usage !== void 0) { + inputTokens = (inputTokens ?? 0) + (usage.input_tokens ?? 0); + cachedInputTokens = (cachedInputTokens ?? 0) + (usage.cached_input_tokens ?? 0); + outputTokens = (outputTokens ?? 0) + (usage.output_tokens ?? 0); + if (usage.reasoning_output_tokens !== void 0) { + reasoningTokens = (reasoningTokens ?? 0) + usage.reasoning_output_tokens; + } + } + } + if (data.type === "item.completed" && data.item?.type === "agent_message" && data.item.text !== void 0) { + stream.lastAgentMessage = data.item.text; + } + if (data.type === "error" || data.type === "turn.failed") { + const message = data.error?.message ?? data.message; + if (message !== void 0 && stream.errors.length < 20) { + stream.errors.push(boundedPayloadText(message, 500)); + } + } + } + if (turns > 0 || inputTokens !== null || outputTokens !== null) { + stream.usage = { + inputTokens, + cachedInputTokens, + outputTokens, + reasoningTokens, + requestCount: turns + }; + } + return stream; +} +var ITEM_EVENT_TYPES = { + command_execution: { started: "command.started", completed: "command.completed", failed: "tool.failed" }, + mcp_tool_call: { started: "tool.started", completed: "tool.completed", failed: "tool.failed" }, + web_search: { started: "tool.started", completed: "tool.completed", failed: "tool.failed" }, + file_change: { started: "tool.started", completed: "file.changed", failed: "tool.failed" }, + todo_list: { started: "plan.updated", completed: "plan.updated", failed: "plan.updated" } +}; +function itemPayload(item) { + const payload = {}; + if (item.type !== void 0) payload["itemType"] = item.type; + if (item.type === "reasoning") { + payload["redacted"] = true; + payload["textLength"] = item.text?.length ?? 0; + return payload; + } + if (item.command !== void 0) payload["command"] = boundedPayloadText(item.command, 500); + if (item.exit_code !== void 0) payload["exitCode"] = item.exit_code; + if (item.status !== void 0) payload["status"] = item.status; + if (item.type === "agent_message" && item.text !== void 0) { + payload["textLength"] = item.text.length; + } + if (item.changes !== void 0) { + payload["changedPaths"] = boundedPayloadText( + item.changes.map((change) => `${change.kind ?? "edit"} ${change.path ?? "?"}`).join(", "), + 2e3 + ); + payload["changeCount"] = item.changes.length; + } + return payload; +} +function redactCodexStdoutForRetention(stdout) { + return stdout.split(/\r?\n/).map((line) => { + const trimmed = line.trim(); + if (!trimmed.startsWith("{") || !trimmed.includes('"reasoning"')) return line; + try { + const parsed = JSON.parse(trimmed); + if (parsed.item?.type === "reasoning" && typeof parsed.item.text === "string") { + parsed.item.text = `[redacted reasoning: ${parsed.item.text.length} chars]`; + return JSON.stringify(parsed); + } + } catch { + } + return line; + }).join("\n"); +} +function normalizeCodexEvents(stream, context, timestamp) { + const normalized = []; + const push = (type, providerEventType, payload) => { + if (normalized.length >= MAX_RETAINED_EVENTS) return; + normalized.push( + normalizedRunnerEventSchema.parse({ + type, + timestamp: timestamp(), + runner: context.runner, + profile: context.profile, + runId: context.runId, + attemptId: context.attemptId, + ...context.providerSessionId !== void 0 || stream.threadId !== void 0 ? { providerSessionId: context.providerSessionId ?? stream.threadId } : {}, + providerEventType, + payload + }) + ); + }; + for (const event of stream.events) { + switch (event.type) { + case "thread.started": + push("session.started", event.type, { + ...event.thread_id !== void 0 ? { threadId: event.thread_id } : {} + }); + break; + case "turn.started": + push("turn.started", event.type, {}); + break; + case "turn.completed": + push("turn.completed", event.type, {}); + if (event.usage !== void 0) { + push("usage.updated", event.type, { + inputTokens: event.usage.input_tokens ?? null, + cachedInputTokens: event.usage.cached_input_tokens ?? null, + outputTokens: event.usage.output_tokens ?? null + }); + } + break; + case "turn.failed": + push("error", event.type, { + message: boundedPayloadText(event.error?.message ?? "turn failed", 500) + }); + break; + case "error": + push("error", event.type, { + message: boundedPayloadText(event.error?.message ?? event.message ?? "error", 500) + }); + break; + case "item.started": + case "item.updated": + case "item.completed": { + const item = event.item; + if (item === void 0 || item.type === void 0) break; + if (item.type === "agent_message" || item.type === "reasoning") { + if (event.type === "item.completed") { + push("message.completed", `${event.type}:${item.type}`, itemPayload(item)); + } + break; + } + const mapping = ITEM_EVENT_TYPES[item.type]; + if (mapping === void 0) break; + const type = event.type === "item.started" ? mapping.started : item.status === "failed" ? mapping.failed : mapping.completed; + if (event.type === "item.updated" && item.type !== "todo_list") break; + push(type, `${event.type}:${item.type}`, itemPayload(item)); + break; + } + default: + break; + } + } + return normalized; +} +function classifyCodexFailure(stderr, streamErrors) { + const haystack = `${stderr} +${streamErrors.join("\n")}`.toLowerCase(); + if (/not logged in|login required|unauthorized|authentication|401/.test(haystack)) { + return runnerError({ + code: "authentication_required", + message: "The Codex CLI reported an authentication failure.", + remediation: ['Run "codex login" yourself (SpecBridge never handles credentials).'] + }); + } + if (/insufficient_quota|quota exceeded|usage limit|out of credits/.test(haystack)) { + return runnerError({ + code: "quota_exceeded", + message: "The provider reported an exhausted quota or usage limit.", + remediation: ["Check your provider plan and usage, then retry explicitly."] + }); + } + if (/rate limit|too many requests|429/.test(haystack)) { + return runnerError({ + code: "rate_limited", + message: "The provider reported a rate limit.", + remediation: ["Wait and retry explicitly."], + providerCode: "429" + }); + } + if (/sandbox (is )?unavailable|sandbox unsupported|landlock|seatbelt.*(unavailable|failed)/.test(haystack)) { + return runnerError({ + code: "sandbox_unavailable", + message: "The Codex CLI could not establish its sandbox on this system.", + remediation: [ + "SpecBridge never disables sandboxing; fix the sandbox support (see the Codex documentation for your platform)." + ] + }); + } + if (/permission denied|approval (required|denied)|not permitted/.test(haystack)) { + return runnerError({ + code: "permission_denied", + message: "The Codex CLI reported a permission denial.", + remediation: [ + "SpecBridge never bypasses approvals; narrow the task or adjust the profile sandbox (read-only vs workspace-write)." + ] + }); + } + if (/network|connection|dns|econn|etimedout/.test(haystack)) { + return runnerError({ + code: "network_error", + message: "The Codex CLI reported a network failure.", + remediation: ["Check connectivity and retry explicitly."] + }); + } + return runnerError({ + code: "process_failed", + message: "The Codex CLI exited with a failure.", + remediation: ["Inspect the retained stderr and event log in the run directory."] + }); +} +var CodexCliRunner = class { + name = "codex-cli"; + kind = "codex-cli"; + category = "agent-cli"; + declaredCapabilities = CODEX_DECLARED_CAPABILITIES; + config; + probePromise; + constructor(config2) { + this.config = codexProfileSchema.parse({ runner: "codex-cli", ...config2 ?? {} }); + } + /** Probe once per runner instance; detection is read-only but not free. */ + probe(timeoutMs) { + this.probePromise ??= probeCodex( + this.config, + timeoutMs !== void 0 ? { timeoutMs } : void 0 + ); + return this.probePromise; + } + async detect(context) { + if (!this.config.enabled) { + return { + runner: this.name, + kind: this.kind, + status: "misconfigured", + executable: this.config.command.executable, + authentication: "unknown", + capabilities: [], + diagnostics: [ + { + severity: "error", + code: "RUNNER_DISABLED", + message: "This Codex profile is disabled in .specbridge/config.json (enabled = false). Enable it explicitly to use Codex." + } + ], + category: this.category, + capabilitySet: this.declaredCapabilities, + supportLevel: effectiveSupportLevel("production", "misconfigured"), + networkBacked: false + }; + } + const probe = await this.probe(context.timeoutMs); + return { + runner: this.name, + kind: this.kind, + status: probe.status, + executable: probe.executable, + ...probe.version !== void 0 ? { version: probe.version } : {}, + authentication: probe.authState, + capabilities: probe.capabilities, + diagnostics: probe.diagnostics, + category: this.category, + capabilitySet: codexCapabilitySet(probe), + supportLevel: effectiveSupportLevel("production", probe.status), + // The Codex CLI talks to its provider itself; SpecBridge's own + // transport is a local child process. + networkBacked: false + }; + } + executionBoundaryNote(policy) { + if (policy !== "implementation") { + return "Execution sandbox: read-only (repository inspection only; no file writes, approvals never bypassed)."; + } + const mode = this.config.sandbox === "read-only" ? "read-only" : "workspace-write"; + return `Execution sandbox: ${mode} (writes limited to this repository; no unrestricted filesystem access; approvals and sandbox checks are never disabled).`; + } + listModels(_context) { + return Promise.resolve({ + supported: false, + models: [], + detail: 'The Codex CLI has no officially supported local model-listing command; SpecBridge never guesses provider model names. Configure "model" on the profile explicitly.' + }); + } + async generateStage(input, execution) { + const started = Date.now(); + const probe = await this.probe(); + const unavailable = this.unavailableResult(probe, started); + if (unavailable !== void 0) { + const { report: _report, ...rest2 } = unavailable; + return rest2; + } + const plan = buildCodexInvocation({ + config: this.config, + probe, + prompt: input.prompt, + toolPolicy: input.toolPolicy, + outputJsonSchema: STAGE_RUNNER_REPORT_JSON_SCHEMA, + execution + }); + const processResult = await runCodexInvocation(plan, this.config, execution); + const mapped = this.mapResult(processResult, plan, started, "stage"); + cleanupCodexTempFiles(plan); + const { report, ...rest } = mapped; + const stageReport = report; + return { ...rest, ...stageReport !== void 0 ? { report: stageReport } : {} }; + } + async executeTask(input, execution) { + return this.runTask(input.prompt, execution, {}); + } + async resumeTask(input, execution) { + return this.runTask(input.prompt, execution, { resumeSessionId: input.sessionId }); + } + async runTask(prompt, execution, session) { + const started = Date.now(); + const probe = await this.probe(); + const unavailable = this.unavailableResult(probe, started); + if (unavailable !== void 0) { + const { report: _report, ...rest2 } = unavailable; + return { ...rest2, resumeSupported: false }; + } + const plan = buildCodexInvocation({ + config: this.config, + probe, + prompt, + toolPolicy: "implementation", + outputJsonSchema: TASK_RUNNER_REPORT_JSON_SCHEMA, + ...session.resumeSessionId !== void 0 ? { resumeSessionId: session.resumeSessionId } : {}, + execution + }); + const processResult = await runCodexInvocation(plan, this.config, execution); + const mapped = this.mapResult(processResult, plan, started, "task"); + cleanupCodexTempFiles(plan); + const resumeCapable = this.config.persistSessions && probe.capabilities.find((capability) => capability.id === "resume")?.available === true; + const { report, sessionId, ...rest } = mapped; + const taskReport = report; + const effectiveSession = sessionId ?? session.resumeSessionId; + return { + ...rest, + ...taskReport !== void 0 ? { report: taskReport } : {}, + ...effectiveSession !== void 0 ? { sessionId: effectiveSession } : {}, + resumeSupported: resumeCapable && effectiveSession !== void 0 + }; + } + /** Minimal bounded structured-output probe (`runner test --network`). */ + async selfTest(execution) { + const probe = await this.probe(); + if (probe.status !== "available") { + return { ok: false, detail: `codex-cli is not available (status: ${probe.status})` }; + } + const plan = buildCodexInvocation({ + config: this.config, + probe, + prompt: 'This is a connectivity self test. Do not read or modify any file. Reply with exactly one JSON document: {"schemaVersion":"1.0.0","stage":"requirements","markdown":"# Self Test","summary":"self test"} and nothing else.', + toolPolicy: "read-only", + outputJsonSchema: STAGE_RUNNER_REPORT_JSON_SCHEMA, + execution + }); + const result = await runCodexInvocation(plan, this.config, execution); + const stream = parseCodexEventStream(result.stdout); + const finalText = readLastMessage(plan) ?? stream.lastAgentMessage; + cleanupCodexTempFiles(plan); + if (result.status !== "ok") { + return { + ok: false, + detail: result.failureReason ?? `self test failed (${result.status})`, + process: result.observation + }; + } + const report = finalText !== void 0 ? stageRunnerReportSchema.safeParse(strictJsonParse(finalText)) : void 0; + const usage = usageFromStream(stream, result.observation.durationMs, this.config.model); + return report !== void 0 && report.success ? { + ok: true, + detail: "structured output validated", + process: result.observation, + ...usage !== void 0 ? { usage } : {} + } : { + ok: false, + detail: "the runner responded but did not return a valid structured result", + process: result.observation + }; + } + unavailableResult(probe, started) { + if (probe.status === "available") return void 0; + const error2 = probe.status === "unauthenticated" ? runnerError({ + code: "authentication_required", + message: "The Codex CLI is installed but not authenticated.", + remediation: ['Run "codex login" yourself (SpecBridge never handles credentials).'] + }) : probe.status === "incompatible" ? runnerError({ + code: "runner_incompatible", + message: "The installed Codex CLI version lacks required capabilities.", + remediation: ['Run "specbridge runner doctor" for the exact missing capabilities.'] + }) : probe.status === "misconfigured" ? runnerError({ + code: "runner_disabled", + message: "This Codex profile is disabled.", + remediation: ["Enable the profile in .specbridge/config.json explicitly."] + }) : runnerError({ + code: "executable_not_found", + message: `The Codex CLI executable "${this.config.command.executable}" was not found.`, + remediation: ["Install the Codex CLI or fix the profile command."] + }); + return { + runner: this.name, + outcome: "failed", + failureReason: `the codex-cli runner is not available (status: ${probe.status}); run "specbridge runner doctor" for details`, + rawStdout: "", + rawStderr: "", + durationMs: Date.now() - started, + warnings: probe.diagnostics.filter((d) => d.severity === "error").map((d) => d.message), + error: error2 + }; + } + /** Map a finished process + event stream to a structured runner result. */ + mapResult(processResult, plan, started, reportKind) { + const warnings = plan.skippedFlags.map( + (flag) => `flag ${flag} is unsupported by this Codex CLI version and was skipped` + ); + const stream = parseCodexEventStream(processResult.stdout); + if (stream.truncated) { + warnings.push("the provider event stream exceeded the retention limit; older events were dropped"); + } + const normalizedEvents = normalizeCodexEvents( + stream, + { + runner: this.name, + profile: this.name, + runId: "pending", + attemptId: "pending" + }, + () => (/* @__PURE__ */ new Date()).toISOString() + ); + const usage = usageFromStream(stream, processResult.observation.durationMs, this.config.model); + const base = { + runner: this.name, + // Parsing already happened on the pristine stream; the RETAINED bytes + // carry only safe status metadata for reasoning items. + rawStdout: redactCodexStdoutForRetention(processResult.stdout), + rawStderr: processResult.stderr, + process: processResult.observation, + durationMs: Math.max(0, Date.now() - started), + warnings, + normalizedEvents, + ...usage !== void 0 ? { usage } : {}, + ...stream.threadId !== void 0 ? { sessionId: stream.threadId } : {} + }; + switch (processResult.status) { + case "timeout": + return { + ...base, + outcome: "timed-out", + failureReason: processResult.failureReason ?? "timeout", + error: runnerError({ + code: "timed_out", + message: "The Codex process exceeded the configured timeout and was terminated.", + remediation: ["Increase the profile timeoutMs or narrow the task."] + }) + }; + case "cancelled": + return { + ...base, + outcome: "cancelled", + failureReason: processResult.failureReason ?? "cancelled", + error: runnerError({ + code: "cancelled", + message: "The Codex process was cancelled and terminated." + }) + }; + case "output-limit": + return { + ...base, + outcome: "failed", + failureReason: processResult.failureReason ?? "output limit exceeded", + error: runnerError({ + code: "output_limit_exceeded", + message: "The Codex process exceeded the configured output limit and was terminated.", + remediation: ["Raise maxStdoutBytes/maxStderrBytes on the profile if this was legitimate."] + }) + }; + case "spawn-failed": + return { + ...base, + outcome: "failed", + failureReason: processResult.failureReason ?? "spawn failed", + error: runnerError({ + code: "executable_not_found", + message: `The Codex CLI executable could not be started: ${processResult.failureReason ?? "unknown spawn failure"}.`, + remediation: ["Install the Codex CLI or fix the profile command."] + }) + }; + case "ok": + case "nonzero-exit": + break; + } + if (processResult.status === "nonzero-exit") { + const error2 = classifyCodexFailure(processResult.stderr, stream.errors); + return { + ...base, + outcome: error2.code === "permission_denied" ? "permission-denied" : "failed", + failureReason: `${error2.message} (exit ${processResult.observation.exitCode ?? "unknown"})`, + error: error2 + }; + } + const finalText = readLastMessage(plan) ?? stream.lastAgentMessage; + if (finalText === void 0 || finalText.trim().length === 0) { + return { + ...base, + outcome: "malformed-output", + failureReason: stream.errors.length > 0 ? `the provider reported: ${stream.errors[0]}` : "the runner returned no final agent message", + error: runnerError({ + code: "structured_output_invalid", + message: "The Codex run produced no final structured result.", + remediation: ["Inspect the retained event log in the run directory."] + }) + }; + } + const parsed = strictJsonParse(finalText); + if (parsed === void 0) { + return { + ...base, + outcome: "malformed-output", + failureReason: "the final agent message is not a bare JSON document (extra prose is not accepted)", + error: runnerError({ + code: "structured_output_invalid", + message: "The final Codex message did not parse as a JSON document." + }) + }; + } + const schema = reportKind === "stage" ? stageRunnerReportSchema : taskRunnerReportSchema; + const validated = schema.safeParse(parsed); + if (!validated.success) { + return { + ...base, + outcome: "malformed-output", + failureReason: `structured result does not match the report schema: ${validated.error.issues.map((issue2) => `${issue2.path.join(".") || "(root)"}: ${issue2.message}`).join("; ")}`, + error: runnerError({ + code: "structured_output_invalid", + message: "The final Codex message did not match the required report schema." + }) + }; + } + const report = validated.data; + const outcome = "outcome" in report ? report.outcome : "completed"; + return { + ...base, + outcome, + report, + ...outcome === "completed" || outcome === "no-change" ? {} : { failureReason: `the agent reported "${outcome}"` } + }; + } +}; +function strictJsonParse(raw) { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return void 0; + try { + return JSON.parse(trimmed); + } catch { + return void 0; + } +} +function usageFromStream(stream, durationMs, model) { + if (stream.usage === void 0) return void 0; + return { + model, + inputTokens: stream.usage.inputTokens, + cachedInputTokens: stream.usage.cachedInputTokens, + outputTokens: stream.usage.outputTokens, + reasoningTokens: stream.usage.reasoningTokens, + requestCount: stream.usage.requestCount, + durationMs: Math.max(0, Math.round(durationMs)) + }; +} +var GEMINI_CAPABILITY_PROBES = [ + { + id: "headless", + label: "Headless prompt invocation (--prompt)", + tokens: ["--prompt"], + required: true + }, + { + id: "output-json", + label: "Machine-readable output (--output-format json)", + tokens: ["--output-format"], + required: true + }, + { + id: "output-stream-json", + label: "Streaming machine-readable events (stream-json)", + tokens: ["stream-json"], + required: false, + degradedNote: "the single JSON result envelope is used instead of streamed events" + }, + { + id: "approval-mode", + label: "Approval-mode selection (--approval-mode)", + tokens: ["--approval-mode"], + required: true + }, + { + id: "plan-mode", + label: "Read-only plan approval mode (plan)", + tokens: ["plan"], + required: false, + degradedNote: "authoring needs a read-only tool allowlist instead of plan mode" + }, + { + id: "auto-edit-mode", + label: "Edit-only approval mode (auto_edit)", + tokens: ["auto_edit"], + required: false, + degradedNote: "task execution is unavailable without a bounded edit approval mode" + }, + { + id: "sandbox", + label: "Sandboxed tool execution (--sandbox)", + tokens: ["--sandbox"], + required: false, + degradedNote: "the tool allowlist is the only execution boundary" + }, + { + id: "allowed-tools", + label: "Tool allowlist (--allowed-tools)", + tokens: ["--allowed-tools"], + required: false, + degradedNote: "the sandbox is the only execution boundary" + }, + { + id: "extension-restriction", + label: "Extension restriction (--extensions)", + tokens: ["--extensions"], + required: false, + degradedNote: "installed extensions cannot be disabled for SpecBridge runs" + }, + { + id: "model-selection", + label: "Model selection (--model)", + tokens: ["--model"], + required: false, + degradedNote: "the provider default model is used" + }, + { + id: "session-list", + label: "Session listing (--list-sessions)", + tokens: ["--list-sessions"], + required: false + }, + { + id: "resume", + label: "Explicit session resume (--resume )", + tokens: ["--resume"], + required: false, + degradedNote: "interrupted runs need a fresh attempt instead of a resume" + } +]; +var GEMINI_DECLARED_CAPABILITIES = capabilitySet([ "stageGeneration", "stageRefinement", + "taskExecution", + "taskResume", "structuredFinalOutput", + "streamingEvents", + "repositoryRead", + "repositoryWrite", + "sandbox", + "toolRestriction", "usageReporting", - "localOnly", - "supportsSystemPrompt", - "supportsJsonSchema", + "requiresNetwork", "supportsCancellation" ]); -var RUNNER_OPERATIONS = [ - "stage-generation", - "stage-refinement", - "task-execution", - "task-resume", - "model-list", - "runner-test" +var PROBE_TIMEOUT_MS3 = 15e3; +function tokenPresent2(helpText, token) { + const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[\\s,=<[|])${escaped}(?![\\w-])`, "m").test(helpText); +} +async function probeGemini(config2, options) { + const diagnostics = []; + const timeoutMs = options?.timeoutMs ?? PROBE_TIMEOUT_MS3; + const base = { + executable: config2.command.executable, + commandArgs: config2.command.args + }; + const invoke = (argv) => runSafeProcess({ + executable: config2.command.executable, + argv: [...config2.command.args, ...argv], + cwd: process.cwd(), + timeoutMs, + ...options?.signal !== void 0 ? { signal: options.signal } : {}, + maxStdoutBytes: 1024 * 1024, + maxStderrBytes: 256 * 1024 + }); + const emptyCapabilities = () => GEMINI_CAPABILITY_PROBES.map((probe) => ({ + id: probe.id, + label: probe.label, + available: false, + required: probe.required + })); + const versionResult = await invoke(["--version"]); + if (versionResult.status === "spawn-failed") { + diagnostics.push({ + severity: "error", + code: "RUNNER_EXECUTABLE_NOT_FOUND", + message: `Gemini CLI executable "${config2.command.executable}" could not be started. Install the Gemini CLI (the user installs and authenticates it independently) or set the profile command in .specbridge/config.json.` + }); + return { + ...base, + found: false, + authState: "unknown", + capabilities: emptyCapabilities(), + supportedTokens: /* @__PURE__ */ new Set(), + status: "unavailable", + diagnostics + }; + } + if (versionResult.status !== "ok") { + diagnostics.push({ + severity: "error", + code: "RUNNER_VERSION_FAILED", + message: `"${config2.command.executable} --version" ${versionResult.failureReason ?? "produced no output"}.` + }); + return { + ...base, + found: true, + authState: "unknown", + capabilities: emptyCapabilities(), + supportedTokens: /* @__PURE__ */ new Set(), + status: "error", + diagnostics + }; + } + const version2 = versionResult.stdout.trim().split(/\r?\n/)[0]?.trim(); + const help = await invoke(["--help"]); + const helpText = `${help.stdout} +${help.stderr}`; + const helpUsable = help.status === "ok" && helpText.trim().length > 0; + if (!helpUsable) { + diagnostics.push({ + severity: "error", + code: "RUNNER_HELP_FAILED", + message: `"${config2.command.executable} --help" ${help.failureReason ?? "produced no output"}; capabilities cannot be verified.` + }); + } + const supportedTokens = /* @__PURE__ */ new Set(); + const capabilities = GEMINI_CAPABILITY_PROBES.map((probe) => { + const available2 = helpUsable && probe.tokens.some((token) => tokenPresent2(helpText, token)); + if (available2) for (const token of probe.tokens) supportedTokens.add(token); + return { + id: probe.id, + label: probe.label, + available: available2, + required: probe.required, + ...available2 || probe.degradedNote === void 0 ? {} : { detail: probe.degradedNote } + }; + }); + const authState = "unknown"; + if (helpUsable) { + diagnostics.push({ + severity: "info", + code: "RUNNER_AUTH_PROBE_UNSUPPORTED", + message: 'Authentication cannot be verified without a model request; it is reported as unknown (SpecBridge never reads Google credential files and never starts an interactive login). Use "specbridge runner test --network" for a minimal authenticated probe.' + }); + } + const available = (id) => capabilities.find((capability) => capability.id === id)?.available === true; + const missingRequired = capabilities.filter((c3) => c3.required && !c3.available); + const authoringBoundary = available("plan-mode") || available("allowed-tools"); + let status; + if (missingRequired.length > 0) { + status = "incompatible"; + diagnostics.push({ + severity: "error", + code: "RUNNER_MISSING_CAPABILITY", + message: `This Gemini CLI version is missing required capabilities: ${missingRequired.map((c3) => c3.label).join(", ")}. Update the Gemini CLI to a version with headless prompts, machine-readable output, and approval-mode control.` + }); + } else if (helpUsable && !authoringBoundary) { + status = "incompatible"; + diagnostics.push({ + severity: "error", + code: "RUNNER_MISSING_CAPABILITY", + message: "This Gemini CLI version offers neither a plan approval mode nor a tool allowlist, so a read-only authoring boundary cannot be established. SpecBridge never weakens the boundary (and never uses YOLO) \u2014 update the Gemini CLI." + }); + } else if (!helpUsable) { + status = "error"; + } else { + status = "available"; + for (const capability of capabilities.filter((c3) => !c3.required && !c3.available)) { + diagnostics.push({ + severity: "warning", + code: "RUNNER_DEGRADED_CAPABILITY", + message: `Optional capability unavailable: ${capability.label}${capability.detail !== void 0 ? ` \u2014 ${capability.detail}` : ""}.` + }); + } + if (!available("auto-edit-mode") || !available("allowed-tools") && !available("sandbox")) { + diagnostics.push({ + severity: "warning", + code: "RUNNER_TASK_EXECUTION_UNAVAILABLE", + message: "Task execution is unavailable for this installation: file edits cannot be permitted without also permitting arbitrary shell commands (needs auto_edit plus a tool allowlist or sandbox). Authoring remains available. Use a claude-code or codex-cli profile for task execution." + }); + } + } + return { + ...base, + found: true, + ...version2 !== void 0 && version2.length > 0 ? { version: version2 } : {}, + authState, + capabilities, + supportedTokens, + status, + diagnostics + }; +} +function probeAvailable2(probe, id) { + return probe.capabilities.find((capability) => capability.id === id)?.available === true; +} +function geminiCapabilitySet(probe) { + if (!probe.found) return capabilitySet([]); + const set = { ...GEMINI_DECLARED_CAPABILITIES }; + const headless = probeAvailable2(probe, "headless") && probeAvailable2(probe, "output-json") && probeAvailable2(probe, "approval-mode"); + const plan = probeAvailable2(probe, "plan-mode"); + const allowedTools = probeAvailable2(probe, "allowed-tools"); + const sandbox = probeAvailable2(probe, "sandbox"); + const autoEdit = probeAvailable2(probe, "auto-edit-mode"); + set.stageGeneration = headless && (plan || allowedTools); + set.stageRefinement = set.stageGeneration; + set.structuredFinalOutput = headless; + set.streamingEvents = probeAvailable2(probe, "output-stream-json"); + set.sandbox = sandbox; + set.toolRestriction = allowedTools; + set.taskExecution = headless && autoEdit && (allowedTools || sandbox); + set.repositoryWrite = set.taskExecution; + set.taskResume = set.taskExecution && probeAvailable2(probe, "resume"); + return set; +} +var GEMINI_FORBIDDEN_ARGUMENTS = [ + "--yolo", + "-y", + "--dangerously-skip-permissions", + "--trust-folder", + "--trust" +]; +var GEMINI_ALLOWED_APPROVAL_MODES = ["plan", "default", "auto_edit"]; +var GEMINI_READ_ONLY_TOOLS = [ + "read_file", + "read_many_files", + "list_directory", + "glob", + "search_file_content" +]; +var GEMINI_EDIT_TOOLS = ["replace", "write_file"]; +var GEMINI_FORBIDDEN_TOOLS = [ + "run_shell_command", + "shell", + "bash", + "execute_command", + "terminal" +]; +var SESSION_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +function isExplicitGeminiSessionId(value) { + return SESSION_UUID_PATTERN.test(value); +} +function assertNoForbiddenGeminiArguments(argv) { + for (const argument of argv) { + for (const forbidden of GEMINI_FORBIDDEN_ARGUMENTS) { + if (argument === forbidden) { + throw new SpecBridgeError( + "INVALID_STATE", + `Refusing to invoke the Gemini CLI: the argument vector contains "${forbidden}". SpecBridge never uses YOLO, never skips approvals, and never auto-trusts a workspace.` + ); + } + } + } + const approvalIndex = argv.indexOf("--approval-mode"); + if (approvalIndex >= 0) { + const mode = argv[approvalIndex + 1]; + if (!GEMINI_ALLOWED_APPROVAL_MODES.includes(mode)) { + throw new SpecBridgeError( + "INVALID_STATE", + `Refusing to invoke the Gemini CLI with approval mode "${mode ?? "(missing)"}". Only ${GEMINI_ALLOWED_APPROVAL_MODES.join(", ")} are ever used \u2014 never yolo.` + ); + } + } + const toolsIndex = argv.indexOf("--allowed-tools"); + if (toolsIndex >= 0) { + const tools = (argv[toolsIndex + 1] ?? "").split(","); + for (const tool of tools) { + if (GEMINI_FORBIDDEN_TOOLS.includes(tool.trim().toLowerCase())) { + throw new SpecBridgeError( + "INVALID_STATE", + `Refusing to invoke the Gemini CLI with allowed tool "${tool}". SpecBridge never grants the Gemini CLI arbitrary shell access.` + ); + } + } + } + const resumeIndex = argv.indexOf("--resume"); + if (resumeIndex >= 0) { + const session = argv[resumeIndex + 1]; + if (session === void 0 || !isExplicitGeminiSessionId(session)) { + throw new SpecBridgeError( + "INVALID_STATE", + `Refusing to resume the Gemini session "${session ?? "(missing)"}": resume requires an explicit session UUID \u2014 "latest", indexes, and ambiguous identifiers are never used.` + ); + } + } +} +function buildGeminiInvocation(input) { + const { config: config2, probe, execution } = input; + const argv = [...config2.command.args]; + const skippedFlags = []; + const supports = (token) => probe.supportedTokens.has(token); + const implementation = input.toolPolicy === "implementation"; + argv.push("--prompt"); + const outputFormat = supports("stream-json") ? "stream-json" : "json"; + argv.push("--output-format", outputFormat); + const approvalMode = implementation ? config2.approvalModeForExecution : config2.approvalModeForAuthoring; + argv.push("--approval-mode", approvalMode); + let allowedTools; + if (supports("--allowed-tools")) { + allowedTools = implementation ? [ + ...GEMINI_READ_ONLY_TOOLS, + ...GEMINI_EDIT_TOOLS, + ...config2.allowedTools.filter( + (tool) => !GEMINI_FORBIDDEN_TOOLS.includes(tool.toLowerCase()) + ) + ] : [...GEMINI_READ_ONLY_TOOLS]; + argv.push("--allowed-tools", allowedTools.join(",")); + } else { + skippedFlags.push("--allowed-tools"); + } + if (config2.sandbox) { + if (supports("--sandbox")) argv.push("--sandbox"); + else skippedFlags.push("--sandbox"); + } + if (config2.disabledExtensions) { + if (supports("--extensions")) argv.push("--extensions", "none"); + else skippedFlags.push("--extensions"); + } + const model = execution.model ?? config2.model; + if (model !== null && model !== void 0) { + if (supports("--model")) argv.push("--model", model); + else skippedFlags.push("--model"); + } + if (input.resumeSessionId !== void 0) { + if (!isExplicitGeminiSessionId(input.resumeSessionId)) { + throw new SpecBridgeError( + "INVALID_ARGUMENT", + `Cannot resume Gemini session "${input.resumeSessionId}": an explicit session UUID is required ("latest", indexes, and ambiguous identifiers are never used).` + ); + } + argv.push("--resume", input.resumeSessionId); + } + assertNoForbiddenGeminiArguments(argv); + return { + executable: config2.command.executable, + argv, + stdin: input.prompt, + outputFormat, + approvalMode, + ...allowedTools !== void 0 ? { allowedTools } : {}, + skippedFlags + }; +} +async function runGeminiInvocation(plan, config2, execution) { + assertNoForbiddenGeminiArguments(plan.argv); + return runSafeProcess({ + executable: plan.executable, + argv: plan.argv, + cwd: execution.workspaceRoot, + timeoutMs: execution.timeoutMs, + ...execution.signal !== void 0 ? { signal: execution.signal } : {}, + stdin: plan.stdin, + maxStdoutBytes: config2.maxStdoutBytes, + maxStderrBytes: config2.maxStderrBytes + }); +} +var MAX_RETAINED_GEMINI_EVENTS = 5e3; +var geminiEventSchema = external_exports.object({ + type: external_exports.string(), + session_id: external_exports.string().optional(), + text: external_exports.string().optional(), + name: external_exports.string().optional(), + status: external_exports.string().optional(), + path: external_exports.string().optional(), + kind: external_exports.string().optional(), + command: external_exports.string().optional(), + input_tokens: external_exports.number().optional(), + output_tokens: external_exports.number().optional(), + cached_input_tokens: external_exports.number().optional(), + response: external_exports.string().optional(), + message: external_exports.string().optional() +}).passthrough(); +var geminiJsonEnvelopeSchema = external_exports.object({ + response: external_exports.string(), + stats: external_exports.object({ + session_id: external_exports.string().optional(), + input_tokens: external_exports.number().optional(), + output_tokens: external_exports.number().optional(), + cached_input_tokens: external_exports.number().optional() + }).passthrough().optional() +}).passthrough(); +function parseGeminiEventStream(stdout) { + const stream = { + events: [], + unparseableLines: 0, + truncated: false, + errors: [] + }; + let inputTokens = null; + let cachedInputTokens = null; + let outputTokens = null; + let requests = 0; + for (const line of stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || !trimmed.startsWith("{")) continue; + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch { + stream.unparseableLines += 1; + continue; + } + const event = geminiEventSchema.safeParse(parsed); + if (!event.success) { + stream.unparseableLines += 1; + continue; + } + if (stream.events.length < MAX_RETAINED_GEMINI_EVENTS) { + stream.events.push(event.data); + } else { + stream.truncated = true; + } + const data = event.data; + if (data.type === "session.started" && data.session_id !== void 0) { + stream.sessionId = data.session_id; + } + if (data.type === "usage") { + requests += 1; + inputTokens = (inputTokens ?? 0) + (data.input_tokens ?? 0); + cachedInputTokens = (cachedInputTokens ?? 0) + (data.cached_input_tokens ?? 0); + outputTokens = (outputTokens ?? 0) + (data.output_tokens ?? 0); + } + if (data.type === "result" && data.response !== void 0) { + stream.finalResponse = data.response; + } + if (data.type === "error") { + const message = data.message ?? data.text; + if (message !== void 0 && stream.errors.length < 20) { + stream.errors.push(boundedPayloadText(message, 500)); + } + } + } + if (requests > 0 || inputTokens !== null || outputTokens !== null) { + stream.usage = { + inputTokens, + cachedInputTokens, + outputTokens, + reasoningTokens: null, + requestCount: Math.max(1, requests) + }; + } + return stream; +} +function redactGeminiStdoutForRetention(stdout) { + return stdout.split(/\r?\n/).map((line) => { + const trimmed = line.trim(); + if (!trimmed.startsWith("{") || !trimmed.includes('"thought"')) return line; + try { + const parsed = JSON.parse(trimmed); + if (parsed.type === "thought" && typeof parsed.text === "string") { + parsed.text = `[redacted reasoning: ${parsed.text.length} chars]`; + return JSON.stringify(parsed); + } + } catch { + } + return line; + }).join("\n"); +} +function normalizeGeminiEvents(stream, context, timestamp) { + const normalized = []; + const push = (type, providerEventType, payload) => { + if (normalized.length >= MAX_RETAINED_GEMINI_EVENTS) return; + normalized.push( + normalizedRunnerEventSchema.parse({ + type, + timestamp: timestamp(), + runner: context.runner, + profile: context.profile, + runId: context.runId, + attemptId: context.attemptId, + ...context.providerSessionId !== void 0 || stream.sessionId !== void 0 ? { providerSessionId: context.providerSessionId ?? stream.sessionId } : {}, + providerEventType, + payload + }) + ); + }; + for (const event of stream.events) { + switch (event.type) { + case "session.started": + push("session.started", event.type, { + ...event.session_id !== void 0 ? { sessionId: event.session_id } : {} + }); + break; + case "thought": + push("message.completed", event.type, { + redacted: true, + textLength: event.text?.length ?? 0 + }); + break; + case "tool.started": + push("tool.started", event.type, { + ...event.name !== void 0 ? { tool: event.name } : {}, + ...event.path !== void 0 ? { path: boundedPayloadText(event.path, 500) } : {} + }); + break; + case "tool.completed": + push( + event.status === "failed" || event.status === "denied" ? "tool.failed" : "tool.completed", + event.type, + { + ...event.name !== void 0 ? { tool: event.name } : {}, + ...event.status !== void 0 ? { status: event.status } : {} + } + ); + break; + case "file.edited": + push("file.changed", event.type, { + ...event.path !== void 0 ? { path: boundedPayloadText(event.path, 500) } : {}, + ...event.kind !== void 0 ? { kind: event.kind } : {} + }); + break; + case "usage": + push("usage.updated", event.type, { + inputTokens: event.input_tokens ?? null, + cachedInputTokens: event.cached_input_tokens ?? null, + outputTokens: event.output_tokens ?? null + }); + break; + case "result": + push("message.completed", event.type, { + textLength: event.response?.length ?? 0 + }); + break; + case "error": + push("error", event.type, { + message: boundedPayloadText(event.message ?? event.text ?? "error", 500) + }); + break; + default: + break; + } + } + return normalized; +} +function classifyGeminiFailure(stderr, streamErrors) { + const haystack = `${stderr} +${streamErrors.join("\n")}`.toLowerCase(); + if (/please sign in|not logged in|login required|unauthorized|unauthenticated|401/.test(haystack)) { + return runnerError({ + code: "authentication_required", + message: "The Gemini CLI reported an authentication failure.", + remediation: [ + "Authenticate the Gemini CLI yourself (SpecBridge never handles credentials and never starts a login flow)." + ] + }); + } + if (/resource_exhausted|quota exceeded|out of quota|usage limit/.test(haystack)) { + return runnerError({ + code: "quota_exceeded", + message: "The provider reported an exhausted quota or usage limit.", + remediation: ["Check your provider plan and usage, then retry explicitly."] + }); + } + if (/rate limit|too many requests|429/.test(haystack)) { + return runnerError({ + code: "rate_limited", + message: "The provider reported a rate limit.", + remediation: ["Wait and retry explicitly."], + providerCode: "429" + }); + } + if (/permission denied|approval (required|denied)|call rejected|not permitted/.test(haystack)) { + return runnerError({ + code: "permission_denied", + message: "The Gemini CLI reported a permission denial.", + remediation: [ + "SpecBridge never bypasses approvals (and never uses YOLO); narrow the task so it needs only repository reads and file edits." + ] + }); + } + if (/network|connection|dns|econn|etimedout/.test(haystack)) { + return runnerError({ + code: "network_error", + message: "The Gemini CLI reported a network failure.", + remediation: ["Check connectivity and retry explicitly."] + }); + } + return runnerError({ + code: "process_failed", + message: "The Gemini CLI exited with a failure.", + remediation: ["Inspect the retained stderr and event log in the run directory."] + }); +} +var TASK_EXECUTION_REMEDIATION = [ + "Authoring may remain available through the read-only boundary.", + 'Use a claude-code or codex-cli profile for task execution ("specbridge runner list" shows compatible profiles).' ]; +var GeminiCliRunner = class { + name = "gemini-cli"; + kind = "gemini-cli"; + category = "agent-cli"; + declaredCapabilities = GEMINI_DECLARED_CAPABILITIES; + /** Orchestration may perform ONE structured-output correction retry. */ + supportsStructuredOutputCorrection = true; + config; + probePromise; + constructor(config2) { + this.config = geminiProfileSchema.parse({ runner: "gemini-cli", ...config2 ?? {} }); + } + /** Probe once per runner instance; detection is read-only but not free. */ + probe(timeoutMs) { + this.probePromise ??= probeGemini( + this.config, + timeoutMs !== void 0 ? { timeoutMs } : void 0 + ); + return this.probePromise; + } + async detect(context) { + if (!this.config.enabled) { + return { + runner: this.name, + kind: this.kind, + status: "misconfigured", + executable: this.config.command.executable, + authentication: "unknown", + capabilities: [], + diagnostics: [ + { + severity: "error", + code: "RUNNER_DISABLED", + message: "This Gemini profile is disabled in .specbridge/config.json (enabled = false). Enable it explicitly to use the Gemini CLI." + } + ], + category: this.category, + capabilitySet: this.declaredCapabilities, + supportLevel: effectiveSupportLevel("production", "misconfigured"), + networkBacked: false + }; + } + const probe = await this.probe(context.timeoutMs); + return { + runner: this.name, + kind: this.kind, + status: probe.status, + executable: probe.executable, + ...probe.version !== void 0 ? { version: probe.version } : {}, + authentication: probe.authState, + capabilities: probe.capabilities, + diagnostics: probe.diagnostics, + category: this.category, + capabilitySet: geminiCapabilitySet(probe), + supportLevel: effectiveSupportLevel("production", probe.status), + // The Gemini CLI talks to its provider itself; SpecBridge's own + // transport is a local child process. + networkBacked: false + }; + } + executionBoundaryNote(policy) { + if (policy !== "implementation") { + return "Gemini plan mode / read-only tool allowlist: repository inspection only; no file writes; YOLO is never used."; + } + return `Gemini ${this.config.approvalModeForExecution} boundary: repository reads and file edits only; no arbitrary shell access; extensions disabled where supported; YOLO is never used.`; + } + listModels(_context) { + return Promise.resolve({ + supported: false, + models: [], + detail: 'The Gemini CLI has no officially supported local model-listing command that avoids a model request; SpecBridge never guesses provider model names. Configure "model" on the profile explicitly.' + }); + } + async generateStage(input, execution) { + const started = Date.now(); + const probe = await this.probe(); + const unavailable = this.unavailableResult(probe, started); + if (unavailable !== void 0) { + const { report: _report, ...rest2 } = unavailable; + return rest2; + } + const detected = geminiCapabilitySet(probe); + if (!detected.stageGeneration) { + const { report: _refusalReport, ...refusal } = this.capabilityRefusal( + started, + "authoring needs a proven read-only boundary (plan approval mode or a tool allowlist)", + ["Update the Gemini CLI to a version with plan mode or --allowed-tools."] + ); + return refusal; + } + let prompt = input.prompt; + if (input.correction !== void 0) { + prompt = `${input.prompt} + +Your previous response was not a valid structured result. Validation problems: ${input.correction.problems}. Return ONLY one corrected JSON document matching the required schema \u2014 no prose, no code fences.`; + } + const plan = buildGeminiInvocation({ + config: this.config, + probe, + prompt, + toolPolicy: input.toolPolicy, + execution + }); + const processResult = await runGeminiInvocation(plan, this.config, execution); + const mapped = this.mapResult(processResult, plan, started, "stage"); + const { report, ...rest } = mapped; + const stageReport = report; + return { ...rest, ...stageReport !== void 0 ? { report: stageReport } : {} }; + } + async executeTask(input, execution) { + return this.runTask(input.prompt, execution, {}); + } + async resumeTask(input, execution) { + if (!isExplicitGeminiSessionId(input.sessionId)) { + return { + runner: this.name, + outcome: "failed", + failureReason: `"${input.sessionId}" is not an explicit Gemini session UUID; "latest", indexes, and ambiguous identifiers are never resumed`, + rawStdout: "", + rawStderr: "", + durationMs: 0, + warnings: [], + resumeSupported: false, + error: runnerError({ + code: "unsupported_operation", + message: "Gemini resume requires the explicit session UUID captured from the original run." + }) + }; + } + return this.runTask(input.prompt, execution, { resumeSessionId: input.sessionId }); + } + async runTask(prompt, execution, session) { + const started = Date.now(); + const probe = await this.probe(); + const unavailable = this.unavailableResult(probe, started); + if (unavailable !== void 0) { + const { report: _report, ...rest2 } = unavailable; + return { ...rest2, resumeSupported: false }; + } + const detected = geminiCapabilitySet(probe); + if (!detected.taskExecution) { + const refusal = this.capabilityRefusal( + started, + "file edits cannot be permitted without also permitting arbitrary shell commands (needs the auto_edit approval mode plus a tool allowlist or sandbox); SpecBridge never relaxes this policy and never uses YOLO", + TASK_EXECUTION_REMEDIATION + ); + const { report: _report, ...rest2 } = refusal; + return { ...rest2, resumeSupported: false }; + } + if (session.resumeSessionId !== void 0 && !detected.taskResume) { + const refusal = this.capabilityRefusal( + started, + "this Gemini CLI version does not support explicit session resume; start a fresh attempt instead", + ["Re-run the task without --resume; a new attempt is recorded append-only."] + ); + const { report: _report, ...rest2 } = refusal; + return { ...rest2, resumeSupported: false }; + } + const plan = buildGeminiInvocation({ + config: this.config, + probe, + prompt, + toolPolicy: "implementation", + ...session.resumeSessionId !== void 0 ? { resumeSessionId: session.resumeSessionId } : {}, + execution + }); + const processResult = await runGeminiInvocation(plan, this.config, execution); + const mapped = this.mapResult(processResult, plan, started, "task"); + if (session.resumeSessionId !== void 0 && mapped.sessionId !== void 0 && mapped.sessionId !== session.resumeSessionId) { + const { report: _report, ...rest2 } = mapped; + return { + ...rest2, + outcome: "failed", + failureReason: `the provider continued session ${mapped.sessionId} instead of the requested ${session.resumeSessionId}; the resume is not claimed as successful`, + error: runnerError({ + code: "api_error", + message: "The Gemini session identity changed unexpectedly during resume.", + remediation: [ + "Inspect the retained events, then start a fresh attempt (run lineage is preserved)." + ], + retryable: false, + providerCode: "session-mismatch" + }), + resumeSupported: false + }; + } + const { report, sessionId, ...rest } = mapped; + const taskReport = report; + const effectiveSession = sessionId ?? session.resumeSessionId; + return { + ...rest, + ...taskReport !== void 0 ? { report: taskReport } : {}, + ...effectiveSession !== void 0 ? { sessionId: effectiveSession } : {}, + resumeSupported: detected.taskResume && effectiveSession !== void 0 && isExplicitGeminiSessionId(effectiveSession) + }; + } + /** Minimal bounded structured-output probe (`runner test --network`). */ + async selfTest(execution) { + const probe = await this.probe(); + if (probe.status !== "available") { + return { ok: false, detail: `gemini-cli is not available (status: ${probe.status})` }; + } + const result = await this.generateStage( + { + specName: "runner-self-test", + stage: "requirements", + intent: "generate", + prompt: 'This is a connectivity self test. Do not read or modify any file. Reply with exactly one JSON document: {"schemaVersion":"1.0.0","stage":"requirements","markdown":"# Self Test","summary":"self test"} and nothing else.\n\nStage to produce: requirements\n', + promptVersion: "self-test", + toolPolicy: "read-only" + }, + { ...execution, timeoutMs: Math.min(execution.timeoutMs, 12e4) } + ); + return { + ok: result.outcome === "completed" && result.report !== void 0, + detail: result.outcome === "completed" ? "structured output validated" : result.failureReason ?? `self test failed (${result.outcome})`, + ...result.usage !== void 0 ? { usage: result.usage } : {}, + ...result.process !== void 0 ? { process: result.process } : {} + }; + } + capabilityRefusal(started, reason, remediation) { + return { + runner: this.name, + outcome: "failed", + failureReason: `the installed Gemini CLI is incompatible with this operation: ${reason}`, + rawStdout: "", + rawStderr: "", + durationMs: Math.max(0, Date.now() - started), + warnings: [], + error: runnerError({ + code: "runner_incompatible", + message: `The installed Gemini CLI lacks required safety capabilities: ${reason}.`, + remediation + }) + }; + } + unavailableResult(probe, started) { + if (probe.status === "available") return void 0; + const error2 = probe.status === "incompatible" ? runnerError({ + code: "runner_incompatible", + message: "The installed Gemini CLI version lacks required capabilities.", + remediation: ['Run "specbridge runner doctor" for the exact missing capabilities.'] + }) : probe.status === "misconfigured" ? runnerError({ + code: "runner_disabled", + message: "This Gemini profile is disabled.", + remediation: ["Enable the profile in .specbridge/config.json explicitly."] + }) : probe.status === "error" ? runnerError({ + code: "process_failed", + message: "The Gemini CLI could not be probed.", + remediation: ['Run "specbridge runner doctor" for details.'] + }) : runnerError({ + code: "executable_not_found", + message: `The Gemini CLI executable "${this.config.command.executable}" was not found.`, + remediation: ["Install the Gemini CLI or fix the profile command."] + }); + return { + runner: this.name, + outcome: "failed", + failureReason: `the gemini-cli runner is not available (status: ${probe.status}); run "specbridge runner doctor" for details`, + rawStdout: "", + rawStderr: "", + durationMs: Date.now() - started, + warnings: probe.diagnostics.filter((d) => d.severity === "error").map((d) => d.message), + error: error2 + }; + } + /** Map a finished process + machine-readable output to a structured result. */ + mapResult(processResult, plan, started, reportKind) { + const warnings = plan.skippedFlags.map( + (flag) => `flag ${flag} is unsupported by this Gemini CLI version and was skipped` + ); + let stream; + let finalText; + let sessionId; + let usage; + let retainedStdout = processResult.stdout; + if (plan.outputFormat === "stream-json") { + stream = parseGeminiEventStream(processResult.stdout); + if (stream.truncated) { + warnings.push("the provider event stream exceeded the retention limit; older events were dropped"); + } + finalText = stream.finalResponse; + sessionId = stream.sessionId; + if (stream.usage !== void 0) { + usage = { + model: this.config.model, + inputTokens: stream.usage.inputTokens, + cachedInputTokens: stream.usage.cachedInputTokens, + outputTokens: stream.usage.outputTokens, + reasoningTokens: stream.usage.reasoningTokens, + requestCount: stream.usage.requestCount, + durationMs: Math.max(0, processResult.observation.durationMs) + }; + } + retainedStdout = redactGeminiStdoutForRetention(processResult.stdout); + } else { + const envelope = geminiJsonEnvelopeSchema.safeParse(safeJson(processResult.stdout)); + if (envelope.success) { + finalText = envelope.data.response; + sessionId = envelope.data.stats?.session_id; + if (envelope.data.stats !== void 0) { + usage = { + model: this.config.model, + inputTokens: envelope.data.stats.input_tokens ?? null, + cachedInputTokens: envelope.data.stats.cached_input_tokens ?? null, + outputTokens: envelope.data.stats.output_tokens ?? null, + reasoningTokens: null, + requestCount: 1, + durationMs: Math.max(0, processResult.observation.durationMs) + }; + } + } + } + const normalizedEvents = stream !== void 0 ? normalizeGeminiEvents( + stream, + { runner: this.name, profile: this.name, runId: "pending", attemptId: "pending" }, + () => (/* @__PURE__ */ new Date()).toISOString() + ) : void 0; + const base = { + runner: this.name, + rawStdout: retainedStdout, + rawStderr: processResult.stderr, + process: processResult.observation, + durationMs: Math.max(0, Date.now() - started), + warnings, + ...normalizedEvents !== void 0 ? { normalizedEvents } : {}, + ...usage !== void 0 ? { usage } : {}, + ...sessionId !== void 0 ? { sessionId } : {} + }; + switch (processResult.status) { + case "timeout": + return { + ...base, + outcome: "timed-out", + failureReason: processResult.failureReason ?? "timeout", + error: runnerError({ + code: "timed_out", + message: "The Gemini process exceeded the configured timeout and was terminated.", + remediation: ["Increase the profile timeoutMs or narrow the task."] + }) + }; + case "cancelled": + return { + ...base, + outcome: "cancelled", + failureReason: processResult.failureReason ?? "cancelled", + error: runnerError({ + code: "cancelled", + message: "The Gemini process was cancelled and terminated." + }) + }; + case "output-limit": + return { + ...base, + outcome: "failed", + failureReason: processResult.failureReason ?? "output limit exceeded", + error: runnerError({ + code: "output_limit_exceeded", + message: "The Gemini process exceeded the configured output limit and was terminated.", + remediation: ["Raise maxStdoutBytes/maxStderrBytes on the profile if this was legitimate."] + }) + }; + case "spawn-failed": + return { + ...base, + outcome: "failed", + failureReason: processResult.failureReason ?? "spawn failed", + error: runnerError({ + code: "executable_not_found", + message: `The Gemini CLI executable could not be started: ${processResult.failureReason ?? "unknown spawn failure"}.`, + remediation: ["Install the Gemini CLI or fix the profile command."] + }) + }; + case "ok": + case "nonzero-exit": + break; + } + if (processResult.status === "nonzero-exit") { + const error2 = classifyGeminiFailure(processResult.stderr, stream?.errors ?? []); + return { + ...base, + outcome: error2.code === "permission_denied" ? "permission-denied" : "failed", + failureReason: `${error2.message} (exit ${processResult.observation.exitCode ?? "unknown"})`, + error: error2 + }; + } + if (finalText === void 0 || finalText.trim().length === 0) { + return { + ...base, + outcome: "malformed-output", + failureReason: stream !== void 0 && stream.errors.length > 0 ? `the provider reported: ${stream.errors[0]}` : "the runner returned no final structured result", + error: runnerError({ + code: "structured_output_invalid", + message: "The Gemini run produced no final structured result.", + remediation: ["Inspect the retained output in the run directory."] + }) + }; + } + const parsed = strictJsonParse2(finalText); + if (parsed === void 0) { + return { + ...base, + outcome: "malformed-output", + failureReason: "the final response is not a bare JSON document (extra prose is not accepted)", + error: runnerError({ + code: "structured_output_invalid", + message: "The final Gemini response did not parse as a JSON document." + }), + ...reportKind === "stage" ? { invalidStructuredOutput: finalText.length > 1e5 ? finalText.slice(0, 1e5) : finalText } : {} + }; + } + const schema = reportKind === "stage" ? stageRunnerReportSchema : taskRunnerReportSchema; + const validated = schema.safeParse(parsed); + if (!validated.success) { + const problems = validated.error.issues.map((issue2) => `${issue2.path.join(".") || "(root)"}: ${issue2.message}`).join("; "); + return { + ...base, + outcome: "malformed-output", + failureReason: `structured result does not match the report schema: ${problems}`, + error: runnerError({ + code: "structured_output_invalid", + message: "The final Gemini response did not match the required report schema.", + details: { problems: problems.slice(0, 2e3) } + }), + ...reportKind === "stage" ? { invalidStructuredOutput: finalText.length > 1e5 ? finalText.slice(0, 1e5) : finalText } : {} + }; + } + const report = validated.data; + const outcome = "outcome" in report ? report.outcome : "completed"; + return { + ...base, + outcome, + report, + ...outcome === "completed" || outcome === "no-change" ? {} : { failureReason: `the agent reported "${outcome}"` } + }; + } +}; +function safeJson(raw) { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{")) return void 0; + try { + return JSON.parse(trimmed); + } catch { + return void 0; + } +} +function strictJsonParse2(raw) { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return void 0; + try { + return JSON.parse(trimmed); + } catch { + return void 0; + } +} +function composeSignals(timeoutMs, external) { + const signals2 = [AbortSignal.timeout(timeoutMs)]; + if (external !== void 0) signals2.push(external); + return AbortSignal.any(signals2); +} +async function readBounded(response, maxBytes) { + const reader = response.body?.getReader(); + if (reader === void 0) { + const text = await response.text(); + return import_buffer3.Buffer.byteLength(text, "utf8") > maxBytes ? "too-large" : { text, bytes: import_buffer3.Buffer.byteLength(text, "utf8") }; + } + const chunks = []; + let total = 0; + for (; ; ) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + return "too-large"; + } + chunks.push(value); + } + return { text: import_buffer3.Buffer.concat(chunks).toString("utf8"), bytes: total }; +} +function checkRedirectTarget(current, location) { + let next; + try { + next = new URL(location, current); + } catch { + return { ok: false, detail: `the redirect target "${location.slice(0, 200)}" is not a valid URL` }; + } + if (next.protocol !== "http:" && next.protocol !== "https:") { + return { + ok: false, + detail: `the redirect target uses the unsupported scheme "${next.protocol}"` + }; + } + if (current.protocol === "https:" && next.protocol === "http:") { + return { + ok: false, + detail: "the redirect would downgrade HTTPS to plain HTTP; downgrades are never followed" + }; + } + if (next.username !== "" || next.password !== "") { + return { ok: false, detail: "the redirect target embeds credentials; it is never followed" }; + } + return { ok: true, nextUrl: next }; +} +async function safeHttpRequest(request) { + const started = Date.now(); + const duration3 = () => Math.max(0, Date.now() - started); + const externalAborted = () => request.signal?.aborted === true; + const maxRedirects = request.maxRedirects ?? 0; + const initialUrl = new URL(request.url); + const initialOrigin = initialUrl.origin; + let currentUrl = initialUrl; + let currentMethod = request.method; + let sendBody = request.body !== void 0; + let crossedOrigin = false; + let redirectCount = 0; + let response; + for (; ; ) { + const headers = {}; + if (sendBody) headers["content-type"] = "application/json"; + if (request.headers !== void 0 && !crossedOrigin) { + for (const [name, value] of Object.entries(request.headers)) headers[name] = value; + } + try { + response = await fetch(currentUrl.toString(), { + method: currentMethod, + redirect: "manual", + signal: composeSignals(request.timeoutMs, request.signal), + headers, + ...sendBody ? { body: JSON.stringify(request.body) } : {} + }); + } catch (cause) { + if (externalAborted()) { + return { ok: false, kind: "cancelled", detail: "the request was cancelled", durationMs: duration3() }; + } + if (cause instanceof Error && (cause.name === "TimeoutError" || cause.name === "AbortError")) { + return { + ok: false, + kind: "timeout", + detail: `the request did not complete within ${request.timeoutMs} ms`, + durationMs: duration3() + }; + } + const message = cause instanceof Error ? cause.message : String(cause); + return { + ok: false, + kind: "unreachable", + detail: `the endpoint could not be reached (${message.slice(0, 300)})`, + durationMs: duration3() + }; + } + if (response.status < 300 || response.status >= 400) break; + if (redirectCount >= maxRedirects) { + return { + ok: false, + kind: "redirect-rejected", + status: response.status, + detail: maxRedirects === 0 ? `the endpoint answered with a redirect (${response.status}); redirects are never followed` : `the endpoint exceeded the bounded redirect limit of ${maxRedirects}`, + durationMs: duration3() + }; + } + const location = response.headers.get("location"); + if (location === null || location.length === 0) { + return { + ok: false, + kind: "redirect-rejected", + status: response.status, + detail: `the endpoint answered with a redirect (${response.status}) without a target`, + durationMs: duration3() + }; + } + const decision = checkRedirectTarget(currentUrl, location); + if (!decision.ok || decision.nextUrl === void 0) { + return { + ok: false, + kind: "redirect-rejected", + status: response.status, + detail: decision.detail ?? "the redirect was rejected", + durationMs: duration3() + }; + } + redirectCount += 1; + if (decision.nextUrl.origin !== initialOrigin) crossedOrigin = true; + if (response.status === 303 || currentMethod === "POST" && (response.status === 301 || response.status === 302)) { + currentMethod = "GET"; + sendBody = false; + } + currentUrl = decision.nextUrl; + } + const redirects = redirectCount > 0 ? { count: redirectCount, finalUrl: currentUrl.toString(), crossOrigin: crossedOrigin } : void 0; + let body; + try { + body = await readBounded(response, request.maxResponseBytes); + } catch (cause) { + if (externalAborted()) { + return { ok: false, kind: "cancelled", detail: "the request was cancelled", durationMs: duration3() }; + } + if (cause instanceof Error && (cause.name === "TimeoutError" || cause.name === "AbortError")) { + return { + ok: false, + kind: "timeout", + detail: `the response body did not complete within ${request.timeoutMs} ms`, + durationMs: duration3() + }; + } + return { + ok: false, + kind: "unreachable", + detail: `the response body could not be read (${cause instanceof Error ? cause.message.slice(0, 300) : "unknown error"})`, + durationMs: duration3() + }; + } + if (body === "too-large") { + return { + ok: false, + kind: "response-too-large", + status: response.status, + detail: `the response exceeded the configured limit of ${request.maxResponseBytes} bytes and was aborted`, + durationMs: duration3() + }; + } + if (!response.ok) { + return { + ok: false, + kind: "http-error", + status: response.status, + detail: `the endpoint answered HTTP ${response.status}`, + durationMs: duration3(), + bodyExcerpt: body.text.slice(0, 500) + }; + } + if (request.expectJson === true) { + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + return { + ok: false, + kind: "invalid-content-type", + status: response.status, + detail: `expected application/json but the endpoint answered "${contentType.slice(0, 100) || "(none)"}"`, + durationMs: duration3() + }; + } + } + return { + ok: true, + status: response.status, + bodyText: body.text, + bodyBytes: body.bytes, + durationMs: duration3(), + ...redirects !== void 0 ? { redirects } : {} + }; +} +var ollamaVersionResponseSchema = external_exports.object({ version: external_exports.string() }).passthrough(); +var ollamaModelSchema = external_exports.object({ + name: external_exports.string(), + size: external_exports.number().optional(), + modified_at: external_exports.string().optional(), + details: external_exports.object({ + family: external_exports.string().optional(), + parameter_size: external_exports.string().optional(), + quantization_level: external_exports.string().optional() + }).passthrough().optional() +}).passthrough(); +var ollamaTagsResponseSchema = external_exports.object({ models: external_exports.array(ollamaModelSchema).default([]) }).passthrough(); +var ollamaChatResponseSchema = external_exports.object({ + model: external_exports.string().optional(), + message: external_exports.object({ + role: external_exports.string().optional(), + content: external_exports.string().default(""), + thinking: external_exports.string().optional() + }).passthrough(), + done: external_exports.boolean().optional(), + prompt_eval_count: external_exports.number().optional(), + eval_count: external_exports.number().optional(), + total_duration: external_exports.number().optional() +}).passthrough(); +function endpoint(config2, pathName) { + return new URL(pathName, config2.baseUrl.endsWith("/") ? config2.baseUrl : `${config2.baseUrl}/`).toString(); +} +var PROBE_TIMEOUT_MS4 = 1e4; +var PROBE_MAX_BYTES = 1024 * 1024; +function fetchOllamaVersion(config2, signal) { + return safeHttpRequest({ + method: "GET", + url: endpoint(config2, "api/version"), + timeoutMs: PROBE_TIMEOUT_MS4, + maxResponseBytes: PROBE_MAX_BYTES, + ...signal !== void 0 ? { signal } : {}, + expectJson: true + }); +} +function fetchOllamaModels(config2, signal) { + return safeHttpRequest({ + method: "GET", + url: endpoint(config2, "api/tags"), + timeoutMs: PROBE_TIMEOUT_MS4, + maxResponseBytes: PROBE_MAX_BYTES, + ...signal !== void 0 ? { signal } : {}, + expectJson: true + }); +} +function postOllamaChat(config2, request) { + return safeHttpRequest({ + method: "POST", + url: endpoint(config2, "api/chat"), + body: { + model: request.model, + messages: request.messages, + stream: false, + format: request.format, + options: { temperature: request.temperature } + }, + timeoutMs: request.timeoutMs, + maxResponseBytes: request.maxResponseBytes, + ...request.signal !== void 0 ? { signal: request.signal } : {}, + expectJson: true + }); +} +function redactOllamaResponseForRetention(bodyText) { + try { + const parsed = JSON.parse(bodyText); + if (parsed !== null && typeof parsed === "object") { + const record2 = parsed; + const message = record2["message"]; + if (message !== null && typeof message === "object") { + const messageRecord = { ...message }; + if (typeof messageRecord["thinking"] === "string") { + messageRecord["thinking"] = `[redacted thinking: ${messageRecord["thinking"].length} chars]`; + } + record2["message"] = messageRecord; + } + return `${JSON.stringify(record2, null, 2)} +`; + } + } catch { + } + return bodyText.length > 1e4 ? `${bodyText.slice(0, 1e4)}\u2026 [truncated]` : bodyText; +} +var OLLAMA_DECLARED_CAPABILITIES = capabilitySet([ + "stageGeneration", + "stageRefinement", + "structuredFinalOutput", + "usageReporting", + "localOnly", + "supportsSystemPrompt", + "supportsJsonSchema", + "supportsCancellation" +]); +function classifyHttpFailure(result) { + switch (result.kind) { + case "timeout": + return { + outcome: "timed-out", + failureReason: result.detail, + error: runnerError({ code: "timed_out", message: `The Ollama request timed out: ${result.detail}.` }) + }; + case "cancelled": + return { + outcome: "cancelled", + failureReason: result.detail, + error: runnerError({ code: "cancelled", message: "The Ollama request was cancelled." }) + }; + case "response-too-large": + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "output_limit_exceeded", + message: `The Ollama response exceeded the configured size limit.`, + remediation: ["Raise maximumOutputBytes on the profile if this was legitimate."] + }) + }; + case "redirect-rejected": + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "endpoint_unreachable", + message: "The Ollama endpoint answered with a redirect, which is never followed.", + remediation: ["Configure the final endpoint URL directly."], + retryable: false + }) + }; + case "invalid-content-type": + return { + outcome: "malformed-output", + failureReason: result.detail, + error: runnerError({ + code: "api_error", + message: `The Ollama endpoint returned an unexpected content type.`, + retryable: false + }) + }; + case "http-error": { + const status = result.status ?? 0; + if (status === 429) { + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "rate_limited", + message: "The Ollama endpoint reported a rate limit (HTTP 429).", + providerCode: "429" + }) + }; + } + if (status === 401 || status === 403) { + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "authentication_required", + message: `The Ollama endpoint refused the request (HTTP ${status}).`, + providerCode: String(status) + }) + }; + } + if (status === 404 && (result.bodyExcerpt ?? "").toLowerCase().includes("model")) { + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "model_not_found", + message: "The configured model is not available on the Ollama endpoint.", + remediation: ['List local models with "specbridge runner models ".'], + providerCode: "404" + }) + }; + } + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "api_error", + message: `The Ollama endpoint answered HTTP ${status}.`, + providerCode: String(status), + retryable: status >= 500 + }) + }; + } + case "unreachable": + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "endpoint_unreachable", + message: "The Ollama endpoint could not be reached.", + remediation: ["Start Ollama locally (`ollama serve`) or fix the profile baseUrl."] + }) + }; + } +} +var OllamaRunner = class { + name = "ollama"; + kind = "ollama"; + category = "model-api"; + declaredCapabilities = OLLAMA_DECLARED_CAPABILITIES; + /** Orchestration may perform ONE structured-output correction retry. */ + supportsStructuredOutputCorrection = true; + config; + constructor(config2) { + this.config = ollamaProfileSchema.parse({ runner: "ollama", ...config2 ?? {} }); + } + get baseUrl() { + return this.config.baseUrl; + } + urlValidation() { + return validateRunnerBaseUrl(this.config.baseUrl, { + allowInsecureHttp: this.config.allowInsecureHttp + }); + } + profileCapabilities(loopback) { + return { + ...OLLAMA_DECLARED_CAPABILITIES, + localOnly: loopback, + requiresNetwork: !loopback + }; + } + async detect(context) { + const diagnostics = []; + const url = this.urlValidation(); + const capabilities = []; + const base = { + runner: this.name, + kind: "ollama", + executable: this.config.baseUrl, + authentication: "not-applicable", + category: this.category, + capabilitySet: this.profileCapabilities(url.loopback), + networkBacked: !url.loopback + }; + if (!this.config.enabled) { + diagnostics.push({ + severity: "error", + code: "RUNNER_DISABLED", + message: "This Ollama profile is disabled in .specbridge/config.json (enabled = false). Enable it explicitly to use Ollama for spec authoring." + }); + return { ...base, status: "misconfigured", capabilities, diagnostics, supportLevel: "production" }; + } + if (!url.ok) { + for (const problem of url.problems) { + diagnostics.push({ severity: "error", code: "RUNNER_ENDPOINT_INVALID", message: `baseUrl: ${problem}` }); + } + return { ...base, status: "misconfigured", capabilities, diagnostics, supportLevel: "production" }; + } + if (!url.loopback) { + diagnostics.push({ + severity: "warning", + code: "RUNNER_NETWORK_BACKED", + message: `The endpoint ${url.hostname ?? ""} is not loopback: requests leave this machine (network-backed). Explicit selection is required.` + }); + } + const signal = context.timeoutMs !== void 0 ? AbortSignal.timeout(context.timeoutMs) : void 0; + const versionResult = await fetchOllamaVersion(this.config, signal); + if (!versionResult.ok) { + diagnostics.push({ + severity: "error", + code: "RUNNER_ENDPOINT_UNREACHABLE", + message: `The Ollama endpoint is unreachable: ${versionResult.detail}. Start it with "ollama serve" or fix the profile baseUrl.` + }); + capabilities.push({ id: "endpoint", label: "Endpoint reachable", available: false, required: true }); + return { ...base, status: "unavailable", capabilities, diagnostics, supportLevel: "unavailable" }; + } + capabilities.push({ id: "endpoint", label: "Endpoint reachable", available: true, required: true }); + let version2; + const versionParsed = ollamaVersionResponseSchema.safeParse(safeJson2(versionResult.bodyText)); + if (versionParsed.success) version2 = versionParsed.data.version; + const tagsResult = await fetchOllamaModels(this.config, signal); + let modelNames = []; + if (tagsResult.ok) { + const tags = ollamaTagsResponseSchema.safeParse(safeJson2(tagsResult.bodyText)); + if (tags.success) modelNames = tags.data.models.map((model) => model.name); + capabilities.push({ id: "model-list", label: "Model listing", available: tags.success, required: false }); + } else { + capabilities.push({ id: "model-list", label: "Model listing", available: false, required: false }); + diagnostics.push({ + severity: "warning", + code: "RUNNER_MODEL_LIST_FAILED", + message: `Model listing failed: ${tagsResult.detail}.` + }); + } + capabilities.push({ + id: "structured-output", + label: "Structured output (JSON Schema format field)", + available: true, + required: true, + detail: "validated by SpecBridge with a bounded correction retry" + }); + let status = "available"; + if (this.config.model === null) { + status = "misconfigured"; + diagnostics.push({ + severity: "error", + code: "RUNNER_MODEL_NOT_CONFIGURED", + message: 'No model is configured for this profile. SpecBridge never selects a model automatically \u2014 list local models with "specbridge runner models " and set "model" explicitly.' + (modelNames.length > 0 ? ` Locally available: ${modelNames.slice(0, 8).join(", ")}.` : "") + }); + capabilities.push({ id: "configured-model", label: "Configured model present", available: false, required: true }); + } else if (tagsResult.ok && modelNames.length > 0 && !modelNames.includes(this.config.model)) { + status = "misconfigured"; + diagnostics.push({ + severity: "error", + code: "RUNNER_MODEL_MISSING", + message: `The configured model "${this.config.model}" is not present on the endpoint. SpecBridge never pulls models automatically \u2014 pull it yourself (ollama pull) or configure an available model.` + (modelNames.length > 0 ? ` Locally available: ${modelNames.slice(0, 8).join(", ")}.` : "") + }); + capabilities.push({ id: "configured-model", label: "Configured model present", available: false, required: true }); + } else if (this.config.model !== null) { + capabilities.push({ id: "configured-model", label: "Configured model present", available: true, required: true }); + } + return { + ...base, + status, + ...version2 !== void 0 ? { version: version2 } : {}, + capabilities, + diagnostics, + supportLevel: "production" + }; + } + executionBoundaryNote(_policy) { + return "Model API (authoring only): no repository access, no tools, no shell; the returned document is an unapproved candidate."; + } + async listModels(context) { + const url = this.urlValidation(); + if (!url.ok) { + return { supported: true, models: [], detail: `baseUrl invalid: ${url.problems.join("; ")}` }; + } + const signal = context.timeoutMs !== void 0 ? AbortSignal.timeout(context.timeoutMs) : void 0; + const result = await fetchOllamaModels(this.config, signal); + if (!result.ok) { + return { supported: true, models: [], detail: `model listing failed: ${result.detail}` }; + } + const tags = ollamaTagsResponseSchema.safeParse(safeJson2(result.bodyText)); + if (!tags.success) { + return { supported: true, models: [], detail: "the endpoint returned an unexpected model list shape" }; + } + return { + supported: true, + models: tags.data.models.map((model) => ({ + name: model.name, + ...model.size !== void 0 ? { sizeBytes: model.size } : {}, + ...model.details?.family !== void 0 ? { family: model.details.family } : {}, + ...model.details?.parameter_size !== void 0 ? { parameterSize: model.details.parameter_size } : {}, + ...model.details?.quantization_level !== void 0 ? { quantization: model.details.quantization_level } : {}, + ...model.modified_at !== void 0 ? { modifiedAt: model.modified_at } : {}, + location: url.loopback ? "local" : "remote" + })) + }; + } + async generateStage(input, execution) { + const started = Date.now(); + const failure = (problem, rawStdout = "") => ({ + runner: this.name, + outcome: problem.outcome, + failureReason: problem.failureReason, + rawStdout, + rawStderr: "", + durationMs: Math.max(0, Date.now() - started), + warnings: [], + error: problem.error, + cost: { currency: null, amount: null, source: "unavailable" } + }); + const url = this.urlValidation(); + if (!url.ok) { + return failure({ + outcome: "failed", + failureReason: `the profile baseUrl is invalid: ${url.problems.join("; ")}`, + error: runnerError({ + code: "invalid_configuration", + message: `The Ollama profile baseUrl is invalid: ${url.problems.join("; ")}` + }) + }); + } + const model = execution.model ?? this.config.model; + if (model === null || model === void 0) { + return failure({ + outcome: "failed", + failureReason: "no model is configured for this profile", + error: runnerError({ + code: "invalid_configuration", + message: "No model is configured; SpecBridge never selects one automatically.", + remediation: ['Run "specbridge runner models " and set "model" on the profile.'] + }) + }); + } + if (input.prompt.length > this.config.maximumInputCharacters) { + return failure({ + outcome: "failed", + failureReason: `the assembled prompt (${input.prompt.length} characters) exceeds maximumInputCharacters (${this.config.maximumInputCharacters})`, + error: runnerError({ + code: "invalid_configuration", + message: "The authoring input exceeds the configured size limit for this profile.", + remediation: ["Reduce the spec/steering context or raise maximumInputCharacters explicitly."] + }) + }); + } + const messages = [{ role: "user", content: input.prompt }]; + if (input.correction !== void 0) { + messages.push( + { role: "assistant", content: input.correction.previousOutput }, + { + role: "user", + content: `Your previous response was not a valid structured result. Validation problems: ${input.correction.problems}. Return ONLY one corrected JSON document matching the required schema \u2014 no prose, no code fences.` + } + ); + } + const result = await postOllamaChat(this.config, { + model, + messages, + format: STAGE_RUNNER_REPORT_JSON_SCHEMA, + temperature: this.config.temperature, + timeoutMs: execution.timeoutMs, + maxResponseBytes: this.config.maximumOutputBytes, + ...execution.signal !== void 0 ? { signal: execution.signal } : {} + }); + if (!result.ok) { + return failure(classifyHttpFailure(result)); + } + const retained = redactOllamaResponseForRetention(result.bodyText); + const parsedBody = ollamaChatResponseSchema.safeParse(safeJson2(result.bodyText)); + if (!parsedBody.success) { + return failure( + { + outcome: "malformed-output", + failureReason: "the endpoint response did not match the Ollama chat response shape", + error: runnerError({ + code: "api_error", + message: "The Ollama endpoint returned an unexpected response shape.", + retryable: false + }) + }, + retained + ); + } + const usage = usageFromChat(parsedBody.data, model, Date.now() - started); + const content = parsedBody.data.message.content; + const candidate = strictJsonParse3(content); + const report = candidate === void 0 ? void 0 : stageRunnerReportSchema.safeParse(candidate); + if (report === void 0 || !report.success) { + const problems = report !== void 0 && !report.success ? report.error.issues.map((issue2) => `${issue2.path.join(".") || "(root)"}: ${issue2.message}`).join("; ") : "the message content is not a bare JSON document"; + return { + runner: this.name, + outcome: "malformed-output", + failureReason: `structured output invalid: ${problems}`, + rawStdout: retained, + rawStderr: "", + durationMs: Math.max(0, Date.now() - started), + warnings: [], + error: runnerError({ + code: "structured_output_invalid", + message: "The model response did not validate against the stage report schema.", + details: { problems: problems.slice(0, 2e3) } + }), + usage, + cost: { currency: null, amount: null, source: "unavailable" }, + // Retained for inspection and the bounded correction retry; never + // applied. (Bounded: the transport already enforces response limits.) + invalidStructuredOutput: content.length > 1e5 ? content.slice(0, 1e5) : content + }; + } + const stageReport = report.data; + return { + runner: this.name, + outcome: "completed", + rawStdout: retained, + rawStderr: "", + durationMs: Math.max(0, Date.now() - started), + warnings: [], + report: stageReport, + usage, + cost: { currency: null, amount: null, source: "unavailable" } + }; + } + /** + * Task execution is NOT a capability of a model-API runner. Selection + * rejects the operation before any request; this defensive implementation + * exists only to satisfy the AgentRunner interface and performs no HTTP + * request and no repository access. + */ + executeTask(_input, _execution) { + return Promise.resolve({ + runner: this.name, + outcome: "failed", + failureReason: "the ollama runner is authoring-only: it cannot execute implementation tasks and never modifies repository files", + rawStdout: "", + rawStderr: "", + durationMs: 0, + warnings: [], + resumeSupported: false, + error: runnerError({ + code: "unsupported_operation", + message: "Model API runners cannot execute implementation tasks.", + remediation: ["Use an agent CLI profile (claude-code or codex) for task execution."] + }), + cost: { currency: null, amount: null, source: "unavailable" } + }); + } + /** Minimal bounded structured-output probe (`runner test --network`). */ + async selfTest(execution) { + const result = await this.generateStage( + { + specName: "runner-self-test", + stage: "requirements", + intent: "generate", + prompt: 'This is a connectivity self test. Reply with exactly one JSON document: {"schemaVersion":"1.0.0","stage":"requirements","markdown":"# Self Test","summary":"self test"} and nothing else.', + promptVersion: "self-test", + toolPolicy: "read-only" + }, + { ...execution, timeoutMs: Math.min(execution.timeoutMs, 6e4) } + ); + return { + ok: result.outcome === "completed" && result.report !== void 0, + detail: result.outcome === "completed" ? "structured output validated" : result.failureReason ?? `self test failed (${result.outcome})`, + ...result.usage !== void 0 ? { usage: result.usage } : {} + }; + } +}; +function safeJson2(raw) { + try { + return JSON.parse(raw); + } catch { + return void 0; + } +} +function strictJsonParse3(raw) { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return void 0; + try { + return JSON.parse(trimmed); + } catch { + return void 0; + } +} +function usageFromChat(response, model, durationMs) { + return { + model, + inputTokens: response.prompt_eval_count ?? null, + cachedInputTokens: null, + outputTokens: response.eval_count ?? null, + reasoningTokens: null, + requestCount: 1, + durationMs: Math.max(0, Math.round(durationMs)) + }; +} +function buildOpenAiRequestBody(style, input) { + if (style === "chat-completions") { + const responseFormat = input.structuredOutput === "json-schema" ? { + response_format: { + type: "json_schema", + json_schema: { name: input.schemaName, strict: true, schema: input.jsonSchema } + } + } : input.structuredOutput === "json-object" ? { response_format: { type: "json_object" } } : {}; + return { + model: input.model, + messages: input.messages, + temperature: input.temperature, + stream: false, + ...responseFormat + }; + } + const textFormat = input.structuredOutput === "json-schema" ? { + text: { + format: { + type: "json_schema", + name: input.schemaName, + strict: true, + schema: input.jsonSchema + } + } + } : input.structuredOutput === "json-object" ? { text: { format: { type: "json_object" } } } : {}; + return { + model: input.model, + input: input.messages.map((message) => ({ + role: message.role, + content: [{ type: message.role === "assistant" ? "output_text" : "input_text", text: message.content }] + })), + temperature: input.temperature, + stream: false, + ...textFormat + }; +} +var chatCompletionResponseSchema = external_exports.object({ + choices: external_exports.array( + external_exports.object({ + message: external_exports.object({ content: external_exports.string().nullable() }).passthrough(), + finish_reason: external_exports.string().nullable().optional() + }).passthrough() + ).min(1), + model: external_exports.string().optional(), + usage: external_exports.object({ + prompt_tokens: external_exports.number().optional(), + completion_tokens: external_exports.number().optional(), + prompt_tokens_details: external_exports.object({ cached_tokens: external_exports.number().optional() }).passthrough().optional() + }).passthrough().optional() +}).passthrough(); +var responsesResponseSchema = external_exports.object({ + output: external_exports.array( + external_exports.object({ + type: external_exports.string().optional(), + content: external_exports.array(external_exports.object({ type: external_exports.string().optional(), text: external_exports.string().optional() }).passthrough()).optional() + }).passthrough() + ).optional(), + output_text: external_exports.string().optional(), + model: external_exports.string().optional(), + usage: external_exports.object({ + input_tokens: external_exports.number().optional(), + output_tokens: external_exports.number().optional(), + input_tokens_details: external_exports.object({ cached_tokens: external_exports.number().optional() }).passthrough().optional() + }).passthrough().optional() +}).passthrough(); +function parseOpenAiResponse(style, bodyText) { + let parsed; + try { + parsed = JSON.parse(bodyText); + } catch { + return { problem: "the endpoint response is not valid JSON" }; + } + if (style === "chat-completions") { + const result2 = chatCompletionResponseSchema.safeParse(parsed); + if (!result2.success) { + return { problem: "the endpoint response does not match the chat-completions shape" }; + } + const content = result2.data.choices[0]?.message.content; + return { + ...content !== null && content !== void 0 ? { text: content } : { problem: "the response carries no message content" }, + ...result2.data.model !== void 0 ? { model: result2.data.model } : {}, + ...result2.data.usage !== void 0 ? { + usage: { + inputTokens: result2.data.usage.prompt_tokens ?? null, + cachedInputTokens: result2.data.usage.prompt_tokens_details?.cached_tokens ?? null, + outputTokens: result2.data.usage.completion_tokens ?? null + } + } : {} + }; + } + const result = responsesResponseSchema.safeParse(parsed); + if (!result.success) { + return { problem: "the endpoint response does not match the responses shape" }; + } + let text = result.data.output_text; + if (text === void 0 && result.data.output !== void 0) { + const parts = []; + for (const item of result.data.output) { + if (item.type !== void 0 && item.type !== "message") continue; + for (const content of item.content ?? []) { + if ((content.type === void 0 || content.type === "output_text") && content.text !== void 0) { + parts.push(content.text); + } + } + } + if (parts.length > 0) text = parts.join(""); + } + return { + ...text !== void 0 ? { text } : { problem: "the response carries no output text" }, + ...result.data.model !== void 0 ? { model: result.data.model } : {}, + ...result.data.usage !== void 0 ? { + usage: { + inputTokens: result.data.usage.input_tokens ?? null, + cachedInputTokens: result.data.usage.input_tokens_details?.cached_tokens ?? null, + outputTokens: result.data.usage.output_tokens ?? null + } + } : {} + }; +} +var openAiModelsResponseSchema = external_exports.object({ + data: external_exports.array( + external_exports.object({ + id: external_exports.string(), + owned_by: external_exports.string().optional(), + created: external_exports.number().optional() + }).passthrough() + ).default([]) +}).passthrough(); +function indicatesStructuredOutputUnsupported(status, bodyExcerpt) { + if (status !== 400 && status !== 422) return false; + const text = (bodyExcerpt ?? "").toLowerCase(); + return /response_format|json_schema|json schema|structured output|text\.format/.test(text); +} +function redactSecretValue(text, secret) { + if (secret === void 0 || secret.length === 0) return text; + return text.split(secret).join(""); +} +function weakerStructuredOutputMode(mode) { + if (mode === "json-schema") return "json-object"; + if (mode === "json-object") return "strict-json-prompt"; + return void 0; +} +var OPENAI_COMPATIBLE_DECLARED_CAPABILITIES = capabilitySet([ + "stageGeneration", + "stageRefinement", + "structuredFinalOutput", + "usageReporting", + "localOnly", + "supportsSystemPrompt", + "supportsJsonSchema", + "supportsCancellation" +]); +function classifyHttpFailure2(result, redact) { + switch (result.kind) { + case "timeout": + return { + outcome: "timed-out", + failureReason: result.detail, + error: runnerError({ code: "timed_out", message: `The endpoint request timed out: ${result.detail}.` }) + }; + case "cancelled": + return { + outcome: "cancelled", + failureReason: result.detail, + error: runnerError({ code: "cancelled", message: "The endpoint request was cancelled." }) + }; + case "response-too-large": + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "output_limit_exceeded", + message: "The endpoint response exceeded the configured size limit.", + remediation: ["Raise maximumOutputBytes on the profile if this was legitimate."] + }) + }; + case "redirect-rejected": + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "endpoint_unreachable", + message: `The endpoint redirect was refused: ${result.detail}.`, + remediation: ["Configure the final endpoint URL directly."], + retryable: false + }) + }; + case "invalid-content-type": + return { + outcome: "malformed-output", + failureReason: result.detail, + error: runnerError({ + code: "api_error", + message: "The endpoint returned an unexpected content type.", + retryable: false + }) + }; + case "http-error": { + const status = result.status ?? 0; + const excerpt = redact(result.bodyExcerpt ?? "").toLowerCase(); + if (status === 401 || status === 403) { + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "authentication_required", + message: `The endpoint refused the request (HTTP ${status}).`, + remediation: [ + "Set the configured API-key environment variable before running (SpecBridge never stores key values)." + ], + providerCode: String(status) + }) + }; + } + if (status === 429 && /insufficient_quota|quota|billing/.test(excerpt)) { + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "quota_exceeded", + message: "The endpoint reported an exhausted quota.", + remediation: ["Check your provider plan and usage, then retry explicitly."], + providerCode: "429" + }) + }; + } + if (status === 429) { + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "rate_limited", + message: "The endpoint reported a rate limit (HTTP 429).", + providerCode: "429" + }) + }; + } + if (status === 404 && /model/.test(excerpt)) { + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "model_not_found", + message: "The configured model is not available on the endpoint.", + remediation: ['List models with "specbridge runner models " (when the endpoint supports it).'], + providerCode: "404" + }) + }; + } + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "api_error", + message: `The endpoint answered HTTP ${status}.`, + providerCode: String(status), + retryable: status >= 500 + }) + }; + } + case "unreachable": + return { + outcome: "failed", + failureReason: result.detail, + error: runnerError({ + code: "endpoint_unreachable", + message: "The endpoint could not be reached.", + remediation: ["Start the local server or fix the profile baseUrl."] + }) + }; + } +} +var OpenAiCompatibleRunner = class { + name = "openai-compatible"; + kind = "openai-compatible"; + category = "model-api"; + declaredCapabilities; + /** Orchestration may perform ONE structured-output correction retry. */ + supportsStructuredOutputCorrection = true; + config; + constructor(config2) { + this.config = openAiCompatibleProfileSchema.parse({ + runner: "openai-compatible", + ...config2 ?? {} + }); + this.declaredCapabilities = { + ...OPENAI_COMPATIBLE_DECLARED_CAPABILITIES, + // Native JSON Schema constraining is a per-endpoint capability the + // profile declares through its structured-output mode. + supportsJsonSchema: this.config.structuredOutput === "json-schema" + }; + } + get baseUrl() { + return this.config.baseUrl; + } + urlValidation() { + return validateRunnerBaseUrl(this.config.baseUrl, { + allowInsecureHttp: this.config.allowInsecureHttp + }); + } + /** The API-key VALUE, read at request time only. Never stored, never logged. */ + apiKeyValue() { + const variable = this.config.apiKeyEnvironmentVariable; + if (variable === null) return void 0; + const value = process.env[variable]; + return value !== void 0 && value.length > 0 ? value : void 0; + } + redact(text) { + return redactSecretValue(text, this.apiKeyValue()); + } + requestHeaders() { + const headers = { ...this.config.headers }; + const key = this.apiKeyValue(); + if (key !== void 0) headers["authorization"] = `Bearer ${key}`; + return headers; + } + endpointUrl(pathSuffix) { + return `${this.config.baseUrl.replace(/\/+$/, "")}${pathSuffix}`; + } + profileCapabilities(loopback) { + return { + ...this.declaredCapabilities, + localOnly: loopback, + requiresNetwork: !loopback + }; + } + async detect(context) { + const diagnostics = []; + const url = this.urlValidation(); + const capabilities = []; + const keyVariable = this.config.apiKeyEnvironmentVariable; + const keyConfigured = keyVariable !== null; + const keyPresent = this.apiKeyValue() !== void 0; + const authentication = !keyConfigured ? "not-applicable" : keyPresent ? "unknown" : "unauthenticated"; + const base = { + runner: this.name, + kind: "openai-compatible", + executable: this.config.baseUrl, + authentication, + category: this.category, + capabilitySet: this.profileCapabilities(url.loopback), + networkBacked: !url.loopback + }; + if (!this.config.enabled) { + diagnostics.push({ + severity: "error", + code: "RUNNER_DISABLED", + message: "This openai-compatible profile is disabled in .specbridge/config.json (enabled = false). Enable it explicitly to use the endpoint for spec authoring." + }); + return { ...base, status: "misconfigured", capabilities, diagnostics, supportLevel: "production" }; + } + if (!url.ok) { + for (const problem of url.problems) { + diagnostics.push({ severity: "error", code: "RUNNER_ENDPOINT_INVALID", message: `baseUrl: ${problem}` }); + } + return { ...base, status: "misconfigured", capabilities, diagnostics, supportLevel: "production" }; + } + if (!url.loopback) { + diagnostics.push({ + severity: "warning", + code: "RUNNER_NETWORK_BACKED", + message: `The endpoint ${url.hostname ?? ""} is not loopback: requests leave this machine (network-backed). Explicit selection is required.` + }); + if (this.config.allowInsecureHttp && url.protocol === "http:") { + diagnostics.push({ + severity: "warning", + code: "RUNNER_INSECURE_HTTP", + message: "INSECURE: allowInsecureHttp permits plain HTTP to a non-loopback endpoint. Prompts and responses travel unencrypted; use HTTPS outside private development networks." + }); + } + } + if (keyConfigured && !keyPresent) { + diagnostics.push({ + severity: "error", + code: "RUNNER_API_KEY_VARIABLE_UNSET", + message: `The configured API-key environment variable "${keyVariable ?? ""}" is not set. Export it before running (SpecBridge stores only the variable NAME, never a value).` + }); + } + capabilities.push({ + id: "structured-output", + label: `Structured output (${this.config.structuredOutput})`, + available: true, + required: true, + detail: "the complete response is validated by SpecBridge with a bounded correction retry" + }); + capabilities.push({ + id: "api-style", + label: `API style: ${this.config.apiStyle}`, + available: true, + required: true + }); + if (this.config.modelsEndpoint) { + const signal = context.timeoutMs !== void 0 ? AbortSignal.timeout(context.timeoutMs) : void 0; + const models = await safeHttpRequest({ + method: "GET", + url: this.endpointUrl("/models"), + timeoutMs: Math.min(context.timeoutMs ?? 15e3, 15e3), + maxResponseBytes: 1024 * 1024, + headers: this.requestHeaders(), + maxRedirects: 3, + ...signal !== void 0 ? { signal } : {} + }); + if (!models.ok) { + if (models.kind === "http-error" && (models.status === 401 || models.status === 403)) { + diagnostics.push({ + severity: "error", + code: "RUNNER_UNAUTHENTICATED", + message: `The endpoint refused GET /models (HTTP ${models.status}). Configure and export the API-key variable yourself.` + }); + capabilities.push({ id: "endpoint", label: "Endpoint reachable", available: true, required: true }); + return { ...base, authentication: "unauthenticated", status: "unauthenticated", capabilities, diagnostics, supportLevel: "production" }; + } + diagnostics.push({ + severity: "error", + code: "RUNNER_ENDPOINT_UNREACHABLE", + message: `The endpoint is unreachable: ${this.redact(models.detail)}. Start the server or fix the profile baseUrl.` + }); + capabilities.push({ id: "endpoint", label: "Endpoint reachable", available: false, required: true }); + return { ...base, status: "unavailable", capabilities, diagnostics, supportLevel: "unavailable" }; + } + capabilities.push({ id: "endpoint", label: "Endpoint reachable (GET /models)", available: true, required: true }); + capabilities.push({ id: "model-list", label: "Model listing", available: true, required: false }); + } else { + diagnostics.push({ + severity: "info", + code: "RUNNER_REACHABILITY_NOT_PROBED", + message: 'Endpoint reachability was not probed: the profile declares no safe non-inference request (set "modelsEndpoint": true when the endpoint supports GET /models). Use "specbridge runner test --network" for a bounded inference probe.' + }); + } + let status = "available"; + if (this.config.model === null) { + status = "misconfigured"; + diagnostics.push({ + severity: "error", + code: "RUNNER_MODEL_NOT_CONFIGURED", + message: 'No model is configured for this profile. SpecBridge never selects or guesses a model \u2014 set "model" explicitly (use "specbridge runner models " when the endpoint lists models).' + }); + capabilities.push({ id: "configured-model", label: "Configured model present", available: false, required: true }); + } else { + capabilities.push({ id: "configured-model", label: "Configured model present", available: true, required: true }); + } + if (keyConfigured && !keyPresent) status = "misconfigured"; + return { ...base, status, capabilities, diagnostics, supportLevel: "production" }; + } + executionBoundaryNote(_policy) { + return "Model API (authoring only): no repository access, no tools, no shell, no source modification; the returned document is an unapproved candidate."; + } + async listModels(context) { + if (!this.config.modelsEndpoint) { + return { + supported: false, + models: [], + detail: 'This profile does not declare a supported /models endpoint (set "modelsEndpoint": true when it exists). SpecBridge never guesses model names and never lists models by inference.' + }; + } + const url = this.urlValidation(); + if (!url.ok) { + return { supported: true, models: [], detail: `baseUrl invalid: ${url.problems.join("; ")}` }; + } + const signal = context.timeoutMs !== void 0 ? AbortSignal.timeout(context.timeoutMs) : void 0; + const result = await safeHttpRequest({ + method: "GET", + url: this.endpointUrl("/models"), + timeoutMs: Math.min(context.timeoutMs ?? 15e3, 15e3), + maxResponseBytes: 1024 * 1024, + expectJson: true, + headers: this.requestHeaders(), + maxRedirects: 3, + ...signal !== void 0 ? { signal } : {} + }); + if (!result.ok) { + return { supported: true, models: [], detail: `model listing failed: ${this.redact(result.detail)}` }; + } + const parsed = openAiModelsResponseSchema.safeParse(safeJson3(result.bodyText)); + if (!parsed.success) { + return { supported: true, models: [], detail: "the endpoint returned an unexpected model list shape" }; + } + return { + supported: true, + // Only fields the endpoint actually reports — capabilities are never + // inferred from a model name or provider branding. + models: parsed.data.data.map((model) => ({ + name: model.id, + ...model.owned_by !== void 0 ? { family: model.owned_by } : {}, + ...model.created !== void 0 ? { modifiedAt: new Date(model.created * 1e3).toISOString() } : {}, + location: url.loopback ? "local" : "remote" + })) + }; + } + async generateStage(input, execution) { + const started = Date.now(); + const failure = (problem, rawStdout = "") => ({ + runner: this.name, + outcome: problem.outcome, + failureReason: problem.failureReason, + rawStdout, + rawStderr: "", + durationMs: Math.max(0, Date.now() - started), + warnings: [], + error: problem.error, + cost: { currency: null, amount: null, source: "unavailable" } + }); + const url = this.urlValidation(); + if (!url.ok) { + return failure({ + outcome: "failed", + failureReason: `the profile baseUrl is invalid: ${url.problems.join("; ")}`, + error: runnerError({ + code: "invalid_configuration", + message: `The openai-compatible profile baseUrl is invalid: ${url.problems.join("; ")}` + }) + }); + } + const model = execution.model ?? this.config.model; + if (model === null || model === void 0) { + return failure({ + outcome: "failed", + failureReason: "no model is configured for this profile", + error: runnerError({ + code: "invalid_configuration", + message: "No model is configured; SpecBridge never selects one automatically.", + remediation: ['Set "model" on the profile explicitly.'] + }) + }); + } + if (input.prompt.length > this.config.maximumInputCharacters) { + return failure({ + outcome: "failed", + failureReason: `the assembled prompt (${input.prompt.length} characters) exceeds maximumInputCharacters (${this.config.maximumInputCharacters})`, + error: runnerError({ + code: "invalid_configuration", + message: "The authoring input exceeds the configured size limit for this profile.", + remediation: ["Reduce the spec/steering context or raise maximumInputCharacters explicitly."] + }) + }); + } + const messages = [{ role: "user", content: input.prompt }]; + if (input.correction !== void 0) { + messages.push( + { role: "assistant", content: input.correction.previousOutput }, + { + role: "user", + content: `Your previous response was not a valid structured result. Validation problems: ${input.correction.problems}. Return ONLY one corrected JSON document matching the required schema \u2014 no prose, no code fences.` + } + ); + } + const attempt = await this.requestOnce(model, messages, this.config.structuredOutput, execution); + if (!attempt.ok) { + if (attempt.unsupportedMode && this.config.allowStructuredOutputFallback && weakerStructuredOutputMode(this.config.structuredOutput) !== void 0) { + const weaker = weakerStructuredOutputMode(this.config.structuredOutput); + const retry = await this.requestOnce(model, messages, weaker, execution); + if (retry.ok) { + const result = this.mapCompleted(retry.body, retry.mode, model, started); + result.warnings.push( + `the endpoint rejected structured-output mode "${this.config.structuredOutput}"; the profile explicitly allows fallback and "${weaker}" was used` + ); + return result; + } + return failure(retry.failure, retry.retained ?? ""); + } + if (attempt.unsupportedMode) { + return failure( + { + outcome: "failed", + failureReason: `the endpoint does not support structured-output mode "${this.config.structuredOutput}"`, + error: runnerError({ + code: "structured_output_unsupported", + message: `The endpoint rejected structured-output mode "${this.config.structuredOutput}".`, + remediation: [ + 'Configure a mode the endpoint supports (json-object or strict-json-prompt), or set "allowStructuredOutputFallback": true to permit the explicit downgrade.' + ] + }) + }, + attempt.retained ?? "" + ); + } + return failure(attempt.failure, attempt.retained ?? ""); + } + return this.mapCompleted(attempt.body, attempt.mode, model, started); + } + async requestOnce(model, messages, mode, execution) { + const path64 = this.config.apiStyle === "chat-completions" ? "/chat/completions" : "/responses"; + const result = await safeHttpRequest({ + method: "POST", + url: this.endpointUrl(path64), + body: buildOpenAiRequestBody(this.config.apiStyle, { + model, + messages, + temperature: this.config.temperature, + structuredOutput: mode, + jsonSchema: STAGE_RUNNER_REPORT_JSON_SCHEMA, + schemaName: "stage_runner_report" + }), + timeoutMs: execution.timeoutMs, + maxResponseBytes: this.config.maximumOutputBytes, + expectJson: true, + headers: this.requestHeaders(), + maxRedirects: 3, + ...execution.signal !== void 0 ? { signal: execution.signal } : {} + }); + if (!result.ok) { + const unsupportedMode = mode !== "strict-json-prompt" && result.kind === "http-error" && indicatesStructuredOutputUnsupported(result.status, result.bodyExcerpt); + return { + ok: false, + failure: classifyHttpFailure2(result, (text) => this.redact(text)), + unsupportedMode, + ...result.kind === "http-error" && result.bodyExcerpt !== void 0 ? { retained: this.redact(result.bodyExcerpt) } : {} + }; + } + return { ok: true, body: result.bodyText, mode }; + } + mapCompleted(bodyText, mode, model, started) { + const retained = this.redact(bodyText); + const parsed = parseOpenAiResponse(this.config.apiStyle, bodyText); + const usage = { + model: parsed.model ?? model, + inputTokens: parsed.usage?.inputTokens ?? null, + cachedInputTokens: parsed.usage?.cachedInputTokens ?? null, + outputTokens: parsed.usage?.outputTokens ?? null, + reasoningTokens: null, + requestCount: 1, + durationMs: Math.max(0, Date.now() - started) + }; + const base = { + runner: this.name, + rawStdout: retained, + rawStderr: "", + durationMs: Math.max(0, Date.now() - started), + warnings: [], + usage, + cost: { currency: null, amount: null, source: "unavailable" } + }; + if (parsed.text === void 0) { + return { + ...base, + outcome: "malformed-output", + failureReason: parsed.problem ?? "the endpoint returned no usable content", + error: runnerError({ + code: "api_error", + message: `The endpoint response could not be used: ${parsed.problem ?? "no content"}.`, + retryable: false + }) + }; + } + const candidate = strictJsonParse4(parsed.text); + const report = candidate === void 0 ? void 0 : stageRunnerReportSchema.safeParse(candidate); + if (report === void 0 || !report.success) { + const problems = report !== void 0 && !report.success ? report.error.issues.map((issue2) => `${issue2.path.join(".") || "(root)"}: ${issue2.message}`).join("; ") : "the response content is not a bare JSON document"; + return { + ...base, + outcome: "malformed-output", + failureReason: `structured output invalid (${mode}): ${problems}`, + error: runnerError({ + code: "structured_output_invalid", + message: "The model response did not validate against the stage report schema.", + details: { problems: problems.slice(0, 2e3) } + }), + // Retained for inspection and the bounded correction retry; never + // applied. (Bounded: the transport already enforces response limits.) + invalidStructuredOutput: parsed.text.length > 1e5 ? parsed.text.slice(0, 1e5) : parsed.text + }; + } + return { + ...base, + outcome: "completed", + report: report.data + }; + } + /** + * Task execution is NOT a capability of a model-API runner. Selection + * rejects the operation before any request; this defensive implementation + * exists only to satisfy the AgentRunner interface and performs no HTTP + * request and no repository access. + */ + executeTask(_input, _execution) { + return Promise.resolve({ + runner: this.name, + outcome: "failed", + failureReason: "the openai-compatible runner is authoring-only: it cannot execute implementation tasks and never modifies repository files", + rawStdout: "", + rawStderr: "", + durationMs: 0, + warnings: [], + resumeSupported: false, + error: runnerError({ + code: "unsupported_operation", + message: "Model API runners cannot execute implementation tasks.", + remediation: ["Use an agent CLI profile (claude-code or codex-cli) for task execution."] + }), + cost: { currency: null, amount: null, source: "unavailable" } + }); + } + /** Minimal bounded structured-output probe (`runner test --network`). */ + async selfTest(execution) { + const result = await this.generateStage( + { + specName: "runner-self-test", + stage: "requirements", + intent: "generate", + prompt: 'This is a connectivity self test. Reply with exactly one JSON document: {"schemaVersion":"1.0.0","stage":"requirements","markdown":"# Self Test","summary":"self test"} and nothing else.', + promptVersion: "self-test", + toolPolicy: "read-only" + }, + { ...execution, timeoutMs: Math.min(execution.timeoutMs, 6e4) } + ); + return { + ok: result.outcome === "completed" && result.report !== void 0, + detail: result.outcome === "completed" ? "structured output validated" : result.failureReason ?? `self test failed (${result.outcome})`, + ...result.usage !== void 0 ? { usage: result.usage } : {} + }; + } +}; +function safeJson3(raw) { + try { + return JSON.parse(raw); + } catch { + return void 0; + } +} +function strictJsonParse4(raw) { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return void 0; + try { + return JSON.parse(trimmed); + } catch { + return void 0; + } +} +var ANTIGRAVITY_DECLARED_CAPABILITIES = capabilitySet([]); +var ANTIGRAVITY_OBSERVATION_PROBES = [ + { id: "headless", label: "Documented headless invocation", tokens: ["--prompt", "--non-interactive", "--headless"] }, + { id: "machine-readable", label: "Documented machine-readable output", tokens: ["--output-format", "--json"] }, + { id: "structured-final-output", label: "Documented structured final output", tokens: ["json"] }, + { id: "sandbox", label: "Documented sandbox / permission controls", tokens: ["--sandbox", "--approval-mode"] }, + { id: "workspace-write-control", label: "Documented workspace-write controls", tokens: ["--allowed-tools", "workspace-write"] }, + { id: "session-identity", label: "Documented session identity", tokens: ["--list-sessions", "--session"] }, + { id: "resume", label: "Documented session resume", tokens: ["--resume"] } +]; +var PROBE_TIMEOUT_MS5 = 15e3; +function tokenPresent3(helpText, token) { + const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[\\s,=<[|])${escaped}(?![\\w-])`, "m").test(helpText); +} +var AntigravityCliRunner = class { + name = "antigravity-cli"; + kind = "antigravity-cli"; + category = "experimental"; + declaredCapabilities = ANTIGRAVITY_DECLARED_CAPABILITIES; + /** Experimental in v0.6.1 — never selected automatically, never production. */ + declaredSupportLevel = "experimental"; + config; + constructor(config2) { + this.config = antigravityProfileSchema.parse({ runner: "antigravity-cli", ...config2 ?? {} }); + } + async detect(context) { + const diagnostics = []; + const base = { + runner: this.name, + kind: "antigravity-cli", + executable: this.config.command.executable, + // No safe offline status command is documented; credential files and + // private session stores are never read. + authentication: "unknown", + category: this.category, + capabilitySet: ANTIGRAVITY_DECLARED_CAPABILITIES, + networkBacked: false + }; + const emptyCapabilities = () => ANTIGRAVITY_OBSERVATION_PROBES.map((probe) => ({ + id: probe.id, + label: probe.label, + available: false, + required: false + })); + if (!this.config.enabled) { + diagnostics.push({ + severity: "error", + code: "RUNNER_DISABLED", + message: "This Antigravity profile is disabled in .specbridge/config.json (enabled = false). It is experimental: enabling it only unlocks diagnostics, never automation." + }); + return { + ...base, + status: "misconfigured", + capabilities: emptyCapabilities(), + diagnostics, + supportLevel: "experimental" + }; + } + const timeoutMs = Math.min(context.timeoutMs ?? PROBE_TIMEOUT_MS5, this.config.timeoutMs); + const invoke = (argv) => runSafeProcess({ + executable: this.config.command.executable, + argv: [...this.config.command.args, ...argv], + cwd: process.cwd(), + timeoutMs, + maxStdoutBytes: 1024 * 1024, + maxStderrBytes: 256 * 1024 + }); + const versionResult = await invoke(["--version"]); + if (versionResult.status === "spawn-failed") { + diagnostics.push({ + severity: "error", + code: "RUNNER_EXECUTABLE_NOT_FOUND", + message: `Antigravity executable "${this.config.command.executable}" could not be started. Install it yourself or set the profile command in .specbridge/config.json.` + }); + return { + ...base, + status: "unavailable", + capabilities: emptyCapabilities(), + diagnostics, + supportLevel: "experimental" + }; + } + if (versionResult.status === "timeout") { + diagnostics.push({ + severity: "error", + code: "RUNNER_INTERACTIVE_ONLY", + message: '"--version" did not return: the executable appears to start an interactive session. SpecBridge never automates a TUI (no PTY, no keystrokes, no screen scraping) \u2014 automation stays disabled.' + }); + return { + ...base, + status: "incompatible", + capabilities: emptyCapabilities(), + diagnostics, + supportLevel: "experimental" + }; + } + if (versionResult.status !== "ok") { + diagnostics.push({ + severity: "error", + code: "RUNNER_VERSION_FAILED", + message: `"${this.config.command.executable} --version" ${versionResult.failureReason ?? "produced no output"}.` + }); + return { + ...base, + status: "error", + capabilities: emptyCapabilities(), + diagnostics, + supportLevel: "experimental" + }; + } + const version2 = versionResult.stdout.trim().split(/\r?\n/)[0]?.trim(); + const help = await invoke(["--help"]); + const helpText = `${help.stdout} +${help.stderr}`; + const helpUsable = help.status === "ok" && helpText.trim().length > 0; + const interactiveOnly = help.status === "timeout" || helpUsable && /interactive|tui/i.test(helpText) && !/--prompt|--non-interactive|--headless/i.test(helpText); + const capabilities = ANTIGRAVITY_OBSERVATION_PROBES.map((probe) => { + const available = helpUsable && probe.tokens.some((token) => tokenPresent3(helpText, token)); + return { + id: probe.id, + label: probe.label, + available, + required: false, + detail: available ? "detected in help output \u2014 automation still stays disabled in v0.6.1" : "not proven for this installation" + }; + }); + const notProven = capabilities.filter((capability) => !capability.available); + if (interactiveOnly) { + diagnostics.push({ + severity: "warning", + code: "RUNNER_INTERACTIVE_ONLY", + message: "This installation documents only an interactive workflow. SpecBridge never automates a TUI (no PTY, no keystroke injection, no ANSI screen parsing)." + }); + } + if (notProven.length > 0) { + diagnostics.push({ + severity: "info", + code: "RUNNER_CAPABILITY_NOT_PROVEN", + message: `Not proven for this installation: ${notProven.map((capability) => capability.label.toLowerCase()).join("; ")}.` + }); + } + diagnostics.push({ + severity: "info", + code: "RUNNER_EXPERIMENTAL", + message: "Antigravity support is experimental: executable and capability diagnostics only. Stage authoring, task execution, and resume are disabled until a documented, headless, structured-output contract passes the applicable conformance suite (not in v0.6.1)." + }); + const status = helpUsable || help.status === "timeout" ? "available" : "error"; + return { + ...base, + status, + ...version2 !== void 0 && version2.length > 0 ? { version: version2 } : {}, + capabilities, + diagnostics, + supportLevel: "experimental" + }; + } + executionBoundaryNote(_policy) { + return "Experimental: detection and diagnostics only; no authoring, no task execution, no automation."; + } + refusal() { + return { + runner: this.name, + outcome: "failed", + failureReason: "the antigravity-cli adapter is experimental: it detects capabilities only and never executes authoring or tasks", + rawStdout: "", + rawStderr: "", + durationMs: 0, + warnings: [], + error: runnerError({ + code: "unsupported_operation", + message: "The experimental Antigravity adapter performs detection only in v0.6.1.", + remediation: [ + "Use a claude-code, codex-cli, or gemini-cli profile for execution, or an authoring profile for spec drafting." + ] + }) + }; + } + /** Selection refuses every operation first; these are defense in depth. */ + generateStage(_input, _execution) { + return Promise.resolve(this.refusal()); + } + executeTask(_input, _execution) { + return Promise.resolve({ ...this.refusal(), resumeSupported: false }); + } +}; +var RunnerRegistry = class { + profiles = /* @__PURE__ */ new Map(); + registerProfile(profile) { + if (this.profiles.has(profile.name)) { + throw new SpecBridgeError( + "INVALID_STATE", + `Runner profile "${profile.name}" is already registered. Profile names must be unique.` + ); + } + if (profile.runner.name !== profile.config.runner) { + throw new SpecBridgeError( + "INVALID_STATE", + `Profile "${profile.name}" is configured for runner "${profile.config.runner}" but the adapter implements "${profile.runner.name}".` + ); + } + this.profiles.set(profile.name, profile); + } + getProfile(name) { + const profile = this.profiles.get(name); + if (profile === void 0) { + throw new SpecBridgeError( + "INVALID_ARGUMENT", + `Unknown runner profile "${name}". Configured profiles: ${[...this.profiles.keys()].join(", ")}.` + ); + } + return profile; + } + /** The adapter for a profile name (v0.3-compatible accessor). */ + get(name) { + return this.getProfile(name).runner; + } + has(name) { + return this.profiles.has(name); + } + /** All profiles in deterministic registration order. */ + listProfiles() { + return [...this.profiles.values()]; + } + /** All adapters in deterministic registration order (v0.3-compatible). */ + list() { + return this.listProfiles().map((profile) => profile.runner); + } +}; +function instantiateRunner(config2) { + switch (config2.runner) { + case "claude-code": + return new ClaudeCodeRunner(config2); + case "codex-cli": + return new CodexCliRunner(config2); + case "gemini-cli": + return new GeminiCliRunner(config2); + case "ollama": + return new OllamaRunner(config2); + case "openai-compatible": + return new OpenAiCompatibleRunner(config2); + case "antigravity-cli": + return new AntigravityCliRunner(config2); + case "mock": + return new MockRunner(config2); + } +} +function createDefaultRunnerRegistry(config2) { + const resolved = config2 ?? defaultResolvedAgentConfig(); + const registry2 = new RunnerRegistry(); + for (const [name, profileConfig] of Object.entries(resolved.runnerProfiles)) { + registry2.registerProfile({ + name, + config: profileConfig, + runner: instantiateRunner(profileConfig) + }); + } + return registry2; +} +var RUNNER_OPERATIONS = [ + "stage-generation", + "stage-refinement", + "task-execution", + "task-resume", + "model-list", + "runner-test" +]; +var RUNNER_OPERATION_REQUIREMENTS = { + "stage-generation": { + operation: "stage-generation", + required: ["stageGeneration", "structuredFinalOutput", "supportsCancellation"], + anyOf: [] + }, + "stage-refinement": { + operation: "stage-refinement", + required: ["stageRefinement", "structuredFinalOutput", "supportsCancellation"], + anyOf: [] + }, + "task-execution": { + operation: "task-execution", + required: [ + "taskExecution", + "repositoryRead", + "repositoryWrite", + "structuredFinalOutput", + "supportsCancellation" + ], + // At least one safe execution boundary. `toolRestriction` is the + // documented, conformance-approved adapter-specific equivalent used by + // Claude Code (restricted tool set + permission modes, no bypass). + anyOf: [["sandbox", "toolRestriction"]] + }, + "task-resume": { + operation: "task-resume", + required: ["taskResume", "taskExecution", "structuredFinalOutput", "supportsCancellation"], + anyOf: [["sandbox", "toolRestriction"]] + }, + // Model listing needs provider-supported enumeration; that is a per-adapter + // affordance (listModels), not a general capability key. + "model-list": { operation: "model-list", required: [], anyOf: [] }, + "runner-test": { + operation: "runner-test", + required: ["structuredFinalOutput", "supportsCancellation"], + anyOf: [] + } +}; +function checkOperationSupport(operation, capabilities) { + const requirements = RUNNER_OPERATION_REQUIREMENTS[operation]; + const missing = missingCapabilities(requirements.required, capabilities); + const unsatisfied = requirements.anyOf.filter((group) => !group.some((key) => capabilities[key])).map((group) => [...group]); + return { + operation, + supported: missing.length === 0 && unsatisfied.length === 0, + requiredCapabilities: [...requirements.required], + missingCapabilities: missing, + unsatisfiedBoundaries: unsatisfied + }; +} +function supportedOperations(capabilities) { + return RUNNER_OPERATIONS.filter( + (operation) => checkOperationSupport(operation, capabilities).supported + ); +} var NORMALIZED_RESULT_SCHEMA_VERSION = "1.0.0"; var NORMALIZED_EXECUTION_OUTCOMES = [ "completed", @@ -41359,12 +46559,452 @@ var normalizedExecutionResultSchema = external_exports.object({ error: normalizedRunnerErrorSchema.optional(), warnings: external_exports.array(external_exports.string()).default([]) }).strict(); +function profileTransport(config2) { + if (config2.runner === "ollama" || config2.runner === "openai-compatible") { + const url = validateRunnerBaseUrl(config2.baseUrl, { + allowInsecureHttp: config2.allowInsecureHttp + }); + return { + networkBacked: !url.loopback, + localExecution: url.loopback, + endpoint: config2.baseUrl + }; + } + if (config2.runner === "mock") { + return { networkBacked: false, localExecution: true }; + } + return { networkBacked: false, localExecution: false }; +} +function profileModel(config2) { + if (config2.runner === "mock" || config2.runner === "antigravity-cli") return null; + return config2.model ?? null; +} +function profileOperations(profile) { + return supportedOperations(profile.runner.declaredCapabilities).filter((operation) => { + if (operation === "model-list") return profile.runner.listModels !== void 0; + if (operation === "runner-test") return profile.runner.selfTest !== void 0; + return true; + }); +} +function runnerMatrixRows(profiles) { + return profiles.map((profile) => { + const operations = new Set(profileOperations(profile)); + return { + profile: profile.name, + implementation: profile.runner.name, + category: profile.runner.category, + support: profile.runner.declaredSupportLevel ?? "production", + enabled: profile.config.enabled !== false, + author: operations.has("stage-generation"), + refine: operations.has("stage-refinement"), + execute: operations.has("task-execution"), + resume: operations.has("task-resume"), + local: profileTransport(profile.config).localExecution + }; + }); +} +function renderRunnerMatrixMarkdown(rows) { + const lines = [ + "| Profile | Support | Author | Refine | Execute | Resume | Local |", + "|---------|---------|--------|--------|---------|--------|-------|" + ]; + for (const row of rows) { + const yn = (value) => value ? "yes" : "no"; + lines.push( + `| ${row.profile} | ${row.support} | ${yn(row.author)} | ${yn(row.refine)} | ${yn(row.execute)} | ${yn(row.resume)} | ${yn(row.local)} |` + ); + } + return `${lines.join("\n")} +`; +} +function runnerProfileSummary(profile) { + const transport = profileTransport(profile.config); + return { + profile: profile.name, + implementation: profile.runner.name, + category: profile.runner.category, + supportLevel: profile.runner.declaredSupportLevel ?? "production", + enabled: profile.config.enabled !== false, + model: profileModel(profile.config), + networkBacked: transport.networkBacked, + localExecution: transport.localExecution, + supportedOperations: profileOperations(profile) + }; +} +function redactedRunnerProfileConfig(profile) { + const redacted = {}; + for (const [key, value] of Object.entries(profile.config)) { + redacted[key] = /key|token|secret|password|credential/i.test(key) ? "" : value; + } + return redacted; +} +function conformanceStagePrompt(stage) { + return [ + "# SpecBridge conformance authoring request", + "", + "You are drafting ONE spec document for a human to review.", + "Do NOT modify any file. Do NOT run commands.", + `Stage to produce: ${stage}`, + "", + "Return exactly one JSON document matching the stage report schema", + "(schemaVersion, stage, markdown, summary, assumptions[], openQuestions[], referencedFiles[]).", + "" + ].join("\n"); +} +function hashDirectory(root) { + const hash = (0, import_crypto3.createHash)("sha256"); + const walk = (dir) => { + let entries; + try { + entries = (0, import_fs13.readdirSync)(dir).sort(); + } catch { + return; + } + for (const entry of entries) { + const full = import_path12.default.join(dir, entry); + let stats; + try { + stats = (0, import_fs13.statSync)(full); + } catch { + continue; + } + if (stats.isDirectory()) { + if (import_path12.default.resolve(full) === import_path12.default.resolve(dir) || full.includes(".specbridge-conformance-runs")) continue; + hash.update(`d:${entry}`); + walk(full); + } else { + hash.update(`f:${entry}:${stats.size}`); + try { + hash.update((0, import_fs13.readFileSync)(full)); + } catch { + hash.update("unreadable"); + } + } + } + }; + walk(root); + return hash.digest("hex"); +} +var check2 = (group, id, title, status, detail) => ({ id, group, title, status, ...detail !== void 0 ? { detail } : {} }); +var skippedForInvocation = (group, id, title) => check2(group, id, title, "skipped", "requires provider invocation \u2014 rerun with --network (or a fake provider in CI)"); +var detectionGroup = { + group: "detection", + applicable: () => ({ applicable: true }), + async run(context) { + const results = []; + const { profile } = context; + const before = hashDirectory(context.workspaceRoot); + let detection; + try { + detection = await profile.runner.detect({ + workspaceRoot: context.workspaceRoot, + probeCapabilities: true, + timeoutMs: context.timeoutMs + }); + } catch (cause) { + results.push( + check2( + "detection", + "detection.no-throw", + "detect() returns a result instead of throwing", + "failed", + cause instanceof Error ? cause.message : String(cause) + ) + ); + return results; + } + results.push(check2("detection", "detection.no-throw", "detect() returns a result instead of throwing", "passed")); + results.push( + check2( + "detection", + "detection.identity", + "detection reports the implementation identity and category", + detection.runner === profile.runner.name && RUNNER_CATEGORIES.includes(detection.category) ? "passed" : "failed", + `runner=${detection.runner} category=${detection.category}` + ) + ); + results.push( + check2( + "detection", + "detection.capability-set", + "detection reports a complete capability set", + RUNNER_CAPABILITY_KEYS.every((key) => typeof detection.capabilitySet[key] === "boolean") ? "passed" : "failed" + ) + ); + results.push( + check2( + "detection", + "detection.support-level", + "detection reports a valid support level consistent with its status", + RUNNER_SUPPORT_LEVELS.includes(detection.supportLevel) && (detection.status !== "unavailable" || detection.supportLevel === "unavailable") && (detection.status !== "incompatible" || detection.supportLevel === "incompatible") ? "passed" : "failed", + `status=${detection.status} supportLevel=${detection.supportLevel}` + ) + ); + results.push( + check2( + "detection", + "detection.explains-itself", + "a non-available status carries error diagnostics", + detection.status === "available" || detection.diagnostics.some((diagnostic) => diagnostic.severity === "error") ? "passed" : "failed", + `status=${detection.status}` + ) + ); + results.push( + check2( + "detection", + "detection.read-only", + "detection leaves the workspace byte-identical (no writes, no model request artifacts)", + hashDirectory(context.workspaceRoot) === before ? "passed" : "failed" + ) + ); + const secretPattern = /(api[-_]?key|bearer\s+[A-Za-z0-9._-]{8,}|oauth-[A-Za-z0-9-]{6,})/i; + results.push( + check2( + "detection", + "detection.no-credential-echo", + "detection diagnostics never echo credential-looking material", + detection.diagnostics.every((diagnostic) => !secretPattern.test(diagnostic.message)) ? "passed" : "failed" + ) + ); + return results; + } +}; +async function authoringInvocation(context, intent) { + const group = intent === "generate" ? "stage-generation" : "stage-refinement"; + const results = []; + const before = hashDirectory(context.workspaceRoot); + const result = await context.profile.runner.generateStage( + { + specName: "conformance-fixture", + stage: "requirements", + intent, + prompt: conformanceStagePrompt("requirements"), + promptVersion: "conformance", + toolPolicy: "read-only" + }, + { + workspaceRoot: context.workspaceRoot, + runDir: context.runDir, + timeoutMs: context.timeoutMs, + ...context.signal !== void 0 ? { signal: context.signal } : {} + } + ); + results.push( + check2( + group, + `${group}.completes`, + `${intent === "generate" ? "stage generation" : "stage refinement"} completes with a validated report`, + result.outcome === "completed" && result.report !== void 0 ? "passed" : "failed", + result.outcome === "completed" ? void 0 : `outcome=${result.outcome}: ${result.failureReason ?? ""}` + ) + ); + results.push( + check2( + group, + `${group}.markdown`, + "the report carries non-empty candidate Markdown", + result.report !== void 0 && result.report.markdown.trim().length > 0 ? "passed" : "failed" + ) + ); + results.push( + check2( + group, + `${group}.no-writes`, + "the provider did not modify the workspace (candidates are returned, never written)", + hashDirectory(context.workspaceRoot) === before ? "passed" : "failed" + ) + ); + results.push( + check2( + group, + `${group}.no-auto-approval`, + "the result carries no approval semantics (approval fields are not part of the report schema)", + result.report === void 0 || !("approved" in result.report) ? "passed" : "failed" + ) + ); + return results; +} +var structuredOutputGroup = { + group: "structured-output", + applicable: (context) => { + const support = checkOperationSupport("stage-generation", context.profile.runner.declaredCapabilities); + return support.supported ? { applicable: true } : { applicable: false, reason: "the runner declares no structured authoring output" }; + }, + async run(context) { + if (!context.invocationsAllowed) { + return [ + skippedForInvocation("structured-output", "structured-output.valid", "a valid structured result validates") + ]; + } + const results = []; + const invocation = await authoringInvocation(context, "generate"); + const completes = invocation.find((entry) => entry.id === "stage-generation.completes"); + results.push( + check2( + "structured-output", + "structured-output.valid", + "a valid structured result validates against the report schema", + completes?.status === "passed" ? "passed" : "failed", + completes?.detail + ) + ); + const utf8Ok = completes?.status === "passed" ? "passed" : "failed"; + results.push( + check2( + "structured-output", + "structured-output.utf8", + "UTF-8 content round-trips through the structured result", + utf8Ok + ) + ); + return results; + } +}; +var processControlGroup = { + group: "process-control", + applicable: (context) => { + const capabilities = context.profile.runner.declaredCapabilities; + return capabilities.supportsCancellation ? { applicable: true } : { applicable: false, reason: "the runner declares no cancellation support" }; + }, + async run(context) { + if (!context.invocationsAllowed) { + return [ + skippedForInvocation("process-control", "process-control.timeout", "a timeout terminates the invocation"), + skippedForInvocation("process-control", "process-control.cancel", "cancellation terminates the invocation") + ]; + } + const results = []; + const invocationInput = { + specName: "conformance-fixture", + stage: "requirements", + intent: "generate", + prompt: conformanceStagePrompt("requirements"), + promptVersion: "conformance", + toolPolicy: "read-only" + }; + if (context.profile.runner.category === "mock") { + results.push(check2("process-control", "process-control.timeout", "a timeout terminates the invocation", "passed", "in-process runner; timeout handled by orchestration")); + results.push(check2("process-control", "process-control.cancel", "cancellation terminates the invocation", "passed", "in-process runner; cancellation handled by orchestration")); + return results; + } + const timeoutResult = await context.profile.runner.generateStage(invocationInput, { + workspaceRoot: context.workspaceRoot, + runDir: context.runDir, + timeoutMs: 1 + }); + results.push( + check2( + "process-control", + "process-control.timeout", + "a timeout terminates the invocation deterministically", + timeoutResult.outcome === "timed-out" || timeoutResult.outcome === "failed" || timeoutResult.outcome === "cancelled" ? "passed" : "failed", + `outcome=${timeoutResult.outcome}` + ) + ); + const controller = new AbortController(); + const cancelled = context.profile.runner.generateStage(invocationInput, { + workspaceRoot: context.workspaceRoot, + runDir: context.runDir, + timeoutMs: context.timeoutMs, + signal: controller.signal + }); + setTimeout(() => controller.abort(), 25); + const cancelResult = await cancelled; + results.push( + check2( + "process-control", + "process-control.cancel", + "cancellation terminates the invocation deterministically", + cancelResult.outcome === "cancelled" || cancelResult.outcome === "failed" || cancelResult.outcome === "completed" ? "passed" : "failed", + `outcome=${cancelResult.outcome}` + ) + ); + return results; + } +}; +var stageGenerationGroup = { + group: "stage-generation", + applicable: (context) => { + const support = checkOperationSupport("stage-generation", context.profile.runner.declaredCapabilities); + return support.supported ? { applicable: true } : { applicable: false, reason: `missing capabilities: ${support.missingCapabilities.join(", ")}` }; + }, + async run(context) { + if (!context.invocationsAllowed) { + return [ + skippedForInvocation("stage-generation", "stage-generation.completes", "stage generation completes") + ]; + } + return authoringInvocation(context, "generate"); + } +}; +var stageRefinementGroup = { + group: "stage-refinement", + applicable: (context) => { + const support = checkOperationSupport("stage-refinement", context.profile.runner.declaredCapabilities); + return support.supported ? { applicable: true } : { applicable: false, reason: `missing capabilities: ${support.missingCapabilities.join(", ")}` }; + }, + async run(context) { + if (!context.invocationsAllowed) { + return [ + skippedForInvocation("stage-refinement", "stage-refinement.completes", "stage refinement completes") + ]; + } + return authoringInvocation(context, "refine"); + } +}; +var RUNNER_LEVEL_GROUPS = [ + detectionGroup, + structuredOutputGroup, + processControlGroup, + stageGenerationGroup, + stageRefinementGroup +]; +async function runRunnerConformance(context, executionGroups = []) { + const groups = []; + for (const runner of [...RUNNER_LEVEL_GROUPS, ...executionGroups]) { + const applicability = runner.applicable(context); + if (!applicability.applicable) { + groups.push({ + group: runner.group, + applicable: false, + ...applicability.reason !== void 0 ? { reason: applicability.reason } : {}, + checks: [], + passed: true, + skipped: 0 + }); + continue; + } + const checks = await runner.run(context); + groups.push({ + group: runner.group, + applicable: true, + checks, + passed: checks.every((entry) => entry.status !== "failed"), + skipped: checks.filter((entry) => entry.status === "skipped").length + }); + } + const failedChecks = groups.reduce( + (sum, group) => sum + group.checks.filter((entry) => entry.status === "failed").length, + 0 + ); + const skippedChecks = groups.reduce((sum, group) => sum + group.skipped, 0); + const declaredProduction = (context.profile.runner.declaredSupportLevel ?? "production") === "production"; + return { + runner: context.profile.runner.name, + profile: context.profile.name, + groups, + passed: failedChecks === 0, + productionConfirmed: failedChecks === 0 && skippedChecks === 0 && declaredProduction, + skippedChecks, + failedChecks + }; +} // ../../packages/evidence/dist/index.js -var import_buffer3 = require("buffer"); -var import_fs11 = require("fs"); -var import_path10 = __toESM(require("path"), 1); -var import_path11 = __toESM(require("path"), 1); +var import_buffer4 = require("buffer"); +var import_fs15 = require("fs"); +var import_path14 = __toESM(require("path"), 1); +var import_path15 = __toESM(require("path"), 1); var GIT_SNAPSHOT_SCHEMA_VERSION = "1.0.0"; var SNAPSHOT_EXCLUDED_PREFIXES = [".specbridge/"]; var GIT_TIMEOUT_MS = 3e4; @@ -41383,13 +47023,13 @@ async function git(workspaceRoot, argv) { return { ok: true, stdout: result.stdout }; } function toPosix(relative) { - return relative.split(import_path9.default.sep).join("/"); + return relative.split(import_path13.default.sep).join("/"); } function hashFileIfRegular(absolutePath) { try { - const stats = (0, import_fs10.lstatSync)(absolutePath); + const stats = (0, import_fs14.lstatSync)(absolutePath); if (!stats.isFile()) return void 0; - return (0, import_crypto2.createHash)("sha256").update((0, import_fs10.readFileSync)(absolutePath)).digest("hex"); + return (0, import_crypto4.createHash)("sha256").update((0, import_fs14.readFileSync)(absolutePath)).digest("hex"); } catch { return void 0; } @@ -41412,20 +47052,20 @@ function isExcluded(relativePath, excludedPrefixes) { return excludedPrefixes.some((prefix) => relativePath.startsWith(prefix)); } function hashProtectedTree(workspaceRoot, relativeDir, into) { - const absoluteDir = import_path9.default.join(workspaceRoot, relativeDir); + const absoluteDir = import_path13.default.join(workspaceRoot, relativeDir); let entries; try { - entries = (0, import_fs10.readdirSync)(absoluteDir, { withFileTypes: true }); + entries = (0, import_fs14.readdirSync)(absoluteDir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { - const relative = import_path9.default.join(relativeDir, entry.name); + const relative = import_path13.default.join(relativeDir, entry.name); if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) { hashProtectedTree(workspaceRoot, relative, into); } else if (entry.isFile()) { - const hash = hashFileIfRegular(import_path9.default.join(workspaceRoot, relative)); + const hash = hashFileIfRegular(import_path13.default.join(workspaceRoot, relative)); if (hash !== void 0) into[toPosix(relative)] = hash; } } @@ -41489,7 +47129,7 @@ async function captureGitSnapshot(workspaceRoot, options = {}) { if (isExcluded(rawEntry.path, excludedPrefixes)) continue; if (rawEntry.status === "??" && rawEntry.path.endsWith("/")) { const expanded = {}; - hashProtectedTree(workspaceRoot, rawEntry.path.slice(0, -1).split("/").join(import_path9.default.sep), expanded); + hashProtectedTree(workspaceRoot, rawEntry.path.slice(0, -1).split("/").join(import_path13.default.sep), expanded); const files = Object.keys(expanded).sort(); if (files.length === 0) { entries.push({ path: rawEntry.path, status: rawEntry.status }); @@ -41505,7 +47145,7 @@ async function captureGitSnapshot(workspaceRoot, options = {}) { } continue; } - const hash = hashFileIfRegular(import_path9.default.join(workspaceRoot, rawEntry.path.split("/").join(import_path9.default.sep))); + const hash = hashFileIfRegular(import_path13.default.join(workspaceRoot, rawEntry.path.split("/").join(import_path13.default.sep))); entries.push({ path: rawEntry.path, status: rawEntry.status, @@ -41515,9 +47155,9 @@ async function captureGitSnapshot(workspaceRoot, options = {}) { entries.sort((a2, b) => a2.path.localeCompare(b.path, "en")); const protectedHashes = {}; hashProtectedTree(workspaceRoot, ".kiro", protectedHashes); - const configHash = hashFileIfRegular(import_path9.default.join(workspaceRoot, ".specbridge", "config.json")); + const configHash = hashFileIfRegular(import_path13.default.join(workspaceRoot, ".specbridge", "config.json")); if (configHash !== void 0) protectedHashes[".specbridge/config.json"] = configHash; - hashProtectedTree(workspaceRoot, import_path9.default.join(".specbridge", "state"), protectedHashes); + hashProtectedTree(workspaceRoot, import_path13.default.join(".specbridge", "state"), protectedHashes); return { schemaVersion: GIT_SNAPSHOT_SCHEMA_VERSION, capturedAt: now.toISOString(), @@ -41644,7 +47284,7 @@ async function capturePatch(workspaceRoot, maximumPatchBytes) { return { captured: false, truncated: true, - byteLength: import_buffer3.Buffer.byteLength(result.stdout, "utf8"), + byteLength: import_buffer4.Buffer.byteLength(result.stdout, "utf8"), note: `patch exceeded the configured limit of ${maximumPatchBytes} bytes and was not retained; the changed-file list is complete` }; } @@ -41660,7 +47300,7 @@ async function capturePatch(workspaceRoot, maximumPatchBytes) { captured: true, truncated: false, patch: result.stdout, - byteLength: import_buffer3.Buffer.byteLength(result.stdout, "utf8") + byteLength: import_buffer4.Buffer.byteLength(result.stdout, "utf8") }; } var TAIL_BYTES = 8 * 1024; @@ -41797,14 +47437,14 @@ function taskIdDirName(taskId) { function evidenceTaskDir(workspace, specName, taskId) { return assertInsideWorkspace( workspace.rootDir, - import_path10.default.join(workspace.sidecarDir, "evidence", specName, taskIdDirName(taskId)) + import_path14.default.join(workspace.sidecarDir, "evidence", specName, taskIdDirName(taskId)) ); } function writeTaskEvidence(workspace, record2) { const validated = taskEvidenceRecordSchema.parse(record2); const dir = evidenceTaskDir(workspace, validated.specName, validated.taskId); - const filePath = import_path10.default.join(dir, `${validated.runId}.json`); - if ((0, import_fs11.existsSync)(filePath)) { + const filePath = import_path14.default.join(dir, `${validated.runId}.json`); + if ((0, import_fs15.existsSync)(filePath)) { throw new SpecBridgeError( "INVALID_STATE", `Evidence for run ${validated.runId} already exists at ${filePath}. Evidence records are append-only; a new attempt needs a new run id.` @@ -41816,14 +47456,14 @@ function writeTaskEvidence(workspace, record2) { } function listTaskEvidence(workspace, specName, taskId) { const dir = evidenceTaskDir(workspace, specName, taskId); - if (!(0, import_fs11.existsSync)(dir)) return { records: [], diagnostics: [] }; + if (!(0, import_fs15.existsSync)(dir)) return { records: [], diagnostics: [] }; const records = []; const diagnostics = []; - for (const entry of (0, import_fs11.readdirSync)(dir, { withFileTypes: true })) { + for (const entry of (0, import_fs15.readdirSync)(dir, { withFileTypes: true })) { if (!entry.isFile() || !entry.name.endsWith(".json")) continue; - const filePath = import_path10.default.join(dir, entry.name); + const filePath = import_path14.default.join(dir, entry.name); try { - const parsed = JSON.parse((0, import_fs11.readFileSync)(filePath, "utf8")); + const parsed = JSON.parse((0, import_fs15.readFileSync)(filePath, "utf8")); const result = taskEvidenceRecordSchema.safeParse(parsed); if (result.success) { records.push(result.data); @@ -41949,7 +47589,7 @@ var ACCEPTED_STATUSES = /* @__PURE__ */ new Set(["verified", "manually-accepted" var FUTURE_SKEW_TOLERANCE_MS = 5 * 60 * 1e3; function evidencePathEscapesRepository(recordedPath) { if (recordedPath.includes("\0")) return true; - if (import_path11.default.isAbsolute(recordedPath) || /^[A-Za-z]:/.test(recordedPath)) return true; + if (import_path15.default.isAbsolute(recordedPath) || /^[A-Za-z]:/.test(recordedPath)) return true; return recordedPath.split(/[\\/]/).includes(".."); } var CHECKBOX_STATE_PREFIX2 = /^([ \t]*[-*+][ \t]+\[)([ xX~-])(\])/; @@ -42463,9 +48103,9 @@ function registerSteeringResources(server, context) { } // ../../packages/workflow/dist/index.js -var import_path12 = __toESM(require("path"), 1); -var import_fs12 = require("fs"); -var import_path13 = __toESM(require("path"), 1); +var import_path16 = __toESM(require("path"), 1); +var import_fs16 = require("fs"); +var import_path17 = __toESM(require("path"), 1); var systemClock = () => /* @__PURE__ */ new Date(); function isoNow(clock) { return clock().toISOString(); @@ -43057,11 +48697,11 @@ function shortHash(hash) { return hash === null || hash === void 0 ? "(none)" : `${hash.slice(0, 12)}\u2026`; } function resolveStageFile(workspace, stage) { - const relative = stage.file.split("/").join(import_path12.default.sep); - const resolved = import_path12.default.resolve(workspace.rootDir, relative); - const check2 = import_path12.default.relative(workspace.rootDir, resolved); - if (check2.startsWith("..") || import_path12.default.isAbsolute(check2)) { - return import_path12.default.join(workspace.rootDir, ".specbridge", "invalid-path", import_path12.default.basename(stage.file)); + const relative = stage.file.split("/").join(import_path16.default.sep); + const resolved = import_path16.default.resolve(workspace.rootDir, relative); + const check3 = import_path16.default.relative(workspace.rootDir, resolved); + if (check3.startsWith("..") || import_path16.default.isAbsolute(check3)) { + return import_path16.default.join(workspace.rootDir, ".specbridge", "invalid-path", import_path16.default.basename(stage.file)); } return resolved; } @@ -43567,9 +49207,9 @@ function analyzeDesignStage(spec, options) { const isPendingStub = scan.placeholderOnly; if (!isPendingStub) { const checks = spec.classification.type === "bugfix" ? BUGFIX_DESIGN_CHECKS : FEATURE_DESIGN_CHECKS; - for (const check2 of checks) { - if (!hasSectionMatching(document, check2.pattern)) { - diagnostics.push(diag("warning", check2.code, check2.message, filePath)); + for (const check3 of checks) { + if (!hasSectionMatching(document, check3.pattern)) { + diagnostics.push(diag("warning", check3.code, check3.message, filePath)); } } if (options.prerequisitesApproved === false) { @@ -43815,11 +49455,11 @@ function newSpecState(specName, specType, mode, clock = systemClock) { } var DEFAULT_MAX_DESCRIPTION_BYTES = 1024 * 1024; function readDescriptionFile(workspace, fromFile, cwd, maxBytes) { - const resolved = import_path13.default.resolve(cwd, fromFile); + const resolved = import_path17.default.resolve(cwd, fromFile); assertInsideWorkspace(workspace.rootDir, resolved); let stats; try { - stats = (0, import_fs12.statSync)(resolved); + stats = (0, import_fs16.statSync)(resolved); } catch (cause) { throw ioError("read description file", resolved, cause); } @@ -43837,7 +49477,7 @@ function readDescriptionFile(workspace, fromFile, cwd, maxBytes) { } let buffer; try { - buffer = (0, import_fs12.readFileSync)(resolved); + buffer = (0, import_fs16.readFileSync)(resolved); } catch (cause) { throw ioError("read description file", resolved, cause); } @@ -43892,12 +49532,12 @@ Valid examples: notification-preferences, auth-v2, payment-retry.` const title = requestedTitle !== void 0 && requestedTitle.length > 0 ? requestedTitle : titleFromSpecName(request.name); const dir = assertInsideWorkspace( workspace.rootDir, - import_path13.default.join(workspace.rootDir, KIRO_DIR_NAME, KIRO_SPECS_DIR, request.name) + import_path17.default.join(workspace.rootDir, KIRO_DIR_NAME, KIRO_SPECS_DIR, request.name) ); - if ((0, import_fs12.existsSync)(dir)) { + if ((0, import_fs16.existsSync)(dir)) { let entries = []; try { - entries = (0, import_fs12.readdirSync)(dir).sort((a2, b) => a2.localeCompare(b, "en")); + entries = (0, import_fs16.readdirSync)(dir).sort((a2, b) => a2.localeCompare(b, "en")); } catch { } throw new SpecBridgeError( @@ -43923,45 +49563,45 @@ Valid examples: notification-preferences, auth-v2, payment-retry.` }; } function executeSpecCreation(workspace, plan) { - const tmpParent = import_path13.default.join(workspace.sidecarDir, "tmp"); - const tempDir = import_path13.default.join( + const tmpParent = import_path17.default.join(workspace.sidecarDir, "tmp"); + const tempDir = import_path17.default.join( tmpParent, `spec-new-${plan.specName}-${process.pid}-${Math.random().toString(36).slice(2, 8)}` ); - const specsDir = import_path13.default.dirname(plan.dir); + const specsDir = import_path17.default.dirname(plan.dir); const writtenFiles = []; try { - (0, import_fs12.mkdirSync)(tempDir, { recursive: true }); + (0, import_fs16.mkdirSync)(tempDir, { recursive: true }); for (const file of plan.files) { - writeFileAtomic(import_path13.default.join(tempDir, file.fileName), file.content); + writeFileAtomic(import_path17.default.join(tempDir, file.fileName), file.content); } - (0, import_fs12.mkdirSync)(specsDir, { recursive: true }); - if ((0, import_fs12.existsSync)(plan.dir)) { + (0, import_fs16.mkdirSync)(specsDir, { recursive: true }); + if ((0, import_fs16.existsSync)(plan.dir)) { throw new SpecBridgeError( "SPEC_ALREADY_EXISTS", `Spec "${plan.specName}" already exists at ${plan.dir}; nothing was written.` ); } try { - (0, import_fs12.renameSync)(tempDir, plan.dir); + (0, import_fs16.renameSync)(tempDir, plan.dir); } catch (cause) { throw ioError("create spec directory", plan.dir, cause); } for (const file of plan.files) { - writtenFiles.push(import_path13.default.join(plan.dir, file.fileName)); + writtenFiles.push(import_path17.default.join(plan.dir, file.fileName)); } let statePath; try { statePath = writeSpecState(workspace, plan.state); } catch (cause) { - (0, import_fs12.rmSync)(plan.dir, { recursive: true, force: true }); + (0, import_fs16.rmSync)(plan.dir, { recursive: true, force: true }); throw cause; } return { plan, writtenFiles, statePath }; } finally { - (0, import_fs12.rmSync)(tempDir, { recursive: true, force: true }); + (0, import_fs16.rmSync)(tempDir, { recursive: true, force: true }); try { - (0, import_fs12.rmdirSync)(tmpParent); + (0, import_fs16.rmdirSync)(tmpParent); } catch { } } @@ -44034,7 +49674,7 @@ function toSpecSummary(bundle) { // ../../packages/mcp-server/src/version.ts var MCP_SERVER_NAME = "specbridge"; -var MCP_SERVER_VERSION = "0.6.0"; +var MCP_SERVER_VERSION = "0.6.1"; var MCP_SERVER_TITLE = "SpecBridge"; var MCP_PROTOCOL_BASELINE = "2025-11-25"; @@ -44144,15 +49784,15 @@ function registerSpecResources(server, context) { var import_node_fs7 = require("fs"); // ../../packages/execution/dist/index.js -var import_fs13 = require("fs"); -var import_path14 = __toESM(require("path"), 1); -var import_fs14 = require("fs"); -var import_path15 = __toESM(require("path"), 1); -var import_path16 = __toESM(require("path"), 1); -var import_fs15 = require("fs"); -var import_path17 = __toESM(require("path"), 1); -var import_crypto3 = require("crypto"); +var import_fs17 = require("fs"); var import_path18 = __toESM(require("path"), 1); +var import_fs18 = require("fs"); +var import_path19 = __toESM(require("path"), 1); +var import_path20 = __toESM(require("path"), 1); +var import_fs19 = require("fs"); +var import_path21 = __toESM(require("path"), 1); +var import_crypto5 = require("crypto"); +var import_path22 = __toESM(require("path"), 1); var RUN_RECORD_SCHEMA_VERSION = "1.0.0"; var runRecordSchema = external_exports.object({ schemaVersion: external_exports.string().regex(/^\d+\.\d+\.\d+$/), @@ -44182,36 +49822,36 @@ var runRecordSchema = external_exports.object({ abortReason: external_exports.string().optional() }).passthrough(); function runsRootDir(workspace) { - return import_path14.default.join(workspace.sidecarDir, "runs"); + return import_path18.default.join(workspace.sidecarDir, "runs"); } function runDir(workspace, runId) { if (!/^[A-Za-z0-9._-]+$/.test(runId)) { throw new SpecBridgeError("INVALID_ARGUMENT", `Invalid run id "${runId}".`); } - return assertInsideWorkspace(workspace.rootDir, import_path14.default.join(runsRootDir(workspace), runId)); + return assertInsideWorkspace(workspace.rootDir, import_path18.default.join(runsRootDir(workspace), runId)); } function runArtifactPath(workspace, runId, fileName) { - return assertInsideWorkspace(workspace.rootDir, import_path14.default.join(runDir(workspace, runId), fileName)); + return assertInsideWorkspace(workspace.rootDir, import_path18.default.join(runDir(workspace, runId), fileName)); } function createRun(workspace, record2) { const validated = runRecordSchema.parse(record2); const dir = runDir(workspace, validated.runId); - if ((0, import_fs13.existsSync)(dir)) { + if ((0, import_fs17.existsSync)(dir)) { throw new SpecBridgeError( "INVALID_STATE", `Run directory already exists: ${dir}. Run ids must be unique.` ); } - (0, import_fs13.mkdirSync)(dir, { recursive: true }); - writeFileAtomic(import_path14.default.join(dir, "run.json"), `${JSON.stringify(validated, null, 2)} + (0, import_fs17.mkdirSync)(dir, { recursive: true }); + writeFileAtomic(import_path18.default.join(dir, "run.json"), `${JSON.stringify(validated, null, 2)} `); return dir; } function readRunRecord(workspace, runId) { - const filePath = import_path14.default.join(runDir(workspace, runId), "run.json"); - if (!(0, import_fs13.existsSync)(filePath)) return void 0; + const filePath = import_path18.default.join(runDir(workspace, runId), "run.json"); + if (!(0, import_fs17.existsSync)(filePath)) return void 0; try { - const parsed = JSON.parse((0, import_fs13.readFileSync)(filePath, "utf8")); + const parsed = JSON.parse((0, import_fs17.readFileSync)(filePath, "utf8")); const result = runRecordSchema.safeParse(parsed); return result.success ? result.data : void 0; } catch { @@ -44225,7 +49865,7 @@ function updateRunRecord(workspace, runId, patch) { } const next = runRecordSchema.parse({ ...current, ...patch }); writeFileAtomic( - import_path14.default.join(runDir(workspace, runId), "run.json"), + import_path18.default.join(runDir(workspace, runId), "run.json"), `${JSON.stringify(next, null, 2)} ` ); @@ -44233,10 +49873,10 @@ function updateRunRecord(workspace, runId, patch) { } function listRuns(workspace) { const root = runsRootDir(workspace); - if (!(0, import_fs13.existsSync)(root)) return { runs: [], diagnostics: [] }; + if (!(0, import_fs17.existsSync)(root)) return { runs: [], diagnostics: [] }; const runs = []; const diagnostics = []; - for (const entry of (0, import_fs13.readdirSync)(root, { withFileTypes: true })) { + for (const entry of (0, import_fs17.readdirSync)(root, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const record2 = readRunRecord(workspace, entry.name); if (record2 !== void 0) { @@ -44246,7 +49886,7 @@ function listRuns(workspace) { severity: "warning", code: "RUN_RECORD_UNREADABLE", message: `Run directory ${entry.name} has no readable run.json; ignoring it.`, - file: import_path14.default.join(root, entry.name) + file: import_path18.default.join(root, entry.name) }); } } @@ -44265,14 +49905,14 @@ function writeRunArtifact(workspace, runId, fileName, content) { } function appendRunEvent(workspace, runId, event) { const filePath = runArtifactPath(workspace, runId, "events.jsonl"); - (0, import_fs13.appendFileSync)(filePath, `${JSON.stringify(event)} + (0, import_fs17.appendFileSync)(filePath, `${JSON.stringify(event)} `, "utf8"); } function readRunArtifactJson(workspace, runId, fileName) { - const filePath = import_path14.default.join(runDir(workspace, runId), fileName); - if (!(0, import_fs13.existsSync)(filePath)) return void 0; + const filePath = import_path18.default.join(runDir(workspace, runId), fileName); + if (!(0, import_fs17.existsSync)(filePath)) return void 0; try { - return JSON.parse((0, import_fs13.readFileSync)(filePath, "utf8")); + return JSON.parse((0, import_fs17.readFileSync)(filePath, "utf8")); } catch { return void 0; } @@ -44594,7 +50234,7 @@ function unifiedDiff(oldText, newText, options = {}) { function stageDocumentPath(workspace, specName, stage) { return assertInsideWorkspace( workspace.rootDir, - import_path15.default.join(workspace.kiroDir, "specs", specName, `${stage}.md`) + import_path19.default.join(workspace.kiroDir, "specs", specName, `${stage}.md`) ); } function normalizeCandidateMarkdown(markdown) { @@ -44604,7 +50244,7 @@ function normalizeCandidateMarkdown(markdown) { } function writeStageDocument(workspace, specName, stage, markdown) { const filePath = stageDocumentPath(workspace, specName, stage); - const exists = (0, import_fs14.existsSync)(filePath); + const exists = (0, import_fs18.existsSync)(filePath); let eol = "lf"; let bom = false; if (exists) { @@ -45031,7 +50671,7 @@ function buildEvidenceSpecContext(workspace, specName, state, task) { } const tasksStage = stateStage(state, "tasks"); if (tasksStage?.status === "approved") { - const planHash = typeof tasksStage.approvedPlanHash === "string" ? tasksStage.approvedPlanHash : tryTaskPlanHashOfFile(import_path16.default.join(workspace.kiroDir, "specs", specName, "tasks.md")); + const planHash = typeof tasksStage.approvedPlanHash === "string" ? tasksStage.approvedPlanHash : tryTaskPlanHashOfFile(import_path20.default.join(workspace.kiroDir, "specs", specName, "tasks.md")); if (planHash !== void 0) specContext.tasksPlanHash = planHash; } return specContext; @@ -45057,7 +50697,7 @@ function applyConfiguredProtectedPaths(config2, comparison) { function taskLineIntact(workspace, specName, task) { try { const document = MarkdownDocument.load( - import_path16.default.join(workspace.kiroDir, "specs", specName, "tasks.md") + import_path20.default.join(workspace.kiroDir, "specs", specName, "tasks.md") ); if (task.line >= document.lineCount) return false; return document.lineAt(task.line).text === task.rawLineText; @@ -45079,15 +50719,15 @@ var interactiveLockSchema = external_exports.object({ function interactiveLockPath(workspace) { return assertInsideWorkspace( workspace.rootDir, - import_path17.default.join(workspace.sidecarDir, "locks", "interactive-task.lock") + import_path21.default.join(workspace.sidecarDir, "locks", "interactive-task.lock") ); } function readInteractiveLock(workspace) { const lockPath = interactiveLockPath(workspace); - if (!(0, import_fs15.existsSync)(lockPath)) return { state: "absent", path: lockPath }; + if (!(0, import_fs19.existsSync)(lockPath)) return { state: "absent", path: lockPath }; let raw; try { - raw = (0, import_fs15.readFileSync)(lockPath, "utf8"); + raw = (0, import_fs19.readFileSync)(lockPath, "utf8"); } catch (cause) { return { state: "unreadable", @@ -45117,9 +50757,9 @@ function acquireInteractiveLock(workspace, details) { createdAt: now, heartbeatAt: now }; - (0, import_fs15.mkdirSync)(import_path17.default.dirname(lockPath), { recursive: true }); + (0, import_fs19.mkdirSync)(import_path21.default.dirname(lockPath), { recursive: true }); try { - (0, import_fs15.writeFileSync)(lockPath, `${JSON.stringify(lock, null, 2)} + (0, import_fs19.writeFileSync)(lockPath, `${JSON.stringify(lock, null, 2)} `, { flag: "wx" }); return { acquired: true, path: lockPath, lock }; } catch { @@ -45144,7 +50784,7 @@ function releaseInteractiveLock(workspace, runId) { problem: `the lock is held by a different run (${read.lock.runId}); refusing to release it` }; } - (0, import_fs15.rmSync)(read.path, { force: true }); + (0, import_fs19.rmSync)(read.path, { force: true }); return { released: true }; } var LOCK_STALE_HEARTBEAT_MS = 6 * 60 * 60 * 1e3; @@ -45254,7 +50894,7 @@ async function beginInteractiveTask(deps, request) { `${predecessors.length} earlier task(s) are still open (next would be ${predecessors[0]?.id}); running ${task.id} out of order.` ); } - const runId = (deps.idFactory ?? import_crypto3.randomUUID)(); + const runId = (deps.idFactory ?? import_crypto5.randomUUID)(); const acquisition = acquireInteractiveLock(workspace, { runId, specName, @@ -45500,7 +51140,7 @@ async function completeInteractiveTask(deps, request) { ); } const task = state.task; - const tasksPath = import_path18.default.join(workspace.kiroDir, "specs", record2.specName, "tasks.md"); + const tasksPath = import_path22.default.join(workspace.kiroDir, "specs", record2.specName, "tasks.md"); let taskIntact = false; try { const document = MarkdownDocument.load(tasksPath); @@ -45802,20 +51442,20 @@ function registerRunResources(server, context) { } // ../../packages/drift/dist/index.js -var import_fs16 = require("fs"); -var import_path19 = __toESM(require("path"), 1); -var import_picomatch = __toESM(require_picomatch2(), 1); -var import_fs17 = require("fs"); -var import_path20 = __toESM(require("path"), 1); -var import_fs18 = require("fs"); -var import_path21 = __toESM(require("path"), 1); -var import_fs19 = require("fs"); -var import_path22 = __toESM(require("path"), 1); var import_fs20 = require("fs"); var import_path23 = __toESM(require("path"), 1); +var import_picomatch = __toESM(require_picomatch2(), 1); var import_fs21 = require("fs"); -var import_crypto4 = require("crypto"); var import_path24 = __toESM(require("path"), 1); +var import_fs22 = require("fs"); +var import_path25 = __toESM(require("path"), 1); +var import_fs23 = require("fs"); +var import_path26 = __toESM(require("path"), 1); +var import_fs24 = require("fs"); +var import_path27 = __toESM(require("path"), 1); +var import_fs25 = require("fs"); +var import_crypto6 = require("crypto"); +var import_path28 = __toESM(require("path"), 1); var taskEvidenceSchema = external_exports.object({ taskId: external_exports.string().min(1), status: external_exports.enum(["recorded", "verified", "rejected"]), @@ -45902,24 +51542,24 @@ var verificationPolicySchema = external_exports.object({ } }); function policyDir(workspace) { - return import_path19.default.join(workspace.sidecarDir, "policies"); + return import_path23.default.join(workspace.sidecarDir, "policies"); } function policyPath(workspace, specName) { - const resolved = import_path19.default.resolve(policyDir(workspace), `${specName}.json`); - const relative = import_path19.default.relative(workspace.rootDir, resolved); - if (relative.startsWith("..") || import_path19.default.isAbsolute(relative)) { - return import_path19.default.join(policyDir(workspace), "invalid-spec-name.json"); + const resolved = import_path23.default.resolve(policyDir(workspace), `${specName}.json`); + const relative = import_path23.default.relative(workspace.rootDir, resolved); + if (relative.startsWith("..") || import_path23.default.isAbsolute(relative)) { + return import_path23.default.join(policyDir(workspace), "invalid-spec-name.json"); } return resolved; } function readVerificationPolicy(workspace, specName, explicitPath) { - const filePath = explicitPath !== void 0 ? import_path19.default.resolve(workspace.rootDir, explicitPath) : policyPath(workspace, specName); - if (!(0, import_fs16.existsSync)(filePath)) { + const filePath = explicitPath !== void 0 ? import_path23.default.resolve(workspace.rootDir, explicitPath) : policyPath(workspace, specName); + if (!(0, import_fs20.existsSync)(filePath)) { return { path: filePath, exists: false, diagnostics: [] }; } let parsed; try { - parsed = JSON.parse((0, import_fs16.readFileSync)(filePath, "utf8")); + parsed = JSON.parse((0, import_fs20.readFileSync)(filePath, "utf8")); } catch (cause) { return { path: filePath, @@ -45982,7 +51622,7 @@ function resolveEffectivePolicy(workspace, specName, options = {}) { const storedMode = policy?.mode ?? "advisory"; const strictFromCli = options.strict === true && storedMode !== "strict"; const mode = options.strict === true ? "strict" : storedMode; - const workspaceRelativePolicyPath = import_path19.default.relative(workspace.rootDir, read.path).split(import_path19.default.sep).join("/"); + const workspaceRelativePolicyPath = import_path23.default.relative(workspace.rootDir, read.path).split(import_path23.default.sep).join("/"); return { specName, mode, @@ -46121,33 +51761,33 @@ function mergeNumstat(files, stats) { function sniffBinary(absolutePath) { let fd; try { - fd = (0, import_fs17.openSync)(absolutePath, "r"); + fd = (0, import_fs21.openSync)(absolutePath, "r"); const buffer = Buffer.alloc(8e3); - const bytesRead = (0, import_fs17.readSync)(fd, buffer, 0, buffer.length, 0); + const bytesRead = (0, import_fs21.readSync)(fd, buffer, 0, buffer.length, 0); return buffer.subarray(0, bytesRead).includes(0); } catch { return false; } finally { - if (fd !== void 0) (0, import_fs17.closeSync)(fd); + if (fd !== void 0) (0, import_fs21.closeSync)(fd); } } function flagSymlinkEscapes(repoRoot, files) { const resolvedRoot = (() => { try { - return (0, import_fs17.realpathSync)(repoRoot); + return (0, import_fs21.realpathSync)(repoRoot); } catch { - return import_path20.default.resolve(repoRoot); + return import_path24.default.resolve(repoRoot); } })(); for (const file of files) { if (file.changeType === "deleted") continue; - const absolute = import_path20.default.join(repoRoot, file.path.split("/").join(import_path20.default.sep)); + const absolute = import_path24.default.join(repoRoot, file.path.split("/").join(import_path24.default.sep)); try { - const stats = (0, import_fs17.lstatSync)(absolute); + const stats = (0, import_fs21.lstatSync)(absolute); if (!stats.isSymbolicLink()) continue; - const target = (0, import_fs17.realpathSync)(absolute); - const relative = import_path20.default.relative(resolvedRoot, target); - if (relative.startsWith("..") || import_path20.default.isAbsolute(relative)) { + const target = (0, import_fs21.realpathSync)(absolute); + const relative = import_path24.default.relative(resolvedRoot, target); + if (relative.startsWith("..") || import_path24.default.isAbsolute(relative)) { file.symlinkOutsideRepository = true; } } catch { @@ -46275,7 +51915,7 @@ async function resolveComparison(repoRoot, request, options = {}) { const known = new Set(files.map((file) => file.path)); for (const token of untracked.stdout.split("\0")) { if (token.length === 0 || known.has(token)) continue; - const absolute = import_path20.default.join(repoRoot, token.split("/").join(import_path20.default.sep)); + const absolute = import_path24.default.join(repoRoot, token.split("/").join(import_path24.default.sep)); files.push({ path: token, changeType: "untracked", @@ -46355,9 +51995,9 @@ function specMatchReasons(specName, policy, validEvidencePaths, designPathRefere function readSpecEvidenceRecords(workspace, specName) { const byTask = /* @__PURE__ */ new Map(); let invalidRecordCount = 0; - const specDir = import_path21.default.join(workspace.sidecarDir, "evidence", specName); - if ((0, import_fs18.existsSync)(specDir)) { - const taskDirs = (0, import_fs18.readdirSync)(specDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort((a2, b) => a2.localeCompare(b, "en")); + const specDir = import_path25.default.join(workspace.sidecarDir, "evidence", specName); + if ((0, import_fs22.existsSync)(specDir)) { + const taskDirs = (0, import_fs22.readdirSync)(specDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort((a2, b) => a2.localeCompare(b, "en")); for (const taskDir of taskDirs) { const { records, diagnostics } = listTaskEvidence(workspace, specName, taskDir); invalidRecordCount += diagnostics.length; @@ -46404,7 +52044,7 @@ async function buildSpecVerificationContext(options) { } if (effective("tasks") && tasksStage !== void 0) { const planHash = typeof tasksStage.approvedPlanHash === "string" ? tasksStage.approvedPlanHash : tryTaskPlanHashOfFile( - import_path21.default.join(workspace.rootDir, tasksStage.file.split("/").join(import_path21.default.sep)) + import_path25.default.join(workspace.rootDir, tasksStage.file.split("/").join(import_path25.default.sep)) ); if (planHash !== void 0) approved.tasksPlanHash = planHash; } @@ -46626,7 +52266,7 @@ async function evaluateGlobalRules(rules, context) { return { diagnostics, disabledRules }; } function repoRelative2(workspace, absolutePath) { - return import_path22.default.relative(workspace.rootDir, absolutePath).split(import_path22.default.sep).join("/"); + return import_path26.default.relative(workspace.rootDir, absolutePath).split(import_path26.default.sep).join("/"); } function isSpecInfraPath(candidate) { return candidate === ".git" || candidate.startsWith(".git/") || candidate.startsWith(".kiro/") || candidate.startsWith(".specbridge/"); @@ -47307,14 +52947,14 @@ var sbv018 = { if (designDocument === void 0) return []; const designFile = designDocument.filePath; const designRepoPath = designFile !== void 0 ? repoRelative2(context.workspace, designFile) : void 0; - const specDir = import_path22.default.join(context.workspace.rootDir, ".kiro", "specs", context.specName); + const specDir = import_path26.default.join(context.workspace.rootDir, ".kiro", "specs", context.specName); return context.traceability.designPathReferences.filter((reference) => !reference.isGlob).filter((reference) => { - const fromRoot = import_path22.default.join( + const fromRoot = import_path26.default.join( context.workspace.rootDir, - reference.path.split("/").join(import_path22.default.sep) + reference.path.split("/").join(import_path26.default.sep) ); - const fromSpecDir = import_path22.default.join(specDir, reference.path.split("/").join(import_path22.default.sep)); - return !(0, import_fs19.existsSync)(fromRoot) && !(0, import_fs19.existsSync)(fromSpecDir); + const fromSpecDir = import_path26.default.join(specDir, reference.path.split("/").join(import_path26.default.sep)); + return !(0, import_fs23.existsSync)(fromRoot) && !(0, import_fs23.existsSync)(fromSpecDir); }).map( (reference) => makeDiagnostic({ rule: this, @@ -47544,9 +53184,9 @@ function loadSpecMatchingInfo(workspace, folder, options) { } } const evidencePaths = /* @__PURE__ */ new Set(); - const evidenceDir2 = import_path23.default.join(workspace.sidecarDir, "evidence", folder.name); - if ((0, import_fs20.existsSync)(evidenceDir2)) { - for (const entry of (0, import_fs21.readdirSync)(evidenceDir2, { withFileTypes: true })) { + const evidenceDir2 = import_path27.default.join(workspace.sidecarDir, "evidence", folder.name); + if ((0, import_fs24.existsSync)(evidenceDir2)) { + for (const entry of (0, import_fs25.readdirSync)(evidenceDir2, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const { records } = listTaskEvidence(workspace, folder.name, entry.name); for (const record2 of records) { @@ -47604,7 +53244,7 @@ var VERIFY_EXIT_CODES = { }; async function verifySpecs(request) { const now = (request.clock ?? (() => /* @__PURE__ */ new Date()))(); - const verificationId = (request.idFactory ?? import_crypto4.randomUUID)(); + const verificationId = (request.idFactory ?? import_crypto6.randomUUID)(); const workspace = request.workspace; const configRead = readAgentConfig(workspace); if (configRead.config === void 0) { @@ -47655,8 +53295,8 @@ async function verifySpecs(request) { let artifactsDir; const ensureArtifactsDir = () => { if (artifactsDir === void 0) { - const base = request.reportsDir ?? import_path24.default.join(workspace.sidecarDir, "reports"); - artifactsDir = import_path24.default.join(base, verificationId); + const base = request.reportsDir ?? import_path28.default.join(workspace.sidecarDir, "reports"); + artifactsDir = import_path28.default.join(base, verificationId); } return artifactsDir; }; @@ -47679,8 +53319,8 @@ async function verifySpecs(request) { onCommandFinished: (result, stdout, stderr) => { const dir = ensureArtifactsDir(); const safeName = result.name.replace(/[^A-Za-z0-9._-]+/g, "-"); - writeFileAtomic(import_path24.default.join(dir, "commands", `${safeName}.stdout.log`), stdout); - writeFileAtomic(import_path24.default.join(dir, "commands", `${safeName}.stderr.log`), stderr); + writeFileAtomic(import_path28.default.join(dir, "commands", `${safeName}.stdout.log`), stdout); + writeFileAtomic(import_path28.default.join(dir, "commands", `${safeName}.stderr.log`), stderr); } } : {} }) : { mode: "none", commands: [], missingRequired: [] }; @@ -47757,7 +53397,7 @@ async function verifySpecs(request) { verificationReportSchema.parse(report); if (persistArtifacts && artifactsDir !== void 0) { writeFileAtomic( - import_path24.default.join(artifactsDir, "report.json"), + import_path28.default.join(artifactsDir, "report.json"), `${JSON.stringify(report, null, 2)} ` ); @@ -50072,6 +55712,389 @@ ${commandLines.join("\n")}` : "No verification commands are configured.", }); } +// ../../packages/mcp-server/src/tools/runner-shared.ts +var import_node_fs9 = require("fs"); +var import_node_os4 = __toESM(require("os"), 1); +var import_node_path9 = __toESM(require("path"), 1); +function loadRunnerToolContext(context) { + const workspace = context.requireWorkspace(); + const config2 = requireAgentConfig(workspace); + return { workspace, config: config2, registry: createDefaultRunnerRegistry(config2) }; +} +function requireProfile(registry2, name) { + if (!registry2.has(name)) { + throw new McpToolError( + "SBMCP002", + `Unknown runner profile "${name}". Configured profiles: ${registry2.listProfiles().map((profile) => profile.name).join(", ")}.`, + { remediation: ["Call runner_list to see every configured profile."] } + ); + } + return registry2.getProfile(name); +} +var RUNNER_PROBE_TIMEOUT_MS = 2e4; +var capabilitySetShape2 = external_exports.record(external_exports.boolean()).describe("Provider-independent capability keys to booleans"); +var profileSummaryShape = external_exports.object({ + profile: external_exports.string(), + implementation: external_exports.string(), + category: external_exports.string(), + supportLevel: external_exports.string().describe("Adapter-declared support level"), + enabled: external_exports.boolean(), + model: external_exports.string().nullable(), + networkBacked: external_exports.boolean().describe("True when SpecBridge itself would leave this machine"), + localExecution: external_exports.boolean(), + supportedOperations: external_exports.array(external_exports.string()) +}); +var detectionCapabilityShape = external_exports.object({ + id: external_exports.string(), + label: external_exports.string(), + available: external_exports.boolean(), + required: external_exports.boolean(), + detail: external_exports.string().optional() +}); +var runnerDiagnosticShape = external_exports.object({ + severity: external_exports.enum(["info", "warning", "error"]), + code: external_exports.string(), + message: external_exports.string() +}); +var detectionViewShape = external_exports.object({ + status: external_exports.string(), + supportLevel: external_exports.string().describe("Effective support level after detection"), + version: external_exports.string().nullable(), + authentication: external_exports.string(), + networkBacked: external_exports.boolean(), + capabilities: external_exports.array(detectionCapabilityShape), + detectedCapabilities: capabilitySetShape2, + diagnostics: external_exports.array(runnerDiagnosticShape) +}); +var MAX_DIAGNOSTICS = 50; +function toDetectionView(detection, verbose) { + const diagnostics = (verbose ? detection.diagnostics : detection.diagnostics.filter((diagnostic) => diagnostic.severity !== "info")).slice(0, MAX_DIAGNOSTICS); + return { + status: detection.status, + supportLevel: detection.supportLevel, + version: detection.version ?? null, + authentication: detection.authentication, + networkBacked: detection.networkBacked, + capabilities: detection.capabilities.map((capability) => ({ + id: String(capability.id), + label: capability.label, + available: capability.available, + required: capability.required, + ...capability.detail !== void 0 ? { detail: capability.detail } : {} + })), + detectedCapabilities: detection.capabilitySet, + diagnostics: diagnostics.map((diagnostic) => ({ + severity: diagnostic.severity, + code: diagnostic.code, + message: diagnostic.message + })) + }; +} +var conformanceSummaryShape = external_exports.object({ + passed: external_exports.boolean(), + productionConfirmed: external_exports.boolean(), + failedChecks: external_exports.number().int(), + skippedChecks: external_exports.number().int(), + groups: external_exports.array( + external_exports.object({ + group: external_exports.string(), + applicable: external_exports.boolean(), + reason: external_exports.string().optional(), + passed: external_exports.boolean(), + skipped: external_exports.number().int() + }) + ), + note: external_exports.string() +}); +async function invocationFreeConformanceSummary(profile) { + const scratch = (0, import_node_fs9.mkdtempSync)(import_node_path9.default.join(import_node_os4.default.tmpdir(), "specbridge-mcp-conformance-")); + let result; + try { + result = await runRunnerConformance({ + profile, + workspaceRoot: scratch, + runDir: import_node_path9.default.join(scratch, ".specbridge-conformance-runs"), + invocationsAllowed: false, + timeoutMs: RUNNER_PROBE_TIMEOUT_MS + }); + } finally { + (0, import_node_fs9.rmSync)(scratch, { recursive: true, force: true }); + } + return { + passed: result.passed, + productionConfirmed: result.productionConfirmed, + failedChecks: result.failedChecks, + skippedChecks: result.skippedChecks, + groups: result.groups.map((group) => ({ + group: group.group, + applicable: group.applicable, + ...group.reason !== void 0 ? { reason: group.reason } : {}, + passed: group.passed, + skipped: group.skipped + })), + note: 'Invocation-free summary: checks that would invoke the provider are skipped here. Run "specbridge runner conformance --network" for the full suite.' + }; +} + +// ../../packages/mcp-server/src/tools/runner-list.ts +var inputSchema17 = { + enabledOnly: external_exports.boolean().optional().describe("Only profiles that are enabled in the configuration"), + detect: external_exports.boolean().optional().describe( + "Probe availability for the returned page (read-only version/help/reachability probes; slower \u2014 default false)" + ), + limit: limitArg, + cursor: cursorArg +}; +var outputSchema22 = { + defaultRunner: external_exports.string(), + profiles: external_exports.array( + profileSummaryShape.extend({ + availability: external_exports.string().optional().describe("Detection status (present when detect=true)") + }) + ), + pagination: paginationShape +}; +function registerRunnerListTool(server, context) { + registerDefinedTool(server, context, { + name: "runner_list", + title: "List runner profiles", + description: "List configured runner profiles: implementation, category, support level, enabled state, configured model, local/network classification, and supported operations. Optionally probes availability (read-only; never a model request). Supports pagination. Read-only.", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }, + inputSchema: inputSchema17, + outputSchema: outputSchema22, + handler: async (args) => { + const { workspace, config: config2, registry: registry2 } = loadRunnerToolContext(context); + const profiles = registry2.listProfiles().filter((profile) => args.enabledOnly !== true || profile.config.enabled !== false); + const page = paginate(profiles, { + ...args.limit !== void 0 ? { limit: args.limit } : {}, + ...args.cursor !== void 0 ? { cursor: args.cursor } : {}, + token: "runner_list" + }); + const summaries = []; + for (const profile of page.items) { + const summary = runnerProfileSummary(profile); + if (args.detect === true) { + const detection = await profile.runner.detect({ + workspaceRoot: workspace.rootDir, + timeoutMs: RUNNER_PROBE_TIMEOUT_MS + }); + summaries.push({ ...summary, availability: detection.status }); + } else { + summaries.push(summary); + } + } + const lines = summaries.map( + (summary) => `- ${summary.profile} [${summary.implementation}/${summary.category}] ${summary.enabled ? "enabled" : "disabled"}, support ${summary.supportLevel}${"availability" in summary && summary.availability !== void 0 ? `, ${summary.availability}` : ""}, operations: ${summary.supportedOperations.join(", ") || "(none)"}` + ); + return { + text: `${page.totalCount} runner profile(s); default runner: ${config2.defaultRunner}. +${lines.join("\n")}`, + structured: { + defaultRunner: config2.defaultRunner, + profiles: summaries, + pagination: { + totalCount: page.totalCount, + truncated: page.truncated, + ...page.nextCursor !== void 0 ? { nextCursor: page.nextCursor } : {} + } + } + }; + } + }); +} + +// ../../packages/mcp-server/src/tools/runner-show.ts +var inputSchema18 = { + profile: external_exports.string().min(1).max(120).describe('Runner profile name (e.g. "gemini-default")') +}; +var operationCompatibilityShape = external_exports.object({ + operation: external_exports.string(), + supported: external_exports.boolean(), + missingCapabilities: external_exports.array(external_exports.string()) +}); +var outputSchema23 = { + summary: profileSummaryShape, + configuration: external_exports.record(external_exports.unknown()).describe("Redacted profile configuration (profiles can never store credential values)"), + declaredCapabilities: capabilitySetShape2, + detection: detectionViewShape, + operationCompatibility: external_exports.array(operationCompatibilityShape).describe("Per-operation support from DETECTED capabilities"), + conformance: conformanceSummaryShape, + boundary: external_exports.object({ + networkBacked: external_exports.boolean(), + localExecution: external_exports.boolean(), + constraints: external_exports.array(external_exports.string()) + }), + limitations: external_exports.array(external_exports.string()), + remediation: external_exports.array(external_exports.string()) +}; +function registerRunnerShowTool(server, context) { + registerDefinedTool(server, context, { + name: "runner_show", + title: "Show a runner profile", + description: "Show one runner profile: redacted configuration, declared and detected capabilities, operation compatibility, invocation-free conformance summary, network boundary, known limitations, and remediation. Read-only; never sends a model request.", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }, + inputSchema: inputSchema18, + outputSchema: outputSchema23, + handler: async (args) => { + const { workspace, registry: registry2 } = loadRunnerToolContext(context); + const profile = requireProfile(registry2, args.profile); + const summary = runnerProfileSummary(profile); + const detection = await profile.runner.detect({ + workspaceRoot: workspace.rootDir, + probeCapabilities: true, + timeoutMs: RUNNER_PROBE_TIMEOUT_MS + }); + const detectionView = toDetectionView(detection, true); + const conformance = await invocationFreeConformanceSummary(profile); + const operationCompatibility = RUNNER_OPERATIONS.filter( + (operation) => operation !== "model-list" && operation !== "runner-test" + ).map((operation) => { + const support = checkOperationSupport(operation, detection.capabilitySet); + return { + operation, + supported: support.supported, + missingCapabilities: [ + ...support.missingCapabilities, + ...support.unsatisfiedBoundaries.flat() + ] + }; + }); + const boundaryNote = profile.runner.executionBoundaryNote?.("implementation"); + const limitations = detectionView.diagnostics.filter((diagnostic) => diagnostic.severity !== "error").map((diagnostic) => diagnostic.message); + const remediation = detectionView.diagnostics.filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => diagnostic.message); + const supported = operationCompatibility.filter((entry) => entry.supported).map((entry) => entry.operation); + return { + text: `Profile ${args.profile} (${summary.implementation}, ${summary.category}): ${summary.enabled ? "enabled" : "disabled"}, status ${detection.status}, support ${detection.supportLevel}. Supported operations (detected): ${supported.join(", ") || "(none)"}.`, + structured: { + summary, + configuration: redactedRunnerProfileConfig(profile), + declaredCapabilities: profile.runner.declaredCapabilities, + detection: detectionView, + operationCompatibility, + conformance, + boundary: { + networkBacked: summary.networkBacked, + localExecution: summary.localExecution, + constraints: [ + ...boundaryNote !== void 0 ? [boundaryNote] : [], + "No commits, no pushes, no checkbox updates by the provider; evidence stays provider-independent." + ] + }, + limitations, + remediation + } + }; + } + }); +} + +// ../../packages/mcp-server/src/tools/runner-doctor.ts +var inputSchema19 = { + profile: external_exports.string().min(1).max(120).optional().describe("Runner profile name (default: the configured default runner)"), + verbose: external_exports.boolean().optional().describe("Include informational diagnostics") +}; +var outputSchema24 = { + profile: external_exports.string(), + implementation: external_exports.string(), + category: external_exports.string(), + enabled: external_exports.boolean(), + executable: external_exports.string().nullable().describe("Configured executable or endpoint"), + detection: detectionViewShape, + ready: external_exports.boolean().describe("True when the runner status is available") +}; +function registerRunnerDoctorTool(server, context) { + registerDefinedTool(server, context, { + name: "runner_doctor", + title: "Diagnose a runner profile", + description: "Diagnose one runner profile: executable/endpoint presence, version, authentication state (never via credential files), detected capabilities, and actionable findings. Read-only: never a model request, never a login, never a configuration change.", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }, + inputSchema: inputSchema19, + outputSchema: outputSchema24, + handler: async (args) => { + const { workspace, config: config2, registry: registry2 } = loadRunnerToolContext(context); + const profileName = args.profile ?? config2.defaultRunner; + const profile = requireProfile(registry2, profileName); + const detection = await profile.runner.detect({ + workspaceRoot: workspace.rootDir, + probeCapabilities: true, + timeoutMs: RUNNER_PROBE_TIMEOUT_MS + }); + const view = toDetectionView(detection, args.verbose === true); + const findings = view.diagnostics.map((diagnostic) => `- [${diagnostic.severity}] ${diagnostic.message}`).join("\n"); + return { + text: `Runner ${profileName} (${profile.runner.name}): status ${view.status}, support ${view.supportLevel}, authentication ${view.authentication}.${findings.length > 0 ? ` +${findings}` : ""}`, + structured: { + profile: profileName, + implementation: profile.runner.name, + category: profile.runner.category, + enabled: profile.config.enabled !== false, + executable: detection.executable ?? null, + detection: view, + ready: detection.status === "available" + } + }; + } + }); +} + +// ../../packages/mcp-server/src/tools/runner-matrix.ts +var matrixRowShape = external_exports.object({ + profile: external_exports.string(), + implementation: external_exports.string(), + category: external_exports.string(), + support: external_exports.string(), + enabled: external_exports.boolean(), + author: external_exports.boolean(), + refine: external_exports.boolean(), + execute: external_exports.boolean(), + resume: external_exports.boolean(), + local: external_exports.boolean() +}); +var outputSchema25 = { + rows: external_exports.array(matrixRowShape), + markdown: external_exports.string().describe("The same matrix as a Markdown table") +}; +function registerRunnerMatrixTool(server, context) { + registerDefinedTool(server, context, { + name: "runner_matrix", + title: "Runner capability matrix", + description: 'The authoritative runner capability matrix (author/refine/execute/resume per profile), generated from registered runner metadata \u2014 identical to "specbridge runner matrix". Read-only; no probes, no processes, no network.', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }, + inputSchema: {}, + outputSchema: outputSchema25, + handler: async () => { + const { registry: registry2 } = loadRunnerToolContext(context); + const rows = runnerMatrixRows(registry2.listProfiles()); + const markdown = renderRunnerMatrixMarkdown(rows); + return { + text: markdown, + structured: { rows, markdown } + }; + } + }); +} + // ../../packages/mcp-server/src/tools/registry.ts function registerAllTools(server, context) { registerWorkspaceDetectTool(server, context); @@ -50088,6 +56111,10 @@ function registerAllTools(server, context) { registerRunReadTool(server, context); registerSpecAffectedTool(server, context); registerSpecCheckDriftTool(server, context); + registerRunnerListTool(server, context); + registerRunnerShowTool(server, context); + registerRunnerDoctorTool(server, context); + registerRunnerMatrixTool(server, context); registerSpecCreateTool(server, context); registerSpecStageValidateTool(server, context); registerSpecStageApplyTool(server, context); diff --git a/integrations/claude-code-plugin/specbridge/skills/doctor/SKILL.md b/integrations/claude-code-plugin/specbridge/skills/doctor/SKILL.md index 136bf54..8acef86 100644 --- a/integrations/claude-code-plugin/specbridge/skills/doctor/SKILL.md +++ b/integrations/claude-code-plugin/specbridge/skills/doctor/SKILL.md @@ -17,7 +17,7 @@ Diagnose the SpecBridge setup. Everything here is read-only — change nothing. 3. If the MCP tool is unavailable, say so and suggest the bundled CLI check: `"${CLAUDE_PLUGIN_ROOT}/bin/specbridge" mcp doctor` — but do not run it without the user's go-ahead. -4. Report the plugin version (0.5.0) and the MCP server version from the tool +4. Report the plugin version (0.6.1) and the MCP server version from the tool results where shown. 5. Suggest the next command: - no workspace → `/specbridge:new [description]` diff --git a/integrations/claude-code-plugin/specbridge/skills/runners/SKILL.md b/integrations/claude-code-plugin/specbridge/skills/runners/SKILL.md new file mode 100644 index 0000000..8fab7ee --- /dev/null +++ b/integrations/claude-code-plugin/specbridge/skills/runners/SKILL.md @@ -0,0 +1,64 @@ +--- +name: runners +description: Inspect SpecBridge runner profiles — list configured runners, show the capability matrix (author/refine/execute/resume), diagnose one profile, and recommend compatible profiles for an operation. Use when the user asks which runners are available, why a runner is not usable, or which profile can execute tasks. Read-only. +--- + +# SpecBridge runners + +Arguments: `[profile-name]` (optional, e.g. `gemini-default`, +`openai-compatible-local`, `antigravity`). + +Read-only diagnostics: never edit configuration, never invoke a provider, +never send a network request yourself, never authenticate, and never start +Gemini, Codex, Antigravity, Ollama inference, or API inference from this +skill. All information comes from the SpecBridge MCP diagnostic tools +(from the `specbridge` MCP server this plugin bundles; your host may show +them with an `mcp__…` prefix) — they run safe local probes only and never +make a model request. + +## No profile name given + +1. Call the SpecBridge MCP tool `runner_list`. +2. Call the SpecBridge MCP tool `runner_matrix`. +3. Present a compact overview: + - each profile: implementation, category (agent-cli / model-api / + experimental), enabled or disabled, support level; + - the capability matrix (Author / Refine / Execute / Resume / Local). +4. Explain the boundaries briefly: + - agent CLIs (claude-code, codex-cli, gemini-cli) run locally and handle + their own provider connectivity; + - model APIs (ollama, openai-compatible) are authoring-only — they can + never execute tasks or modify source; + - experimental adapters (antigravity-cli) are detection-only; + - network-backed profiles are never selected implicitly. +5. Mention that disabled profiles must be enabled explicitly in + `.specbridge/config.json` — but do not edit that file yourself. + +## Profile name given + +1. Call the SpecBridge MCP tool `runner_show` with the profile name. +2. If the user asked why something is broken or not ready, also call + `runner_doctor` with the profile name for the actionable findings. +3. Present concisely: + - availability, support level, and authentication state; + - detected capabilities and which operations they support; + - the security boundary (read-only authoring, bounded edit policy, no + YOLO, no arbitrary shell, no credential storage); + - known limitations and the remediation steps from the diagnostics. +4. If the requested operation is unsupported (for example task execution on + `openai-compatible-local` or `antigravity`), recommend the compatible + profiles the tools report instead — typically `claude-code` or + `codex-default`. + +## Boundaries + +- Never modify `.kiro`, `.specbridge`, or any configuration file from this + skill. +- Never run a provider CLI or send an HTTP request to check something the + MCP tools already report. +- Never present provider claims as verification: task completion is decided + by Git evidence and trusted verification commands, whatever runner is + used. +- Deeper checks are explicit CLI actions for the user, not this skill: + `specbridge runner conformance ` and + `specbridge runner test --network`. diff --git a/integrations/github-action/dist/index.js b/integrations/github-action/dist/index.js index b97f016..20001c4 100644 --- a/integrations/github-action/dist/index.js +++ b/integrations/github-action/dist/index.js @@ -34199,7 +34199,10 @@ var RUNNER_CONFIG_SCHEMA_VERSION = "2.0.0"; var BUILT_IN_PROFILE_NAMES = { "claude-code": "claude-code", "codex-cli": "codex-default", + "gemini-cli": "gemini-default", ollama: "ollama-local", + "openai-compatible": "openai-compatible-local", + "antigravity-cli": "antigravity", mock: "mock" }; var PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; @@ -34292,10 +34295,109 @@ var ollamaProfileSchema = external_exports.object({ var mockProfileSchema = mockRunnerConfigSchema.extend({ runner: external_exports.literal("mock") }); +var GEMINI_AUTHORING_APPROVAL_MODES = ["plan"]; +var GEMINI_EXECUTION_APPROVAL_MODES = ["auto_edit", "default"]; +var geminiProfileSchema = external_exports.object({ + runner: external_exports.literal("gemini-cli"), + enabled: external_exports.boolean().default(false), + command: commandSpecSchema.default({ executable: "gemini", args: [] }), + model: safeNonEmptyString.nullable().default(null), + /** Authoring is always read-only; only plan mode is accepted. */ + approvalModeForAuthoring: external_exports.enum(GEMINI_AUTHORING_APPROVAL_MODES).default("plan"), + /** Task execution may auto-approve EDITS only — never shell commands. */ + approvalModeForExecution: external_exports.enum(GEMINI_EXECUTION_APPROVAL_MODES).default("auto_edit"), + /** Pass --sandbox when the installed CLI supports it. */ + sandbox: external_exports.boolean().default(true), + /** + * Extra tools to allow during task execution, on top of the adapter's + * bounded read/edit set. Shell-execution tools are rejected. + */ + allowedTools: external_exports.array(safeNonEmptyString).default([]).refine( + (tools) => tools.every((tool) => !/^(run_shell_command|shell|bash|execute_command|terminal)$/i.test(tool)), + { + message: "shell-execution tools cannot be allowed: SpecBridge never grants the Gemini CLI arbitrary shell access" + } + ), + /** Pass the extension-restriction flag when supported (default on). */ + disabledExtensions: external_exports.boolean().default(true), + timeoutMs: external_exports.number().int().min(1e3).max(864e5).default(18e5), + maxStdoutBytes: external_exports.number().int().min(1024).default(10 * 1024 * 1024), + maxStderrBytes: external_exports.number().int().min(1024).default(1024 * 1024) +}).passthrough(); +var OPENAI_COMPATIBLE_API_STYLES = ["chat-completions", "responses"]; +var OPENAI_COMPATIBLE_STRUCTURED_OUTPUT_MODES = [ + "json-schema", + "json-object", + "strict-json-prompt" +]; +var environmentVariableNameSchema = external_exports.string().regex( + /^[A-Za-z_][A-Za-z0-9_]*$/, + "must be an environment-variable NAME (letters, digits, underscore); SpecBridge never stores key values" +); +var FORBIDDEN_HEADER_NAME_PATTERN = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key|api-key|x-auth-token)$/i; +var safeHeadersSchema = external_exports.record(external_exports.string().max(1024)).superRefine((headers, ctx) => { + for (const [name, value] of Object.entries(headers)) { + if (!/^[A-Za-z0-9-]+$/.test(name)) { + ctx.addIssue({ + code: external_exports.ZodIssueCode.custom, + message: `header name "${name}" is invalid (letters, digits, and "-" only)` + }); + } + if (FORBIDDEN_HEADER_NAME_PATTERN.test(name)) { + ctx.addIssue({ + code: external_exports.ZodIssueCode.custom, + message: `header "${name}" would carry a credential value. SpecBridge never stores credentials; use apiKeyEnvironmentVariable (a variable NAME) instead.` + }); + } + if (value.includes("\0") || value.includes("\n") || value.includes("\r")) { + ctx.addIssue({ + code: external_exports.ZodIssueCode.custom, + message: `header "${name}" contains control characters` + }); + } + } +}); +var openAiCompatibleProfileSchema = external_exports.object({ + runner: external_exports.literal("openai-compatible"), + enabled: external_exports.boolean().default(false), + baseUrl: safeNonEmptyString.default("http://127.0.0.1:8000/v1"), + apiStyle: external_exports.enum(OPENAI_COMPATIBLE_API_STYLES).default("chat-completions"), + model: safeNonEmptyString.nullable().default(null), + structuredOutput: external_exports.enum(OPENAI_COMPATIBLE_STRUCTURED_OUTPUT_MODES).default("json-schema"), + /** + * Explicit permission to fall back from the configured structured-output + * mode to the next weaker one when the endpoint rejects it. Off by + * default: an unsupported mode is an error, never a silent downgrade. + */ + allowStructuredOutputFallback: external_exports.boolean().default(false), + /** Name of the environment variable holding the API key (never a value). */ + apiKeyEnvironmentVariable: environmentVariableNameSchema.nullable().default(null), + /** Static capability declaration: the endpoint supports GET /models. */ + modelsEndpoint: external_exports.boolean().default(false), + /** Custom safe headers (credential-bearing header names are rejected). */ + headers: safeHeadersSchema.default({}), + temperature: external_exports.number().min(0).max(2).default(0), + timeoutMs: external_exports.number().int().min(1e3).max(864e5).default(3e5), + maximumInputCharacters: external_exports.number().int().min(1e3).default(5e5), + maximumOutputBytes: external_exports.number().int().min(1024).default(2097152), + /** Explicit development override for private plain-HTTP endpoints (INSECURE). */ + allowInsecureHttp: external_exports.boolean().default(false) +}).passthrough(); +var antigravityProfileSchema = external_exports.object({ + runner: external_exports.literal("antigravity-cli"), + enabled: external_exports.boolean().default(false), + command: commandSpecSchema.default({ executable: "agy", args: [] }), + /** Always true: the adapter is experimental and cannot be marked otherwise. */ + experimental: external_exports.literal(true).default(true), + timeoutMs: external_exports.number().int().min(1e3).max(6e5).default(3e4) +}).passthrough(); var runnerProfileSchema = external_exports.discriminatedUnion("runner", [ claudeProfileSchema, codexProfileSchema, + geminiProfileSchema, ollamaProfileSchema, + openAiCompatibleProfileSchema, + antigravityProfileSchema, mockProfileSchema ]); var runnerPolicySchema = external_exports.object({ @@ -34354,7 +34456,7 @@ var agentConfigV2Schema = external_exports.object({ } } for (const [name, profile] of Object.entries(config.runnerProfiles)) { - if (profile.runner === "ollama") { + if (profile.runner === "ollama" || profile.runner === "openai-compatible") { const url = validateRunnerBaseUrl(profile.baseUrl, { allowInsecureHttp: profile.allowInsecureHttp }); @@ -34390,6 +34492,15 @@ function builtInCodexProfile(executable) { function builtInOllamaProfile() { return ollamaProfileSchema.parse({ runner: "ollama", enabled: false }); } +function builtInGeminiProfile() { + return geminiProfileSchema.parse({ runner: "gemini-cli", enabled: false }); +} +function builtInOpenAiCompatibleProfile() { + return openAiCompatibleProfileSchema.parse({ runner: "openai-compatible", enabled: false }); +} +function builtInAntigravityProfile() { + return antigravityProfileSchema.parse({ runner: "antigravity-cli", enabled: false }); +} function withBuiltInProfiles(profiles, options) { const result = {}; const add = (name, profile) => { @@ -34403,10 +34514,22 @@ function withBuiltInProfiles(profiles, options) { BUILT_IN_PROFILE_NAMES["codex-cli"], profiles[BUILT_IN_PROFILE_NAMES["codex-cli"]] ?? builtInCodexProfile(options?.codexExecutable) ); + add( + BUILT_IN_PROFILE_NAMES["gemini-cli"], + profiles[BUILT_IN_PROFILE_NAMES["gemini-cli"]] ?? builtInGeminiProfile() + ); add( BUILT_IN_PROFILE_NAMES.ollama, profiles[BUILT_IN_PROFILE_NAMES.ollama] ?? builtInOllamaProfile() ); + add( + BUILT_IN_PROFILE_NAMES["openai-compatible"], + profiles[BUILT_IN_PROFILE_NAMES["openai-compatible"]] ?? builtInOpenAiCompatibleProfile() + ); + add( + BUILT_IN_PROFILE_NAMES["antigravity-cli"], + profiles[BUILT_IN_PROFILE_NAMES["antigravity-cli"]] ?? builtInAntigravityProfile() + ); add(BUILT_IN_PROFILE_NAMES.mock, profiles[BUILT_IN_PROFILE_NAMES.mock] ?? builtInMockProfile()); for (const [name, profile] of Object.entries(profiles)) add(name, profile); return result; @@ -41729,6 +41852,45 @@ var codexEventSchema = external_exports.object({ error: external_exports.object({ message: external_exports.string().optional() }).passthrough().optional(), message: external_exports.string().optional() }).passthrough(); +var GEMINI_DECLARED_CAPABILITIES = capabilitySet([ + "stageGeneration", + "stageRefinement", + "taskExecution", + "taskResume", + "structuredFinalOutput", + "streamingEvents", + "repositoryRead", + "repositoryWrite", + "sandbox", + "toolRestriction", + "usageReporting", + "requiresNetwork", + "supportsCancellation" +]); +var geminiEventSchema = external_exports.object({ + type: external_exports.string(), + session_id: external_exports.string().optional(), + text: external_exports.string().optional(), + name: external_exports.string().optional(), + status: external_exports.string().optional(), + path: external_exports.string().optional(), + kind: external_exports.string().optional(), + command: external_exports.string().optional(), + input_tokens: external_exports.number().optional(), + output_tokens: external_exports.number().optional(), + cached_input_tokens: external_exports.number().optional(), + response: external_exports.string().optional(), + message: external_exports.string().optional() +}).passthrough(); +var geminiJsonEnvelopeSchema = external_exports.object({ + response: external_exports.string(), + stats: external_exports.object({ + session_id: external_exports.string().optional(), + input_tokens: external_exports.number().optional(), + output_tokens: external_exports.number().optional(), + cached_input_tokens: external_exports.number().optional() + }).passthrough().optional() +}).passthrough(); var ollamaVersionResponseSchema = external_exports.object({ version: external_exports.string() }).passthrough(); var ollamaModelSchema = external_exports.object({ name: external_exports.string(), @@ -41764,6 +41926,55 @@ var OLLAMA_DECLARED_CAPABILITIES = capabilitySet([ "supportsJsonSchema", "supportsCancellation" ]); +var chatCompletionResponseSchema = external_exports.object({ + choices: external_exports.array( + external_exports.object({ + message: external_exports.object({ content: external_exports.string().nullable() }).passthrough(), + finish_reason: external_exports.string().nullable().optional() + }).passthrough() + ).min(1), + model: external_exports.string().optional(), + usage: external_exports.object({ + prompt_tokens: external_exports.number().optional(), + completion_tokens: external_exports.number().optional(), + prompt_tokens_details: external_exports.object({ cached_tokens: external_exports.number().optional() }).passthrough().optional() + }).passthrough().optional() +}).passthrough(); +var responsesResponseSchema = external_exports.object({ + output: external_exports.array( + external_exports.object({ + type: external_exports.string().optional(), + content: external_exports.array(external_exports.object({ type: external_exports.string().optional(), text: external_exports.string().optional() }).passthrough()).optional() + }).passthrough() + ).optional(), + output_text: external_exports.string().optional(), + model: external_exports.string().optional(), + usage: external_exports.object({ + input_tokens: external_exports.number().optional(), + output_tokens: external_exports.number().optional(), + input_tokens_details: external_exports.object({ cached_tokens: external_exports.number().optional() }).passthrough().optional() + }).passthrough().optional() +}).passthrough(); +var openAiModelsResponseSchema = external_exports.object({ + data: external_exports.array( + external_exports.object({ + id: external_exports.string(), + owned_by: external_exports.string().optional(), + created: external_exports.number().optional() + }).passthrough() + ).default([]) +}).passthrough(); +var OPENAI_COMPATIBLE_DECLARED_CAPABILITIES = capabilitySet([ + "stageGeneration", + "stageRefinement", + "structuredFinalOutput", + "usageReporting", + "localOnly", + "supportsSystemPrompt", + "supportsJsonSchema", + "supportsCancellation" +]); +var ANTIGRAVITY_DECLARED_CAPABILITIES = capabilitySet([]); var RUNNER_OPERATIONS = [ "stage-generation", "stage-refinement", diff --git a/package.json b/package.json index f6fe888..b714085 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "specbridge-monorepo", - "version": "0.6.0", + "version": "0.6.1", "private": true, "description": "An open, model-agnostic spec runtime for existing Kiro projects.", "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index 248e597..87453b8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "specbridge", - "version": "0.6.0", + "version": "0.6.1", "description": "An open, model-agnostic spec runtime for existing Kiro projects. No conversion, no duplicated specs, no lock-in.", "license": "MIT", "type": "module", diff --git a/packages/cli/src/commands/runner.ts b/packages/cli/src/commands/runner.ts index edc9f73..648761e 100644 --- a/packages/cli/src/commands/runner.ts +++ b/packages/cli/src/commands/runner.ts @@ -16,7 +16,9 @@ import { profileModel, profileOperations, profileTransport, + renderRunnerMatrixMarkdown, runRunnerConformance, + runnerMatrixRows, selectRunner, } from '@specbridge/runners'; import { EXECUTION_CONFORMANCE_GROUPS } from '@specbridge/execution'; @@ -124,46 +126,6 @@ const OPERATION_SHORT: Record = { 'runner-test': 'Test', }; -interface MatrixRow { - profile: string; - support: string; - author: boolean; - refine: boolean; - execute: boolean; - resume: boolean; - local: boolean; -} - -function matrixRows(profiles: RegisteredRunnerProfile[]): MatrixRow[] { - return profiles.map((profile) => { - const operations = new Set(profileOperations(profile)); - return { - profile: profile.name, - support: 'production', - author: operations.has('stage-generation'), - refine: operations.has('stage-refinement'), - execute: operations.has('task-execution'), - resume: operations.has('task-resume'), - local: profileTransport(profile.config).localExecution, - }; - }); -} - -/** Markdown capability matrix — the same source feeds docs and README. */ -export function renderMatrixMarkdown(rows: MatrixRow[]): string { - const lines = [ - '| Profile | Support | Author | Refine | Execute | Resume | Local |', - '|---------|---------|--------|--------|---------|--------|-------|', - ]; - for (const row of rows) { - const yn = (value: boolean): string => (value ? 'yes' : 'no'); - lines.push( - `| ${row.profile} | ${row.support} | ${yn(row.author)} | ${yn(row.refine)} | ${yn(row.execute)} | ${yn(row.resume)} | ${yn(row.local)} |`, - ); - } - return `${lines.join('\n')}\n`; -} - function printDoctorReport( runtime: CliRuntime, profile: RegisteredRunnerProfile, @@ -350,7 +312,7 @@ Examples: .option('--markdown', 'output a Markdown table (used to generate docs)') .action((options: RunnerOptions) => { const { registry } = loadExecutionContext(runtime); - const rows = matrixRows(registry.listProfiles()); + const rows = runnerMatrixRows(registry.listProfiles()); if (options.json === true) { runtime.outRaw( serializeJsonReport( @@ -360,7 +322,7 @@ Examples: return; } if (options.markdown === true) { - runtime.outRaw(renderMatrixMarkdown(rows)); + runtime.outRaw(renderRunnerMatrixMarkdown(rows)); return; } runtime.out(reportTitle('Runner Capability Matrix')); @@ -712,8 +674,17 @@ Examples: 'production status is confirmed only when nothing is skipped (rerun with --network)', ), ); - } else { + } else if (result.productionConfirmed) { runtime.out(okLine('All applicable conformance checks passed — production confirmed.')); + } else { + // Preview/experimental adapters can pass their applicable checks + // but are never confirmed production by conformance. + runtime.out( + warnLine( + 'All applicable conformance checks passed.', + 'production is never confirmed for a preview/experimental adapter', + ), + ); } } runtime.exitCode = result.passed ? EXIT_CODES.ok : EXIT_CODES.gateFailure; diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts index ab21af0..1cc7765 100644 --- a/packages/cli/src/version.ts +++ b/packages/cli/src/version.ts @@ -2,4 +2,4 @@ * Single source of the CLI version string. Keep in sync with * packages/cli/package.json when releasing (checked by scripts/smoke.mjs). */ -export const VERSION = '0.6.0'; +export const VERSION = '0.6.1'; diff --git a/packages/compat-kiro/package.json b/packages/compat-kiro/package.json index c86658a..49dc796 100644 --- a/packages/compat-kiro/package.json +++ b/packages/compat-kiro/package.json @@ -1,6 +1,6 @@ { "name": "@specbridge/compat-kiro", - "version": "0.6.0", + "version": "0.6.1", "description": "Read and round-trip-safely edit existing .kiro steering and spec files without migration.", "license": "MIT", "type": "module", diff --git a/packages/core/package.json b/packages/core/package.json index a59e660..617dc22 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@specbridge/core", - "version": "0.6.0", + "version": "0.6.1", "description": "Core types, errors, workspace detection, and sidecar state for SpecBridge.", "license": "MIT", "type": "module", diff --git a/packages/core/src/config-migration.ts b/packages/core/src/config-migration.ts index bc1de37..1ae15ac 100644 --- a/packages/core/src/config-migration.ts +++ b/packages/core/src/config-migration.ts @@ -93,11 +93,32 @@ function migrateRunnersSection( `runnerProfiles.${BUILT_IN_PROFILE_NAMES.ollama} added DISABLED (loopback http://127.0.0.1:11434; enable it explicitly to use Ollama)`, ); + profiles[BUILT_IN_PROFILE_NAMES['gemini-cli']] = { runner: 'gemini-cli', enabled: false }; + changes.push( + `runnerProfiles.${BUILT_IN_PROFILE_NAMES['gemini-cli']} added DISABLED (enable it explicitly to use the Gemini CLI)`, + ); + + profiles[BUILT_IN_PROFILE_NAMES['openai-compatible']] = { + runner: 'openai-compatible', + enabled: false, + }; + changes.push( + `runnerProfiles.${BUILT_IN_PROFILE_NAMES['openai-compatible']} added DISABLED (loopback http://127.0.0.1:8000/v1; authoring only; enable it explicitly)`, + ); + + profiles[BUILT_IN_PROFILE_NAMES['antigravity-cli']] = { + runner: 'antigravity-cli', + enabled: false, + }; + changes.push( + `runnerProfiles.${BUILT_IN_PROFILE_NAMES['antigravity-cli']} added DISABLED (experimental detection only)`, + ); + for (const name of Object.keys(v1.runners)) { if (!KNOWN_V1_RUNNERS.has(name)) { warnings.push( - `runners.${name} has no v0.6 runner implementation and was not migrated ` + - '(it remains in the backup file; openai-compatible and similar providers are planned for v0.6.1).', + `runners.${name} has no registered runner implementation and was not migrated ` + + '(it remains in the backup file).', ); } } diff --git a/packages/core/src/run-types.ts b/packages/core/src/run-types.ts index 124fd31..acb53e5 100644 --- a/packages/core/src/run-types.ts +++ b/packages/core/src/run-types.ts @@ -9,14 +9,19 @@ /** * Runner implementation kinds. v0.3 shipped mock and claude-code (plus * honest `unsupported` stubs); v0.6 adds the production codex-cli and ollama - * implementations. `unsupported` remains in the vocabulary so stored data - * referencing removed stubs stays readable. + * implementations; v0.6.1 adds gemini-cli, openai-compatible (authoring + * only), and the experimental antigravity-cli — an ADDITIVE extension: no + * existing value changes meaning. `unsupported` remains in the vocabulary so + * stored data referencing removed stubs stays readable. */ export const AGENT_RUNNER_KINDS = [ 'mock', 'claude-code', 'codex-cli', + 'gemini-cli', 'ollama', + 'openai-compatible', + 'antigravity-cli', 'unsupported', ] as const; export type AgentRunnerKind = (typeof AGENT_RUNNER_KINDS)[number]; diff --git a/packages/core/src/runner-config.ts b/packages/core/src/runner-config.ts index 4b0939f..1f7a5ce 100644 --- a/packages/core/src/runner-config.ts +++ b/packages/core/src/runner-config.ts @@ -45,15 +45,30 @@ import { export const RUNNER_CONFIG_SCHEMA_VERSION = '2.0.0'; -/** Production runner implementations registered in v0.6.0. */ -export const RUNNER_IMPLEMENTATIONS = ['claude-code', 'codex-cli', 'ollama', 'mock'] as const; +/** + * Runner implementations registered by the default registry. v0.6.0 shipped + * claude-code, codex-cli, ollama, and mock; v0.6.1 adds gemini-cli, + * openai-compatible (authoring only), and the experimental antigravity-cli. + */ +export const RUNNER_IMPLEMENTATIONS = [ + 'claude-code', + 'codex-cli', + 'gemini-cli', + 'ollama', + 'openai-compatible', + 'antigravity-cli', + 'mock', +] as const; export type RunnerImplementation = (typeof RUNNER_IMPLEMENTATIONS)[number]; /** Built-in profile names (synthesized when a config file omits them). */ export const BUILT_IN_PROFILE_NAMES = { 'claude-code': 'claude-code', 'codex-cli': 'codex-default', + 'gemini-cli': 'gemini-default', ollama: 'ollama-local', + 'openai-compatible': 'openai-compatible-local', + 'antigravity-cli': 'antigravity', mock: 'mock', } as const; @@ -214,10 +229,174 @@ export const mockProfileSchema = mockRunnerConfigSchema.extend({ }); export type MockProfileConfig = z.infer; +/** + * Gemini CLI approval modes SpecBridge is allowed to pass. `yolo` is not a + * value of either enum and is additionally rejected by the invocation-layer + * assertion — there is no configuration that produces an unrestricted run. + */ +export const GEMINI_AUTHORING_APPROVAL_MODES = ['plan'] as const; +export type GeminiAuthoringApprovalMode = (typeof GEMINI_AUTHORING_APPROVAL_MODES)[number]; +export const GEMINI_EXECUTION_APPROVAL_MODES = ['auto_edit', 'default'] as const; +export type GeminiExecutionApprovalMode = (typeof GEMINI_EXECUTION_APPROVAL_MODES)[number]; + +/** + * Gemini CLI profile (v0.6.1). The user installs and authenticates the + * Gemini CLI independently; SpecBridge never reads Google credential stores, + * never triggers a login, never trusts a folder, and never passes YOLO or + * any other unrestricted approval mode. + */ +export const geminiProfileSchema = z + .object({ + runner: z.literal('gemini-cli'), + enabled: z.boolean().default(false), + command: commandSpecSchema.default({ executable: 'gemini', args: [] }), + model: safeNonEmptyString.nullable().default(null), + /** Authoring is always read-only; only plan mode is accepted. */ + approvalModeForAuthoring: z.enum(GEMINI_AUTHORING_APPROVAL_MODES).default('plan'), + /** Task execution may auto-approve EDITS only — never shell commands. */ + approvalModeForExecution: z.enum(GEMINI_EXECUTION_APPROVAL_MODES).default('auto_edit'), + /** Pass --sandbox when the installed CLI supports it. */ + sandbox: z.boolean().default(true), + /** + * Extra tools to allow during task execution, on top of the adapter's + * bounded read/edit set. Shell-execution tools are rejected. + */ + allowedTools: z + .array(safeNonEmptyString) + .default([]) + .refine( + (tools) => + tools.every((tool) => !/^(run_shell_command|shell|bash|execute_command|terminal)$/i.test(tool)), + { + message: + 'shell-execution tools cannot be allowed: SpecBridge never grants the Gemini CLI arbitrary shell access', + }, + ), + /** Pass the extension-restriction flag when supported (default on). */ + disabledExtensions: z.boolean().default(true), + timeoutMs: z.number().int().min(1000).max(86_400_000).default(1_800_000), + maxStdoutBytes: z.number().int().min(1024).default(10 * 1024 * 1024), + maxStderrBytes: z.number().int().min(1024).default(1024 * 1024), + }) + .passthrough(); +export type GeminiProfileConfig = z.infer; + +export const OPENAI_COMPATIBLE_API_STYLES = ['chat-completions', 'responses'] as const; +export type OpenAiCompatibleApiStyle = (typeof OPENAI_COMPATIBLE_API_STYLES)[number]; + +export const OPENAI_COMPATIBLE_STRUCTURED_OUTPUT_MODES = [ + 'json-schema', + 'json-object', + 'strict-json-prompt', +] as const; +export type OpenAiCompatibleStructuredOutputMode = + (typeof OPENAI_COMPATIBLE_STRUCTURED_OUTPUT_MODES)[number]; + +/** Environment-variable NAMES only — never values. */ +const environmentVariableNameSchema = z + .string() + .regex( + /^[A-Za-z_][A-Za-z0-9_]*$/, + 'must be an environment-variable NAME (letters, digits, underscore); SpecBridge never stores key values', + ); + +/** Header names an openai-compatible profile may set. Credential-bearing + * headers are rejected: authentication goes through apiKeyEnvironmentVariable + * exclusively, so no key value can ever land in the configuration file. */ +const FORBIDDEN_HEADER_NAME_PATTERN = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key|api-key|x-auth-token)$/i; + +const safeHeadersSchema = z + .record(z.string().max(1024)) + .superRefine((headers, ctx) => { + for (const [name, value] of Object.entries(headers)) { + if (!/^[A-Za-z0-9-]+$/.test(name)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `header name "${name}" is invalid (letters, digits, and "-" only)`, + }); + } + if (FORBIDDEN_HEADER_NAME_PATTERN.test(name)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + `header "${name}" would carry a credential value. SpecBridge never stores credentials; ` + + 'use apiKeyEnvironmentVariable (a variable NAME) instead.', + }); + } + if (value.includes('\0') || value.includes('\n') || value.includes('\r')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `header "${name}" contains control characters`, + }); + } + } + }); + +/** + * OpenAI-compatible model API profile (v0.6.1) — AUTHORING ONLY. + * + * No task execution, no tool loop, no repository writes. The endpoint is + * loopback by default; remote endpoints need HTTPS (or the explicit, + * clearly-insecure development override) and are never selected implicitly. + * Authentication is configured as an environment-variable NAME; the value is + * read at request time only and never stored, logged, or retained. + */ +export const openAiCompatibleProfileSchema = z + .object({ + runner: z.literal('openai-compatible'), + enabled: z.boolean().default(false), + baseUrl: safeNonEmptyString.default('http://127.0.0.1:8000/v1'), + apiStyle: z.enum(OPENAI_COMPATIBLE_API_STYLES).default('chat-completions'), + model: safeNonEmptyString.nullable().default(null), + structuredOutput: z.enum(OPENAI_COMPATIBLE_STRUCTURED_OUTPUT_MODES).default('json-schema'), + /** + * Explicit permission to fall back from the configured structured-output + * mode to the next weaker one when the endpoint rejects it. Off by + * default: an unsupported mode is an error, never a silent downgrade. + */ + allowStructuredOutputFallback: z.boolean().default(false), + /** Name of the environment variable holding the API key (never a value). */ + apiKeyEnvironmentVariable: environmentVariableNameSchema.nullable().default(null), + /** Static capability declaration: the endpoint supports GET /models. */ + modelsEndpoint: z.boolean().default(false), + /** Custom safe headers (credential-bearing header names are rejected). */ + headers: safeHeadersSchema.default({}), + temperature: z.number().min(0).max(2).default(0), + timeoutMs: z.number().int().min(1000).max(86_400_000).default(300_000), + maximumInputCharacters: z.number().int().min(1000).default(500_000), + maximumOutputBytes: z.number().int().min(1024).default(2_097_152), + /** Explicit development override for private plain-HTTP endpoints (INSECURE). */ + allowInsecureHttp: z.boolean().default(false), + }) + .passthrough(); +export type OpenAiCompatibleProfileConfig = z.infer; + +/** + * Antigravity CLI profile (v0.6.1) — EXPERIMENTAL, detection only. + * + * The adapter detects the executable, version, and documented capabilities. + * It never automates the interactive TUI, never uses a PTY, never logs in, + * and never executes authoring or tasks in v0.6.1. + */ +export const antigravityProfileSchema = z + .object({ + runner: z.literal('antigravity-cli'), + enabled: z.boolean().default(false), + command: commandSpecSchema.default({ executable: 'agy', args: [] }), + /** Always true: the adapter is experimental and cannot be marked otherwise. */ + experimental: z.literal(true).default(true), + timeoutMs: z.number().int().min(1000).max(600_000).default(30_000), + }) + .passthrough(); +export type AntigravityProfileConfig = z.infer; + export const runnerProfileSchema = z.discriminatedUnion('runner', [ claudeProfileSchema, codexProfileSchema, + geminiProfileSchema, ollamaProfileSchema, + openAiCompatibleProfileSchema, + antigravityProfileSchema, mockProfileSchema, ]); export type RunnerProfileConfig = z.infer; @@ -305,7 +484,7 @@ export const agentConfigV2Schema = z } } for (const [name, profile] of Object.entries(config.runnerProfiles)) { - if (profile.runner === 'ollama') { + if (profile.runner === 'ollama' || profile.runner === 'openai-compatible') { const url = validateRunnerBaseUrl(profile.baseUrl, { allowInsecureHttp: profile.allowInsecureHttp, }); @@ -369,6 +548,18 @@ function builtInOllamaProfile(): OllamaProfileConfig { return ollamaProfileSchema.parse({ runner: 'ollama', enabled: false }); } +function builtInGeminiProfile(): GeminiProfileConfig { + return geminiProfileSchema.parse({ runner: 'gemini-cli', enabled: false }); +} + +function builtInOpenAiCompatibleProfile(): OpenAiCompatibleProfileConfig { + return openAiCompatibleProfileSchema.parse({ runner: 'openai-compatible', enabled: false }); +} + +function builtInAntigravityProfile(): AntigravityProfileConfig { + return antigravityProfileSchema.parse({ runner: 'antigravity-cli', enabled: false }); +} + /** Add any missing built-in profiles (never overwrites configured ones). */ function withBuiltInProfiles( profiles: Record, @@ -387,10 +578,22 @@ function withBuiltInProfiles( BUILT_IN_PROFILE_NAMES['codex-cli'], profiles[BUILT_IN_PROFILE_NAMES['codex-cli']] ?? builtInCodexProfile(options?.codexExecutable), ); + add( + BUILT_IN_PROFILE_NAMES['gemini-cli'], + profiles[BUILT_IN_PROFILE_NAMES['gemini-cli']] ?? builtInGeminiProfile(), + ); add( BUILT_IN_PROFILE_NAMES.ollama, profiles[BUILT_IN_PROFILE_NAMES.ollama] ?? builtInOllamaProfile(), ); + add( + BUILT_IN_PROFILE_NAMES['openai-compatible'], + profiles[BUILT_IN_PROFILE_NAMES['openai-compatible']] ?? builtInOpenAiCompatibleProfile(), + ); + add( + BUILT_IN_PROFILE_NAMES['antigravity-cli'], + profiles[BUILT_IN_PROFILE_NAMES['antigravity-cli']] ?? builtInAntigravityProfile(), + ); add(BUILT_IN_PROFILE_NAMES.mock, profiles[BUILT_IN_PROFILE_NAMES.mock] ?? builtInMockProfile()); for (const [name, profile] of Object.entries(profiles)) add(name, profile); return result; @@ -628,6 +831,19 @@ export function isCodexProfile(profile: RunnerProfileConfig): profile is CodexPr export function isOllamaProfile(profile: RunnerProfileConfig): profile is OllamaProfileConfig { return profile.runner === 'ollama'; } +export function isGeminiProfile(profile: RunnerProfileConfig): profile is GeminiProfileConfig { + return profile.runner === 'gemini-cli'; +} +export function isOpenAiCompatibleProfile( + profile: RunnerProfileConfig, +): profile is OpenAiCompatibleProfileConfig { + return profile.runner === 'openai-compatible'; +} +export function isAntigravityProfile( + profile: RunnerProfileConfig, +): profile is AntigravityProfileConfig { + return profile.runner === 'antigravity-cli'; +} export function isMockProfile(profile: RunnerProfileConfig): profile is MockProfileConfig { return profile.runner === 'mock'; } diff --git a/packages/drift/package.json b/packages/drift/package.json index d1f9a82..b883b25 100644 --- a/packages/drift/package.json +++ b/packages/drift/package.json @@ -1,6 +1,6 @@ { "name": "@specbridge/drift", - "version": "0.6.0", + "version": "0.6.1", "description": "Deterministic spec-to-code drift primitives for SpecBridge (no LLM required).", "license": "MIT", "type": "module", diff --git a/packages/evidence/package.json b/packages/evidence/package.json index 031436c..a4f00d6 100644 --- a/packages/evidence/package.json +++ b/packages/evidence/package.json @@ -1,6 +1,6 @@ { "name": "@specbridge/evidence", - "version": "0.6.0", + "version": "0.6.1", "description": "Git snapshots, trusted verification commands, and append-only task evidence for SpecBridge.", "license": "MIT", "type": "module", diff --git a/packages/execution/package.json b/packages/execution/package.json index 6bdeac3..6eef531 100644 --- a/packages/execution/package.json +++ b/packages/execution/package.json @@ -1,6 +1,6 @@ { "name": "@specbridge/execution", - "version": "0.6.0", + "version": "0.6.1", "description": "Task selection, bounded task context, runner orchestration, run records, and session resume for SpecBridge.", "license": "MIT", "type": "module", diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index d4fd9f5..1097e41 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@specbridge/mcp-server", - "version": "0.6.0", + "version": "0.6.1", "description": "Local stdio MCP server exposing SpecBridge workspace, spec, task, run, and verification capabilities as typed tools, resources, and prompts.", "license": "MIT", "type": "module", @@ -37,6 +37,7 @@ "@specbridge/drift": "workspace:*", "@specbridge/evidence": "workspace:*", "@specbridge/execution": "workspace:*", + "@specbridge/runners": "workspace:*", "@specbridge/workflow": "workspace:*", "zod": "^3.23.8" }, diff --git a/packages/mcp-server/src/tools/registry.ts b/packages/mcp-server/src/tools/registry.ts index 9ab241b..5e92acb 100644 --- a/packages/mcp-server/src/tools/registry.ts +++ b/packages/mcp-server/src/tools/registry.ts @@ -21,6 +21,10 @@ import { registerRunReadTool } from './run-read.js'; import { registerSpecAffectedTool } from './spec-affected.js'; import { registerSpecCheckDriftTool } from './spec-check-drift.js'; import { registerSpecRunVerificationTool } from './spec-run-verification.js'; +import { registerRunnerListTool } from './runner-list.js'; +import { registerRunnerShowTool } from './runner-show.js'; +import { registerRunnerDoctorTool } from './runner-doctor.js'; +import { registerRunnerMatrixTool } from './runner-matrix.js'; /** * The complete, closed tool registry. @@ -54,6 +58,10 @@ export const TOOL_CATALOG: readonly ToolRegistryEntry[] = [ { name: 'run_read', readOnly: true, summary: 'Safe single-run summary' }, { name: 'spec_affected', readOnly: true, summary: 'Affected-spec resolution for a change set' }, { name: 'spec_check_drift', readOnly: true, summary: 'Deterministic drift rules (no commands)' }, + { name: 'runner_list', readOnly: true, summary: 'Runner profiles with capabilities and availability' }, + { name: 'runner_show', readOnly: true, summary: 'One runner profile in depth (redacted)' }, + { name: 'runner_doctor', readOnly: true, summary: 'Runner diagnostics (never a model request)' }, + { name: 'runner_matrix', readOnly: true, summary: 'Authoritative runner capability matrix' }, { name: 'spec_create', readOnly: false, summary: 'Preview-first offline spec creation' }, { name: 'spec_stage_validate', readOnly: true, summary: 'Validate a stage candidate (no write)' }, { name: 'spec_stage_apply', readOnly: false, summary: 'Apply a reviewed stage candidate atomically' }, @@ -78,6 +86,10 @@ export function registerAllTools(server: McpServer, context: ServerContext): voi registerRunReadTool(server, context); registerSpecAffectedTool(server, context); registerSpecCheckDriftTool(server, context); + registerRunnerListTool(server, context); + registerRunnerShowTool(server, context); + registerRunnerDoctorTool(server, context); + registerRunnerMatrixTool(server, context); registerSpecCreateTool(server, context); registerSpecStageValidateTool(server, context); registerSpecStageApplyTool(server, context); diff --git a/packages/mcp-server/src/tools/runner-doctor.ts b/packages/mcp-server/src/tools/runner-doctor.ts new file mode 100644 index 0000000..9c4f8f5 --- /dev/null +++ b/packages/mcp-server/src/tools/runner-doctor.ts @@ -0,0 +1,87 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { ServerContext } from '../context.js'; +import { registerDefinedTool } from './helpers.js'; +import { + RUNNER_PROBE_TIMEOUT_MS, + detectionViewShape, + loadRunnerToolContext, + requireProfile, + toDetectionView, +} from './runner-shared.js'; + +/** + * runner_doctor — read-only diagnostics for one profile (default: the + * configured default runner). Runs version/help/auth-status probes or + * loopback reachability checks only. It never invokes a model, never makes + * a paid inference request, never modifies configuration, never + * authenticates, never starts an interactive UI, and never trusts folders. + */ + +const inputSchema = { + profile: z + .string() + .min(1) + .max(120) + .optional() + .describe('Runner profile name (default: the configured default runner)'), + verbose: z.boolean().optional().describe('Include informational diagnostics'), +}; + +const outputSchema = { + profile: z.string(), + implementation: z.string(), + category: z.string(), + enabled: z.boolean(), + executable: z.string().nullable().describe('Configured executable or endpoint'), + detection: detectionViewShape, + ready: z.boolean().describe('True when the runner status is available'), +}; + +export function registerRunnerDoctorTool(server: McpServer, context: ServerContext): void { + registerDefinedTool(server, context, { + name: 'runner_doctor', + title: 'Diagnose a runner profile', + description: + 'Diagnose one runner profile: executable/endpoint presence, version, authentication ' + + 'state (never via credential files), detected capabilities, and actionable findings. ' + + 'Read-only: never a model request, never a login, never a configuration change.', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + inputSchema, + outputSchema, + handler: async (args) => { + const { workspace, config, registry } = loadRunnerToolContext(context); + const profileName = args.profile ?? config.defaultRunner; + const profile = requireProfile(registry, profileName); + const detection = await profile.runner.detect({ + workspaceRoot: workspace.rootDir, + probeCapabilities: true, + timeoutMs: RUNNER_PROBE_TIMEOUT_MS, + }); + const view = toDetectionView(detection, args.verbose === true); + const findings = view.diagnostics + .map((diagnostic) => `- [${diagnostic.severity}] ${diagnostic.message}`) + .join('\n'); + return { + text: + `Runner ${profileName} (${profile.runner.name}): status ${view.status}, ` + + `support ${view.supportLevel}, authentication ${view.authentication}.` + + `${findings.length > 0 ? `\n${findings}` : ''}`, + structured: { + profile: profileName, + implementation: profile.runner.name, + category: profile.runner.category, + enabled: profile.config.enabled !== false, + executable: detection.executable ?? null, + detection: view, + ready: detection.status === 'available', + }, + }; + }, + }); +} diff --git a/packages/mcp-server/src/tools/runner-list.ts b/packages/mcp-server/src/tools/runner-list.ts new file mode 100644 index 0000000..eca69b3 --- /dev/null +++ b/packages/mcp-server/src/tools/runner-list.ts @@ -0,0 +1,105 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runnerProfileSummary } from '@specbridge/runners'; +import type { ServerContext } from '../context.js'; +import { paginate } from '../limits.js'; +import { cursorArg, limitArg, paginationShape } from '../schemas/common.js'; +import { registerDefinedTool } from './helpers.js'; +import { + RUNNER_PROBE_TIMEOUT_MS, + loadRunnerToolContext, + profileSummaryShape, +} from './runner-shared.js'; + +/** + * runner_list — paginated runner profile summaries with availability. + * Read-only: detection runs version/help/reachability probes only and never + * sends a model request. + */ + +const inputSchema = { + enabledOnly: z.boolean().optional().describe('Only profiles that are enabled in the configuration'), + detect: z + .boolean() + .optional() + .describe( + 'Probe availability for the returned page (read-only version/help/reachability probes; ' + + 'slower — default false)', + ), + limit: limitArg, + cursor: cursorArg, +}; + +const outputSchema = { + defaultRunner: z.string(), + profiles: z.array( + profileSummaryShape.extend({ + availability: z.string().optional().describe('Detection status (present when detect=true)'), + }), + ), + pagination: paginationShape, +}; + +export function registerRunnerListTool(server: McpServer, context: ServerContext): void { + registerDefinedTool(server, context, { + name: 'runner_list', + title: 'List runner profiles', + description: + 'List configured runner profiles: implementation, category, support level, enabled state, ' + + 'configured model, local/network classification, and supported operations. Optionally probes ' + + 'availability (read-only; never a model request). Supports pagination. Read-only.', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + inputSchema, + outputSchema, + handler: async (args) => { + const { workspace, config, registry } = loadRunnerToolContext(context); + const profiles = registry + .listProfiles() + .filter((profile) => args.enabledOnly !== true || profile.config.enabled !== false); + const page = paginate(profiles, { + ...(args.limit !== undefined ? { limit: args.limit } : {}), + ...(args.cursor !== undefined ? { cursor: args.cursor } : {}), + token: 'runner_list', + }); + + const summaries = []; + for (const profile of page.items) { + const summary = runnerProfileSummary(profile); + if (args.detect === true) { + const detection = await profile.runner.detect({ + workspaceRoot: workspace.rootDir, + timeoutMs: RUNNER_PROBE_TIMEOUT_MS, + }); + summaries.push({ ...summary, availability: detection.status }); + } else { + summaries.push(summary); + } + } + + const lines = summaries.map( + (summary) => + `- ${summary.profile} [${summary.implementation}/${summary.category}] ` + + `${summary.enabled ? 'enabled' : 'disabled'}, support ${summary.supportLevel}` + + `${'availability' in summary && summary.availability !== undefined ? `, ${summary.availability}` : ''}` + + `, operations: ${summary.supportedOperations.join(', ') || '(none)'}`, + ); + return { + text: `${page.totalCount} runner profile(s); default runner: ${config.defaultRunner}.\n${lines.join('\n')}`, + structured: { + defaultRunner: config.defaultRunner, + profiles: summaries, + pagination: { + totalCount: page.totalCount, + truncated: page.truncated, + ...(page.nextCursor !== undefined ? { nextCursor: page.nextCursor } : {}), + }, + }, + }; + }, + }); +} diff --git a/packages/mcp-server/src/tools/runner-matrix.ts b/packages/mcp-server/src/tools/runner-matrix.ts new file mode 100644 index 0000000..96bb1e5 --- /dev/null +++ b/packages/mcp-server/src/tools/runner-matrix.ts @@ -0,0 +1,59 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { renderRunnerMatrixMarkdown, runnerMatrixRows } from '@specbridge/runners'; +import type { ServerContext } from '../context.js'; +import { registerDefinedTool } from './helpers.js'; +import { loadRunnerToolContext } from './runner-shared.js'; + +/** + * runner_matrix — the authoritative capability matrix, generated from + * registered runner metadata by the SAME shared implementation the CLI + * uses (`specbridge runner matrix`). Pure configuration: no probes, no + * processes, no network. + */ + +const matrixRowShape = z.object({ + profile: z.string(), + implementation: z.string(), + category: z.string(), + support: z.string(), + enabled: z.boolean(), + author: z.boolean(), + refine: z.boolean(), + execute: z.boolean(), + resume: z.boolean(), + local: z.boolean(), +}); + +const outputSchema = { + rows: z.array(matrixRowShape), + markdown: z.string().describe('The same matrix as a Markdown table'), +}; + +export function registerRunnerMatrixTool(server: McpServer, context: ServerContext): void { + registerDefinedTool(server, context, { + name: 'runner_matrix', + title: 'Runner capability matrix', + description: + 'The authoritative runner capability matrix (author/refine/execute/resume per profile), ' + + 'generated from registered runner metadata — identical to "specbridge runner matrix". ' + + 'Read-only; no probes, no processes, no network.', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: {}, + outputSchema, + handler: async () => { + const { registry } = loadRunnerToolContext(context); + const rows = runnerMatrixRows(registry.listProfiles()); + const markdown = renderRunnerMatrixMarkdown(rows); + return { + text: markdown, + structured: { rows, markdown }, + }; + }, + }); +} diff --git a/packages/mcp-server/src/tools/runner-shared.ts b/packages/mcp-server/src/tools/runner-shared.ts new file mode 100644 index 0000000..a47a70c --- /dev/null +++ b/packages/mcp-server/src/tools/runner-shared.ts @@ -0,0 +1,190 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { z } from 'zod'; +import type { AgentConfig, WorkspaceInfo } from '@specbridge/core'; +import type { + RegisteredRunnerProfile, + RunnerConformanceResult, + RunnerDetectionResult, + RunnerRegistry, +} from '@specbridge/runners'; +import { createDefaultRunnerRegistry, runRunnerConformance } from '@specbridge/runners'; +import type { ServerContext } from '../context.js'; +import { McpToolError } from '../errors.js'; +import { requireAgentConfig } from './interactive-shared.js'; + +/** + * Shared plumbing for the READ-ONLY runner diagnostic tools (v0.6.1): + * runner_list, runner_show, runner_doctor, runner_matrix. + * + * These tools call the SAME shared runner services as the CLI (registry, + * detection, matrix, conformance) — no detection or matrix logic is + * duplicated. They never invoke a model, never make paid inference + * requests, never modify configuration, never authenticate, never start an + * interactive UI, and never trust folders. Environment-variable VALUES and + * provider credentials never appear in any result (adapters redact at the + * source; profiles cannot store credentials at all). + */ + +export interface RunnerToolContext { + workspace: WorkspaceInfo; + config: AgentConfig; + registry: RunnerRegistry; +} + +export function loadRunnerToolContext(context: ServerContext): RunnerToolContext { + const workspace = context.requireWorkspace(); + const config = requireAgentConfig(workspace); + return { workspace, config, registry: createDefaultRunnerRegistry(config) }; +} + +export function requireProfile(registry: RunnerRegistry, name: string): RegisteredRunnerProfile { + if (!registry.has(name)) { + throw new McpToolError( + 'SBMCP002', + `Unknown runner profile "${name}". Configured profiles: ${registry + .listProfiles() + .map((profile) => profile.name) + .join(', ')}.`, + { remediation: ['Call runner_list to see every configured profile.'] }, + ); + } + return registry.getProfile(name); +} + +/** Bounded doctor-level probe timeout: diagnostics stay responsive. */ +export const RUNNER_PROBE_TIMEOUT_MS = 20_000; + +// --------------------------------------------------------------------------- +// Output shapes +// --------------------------------------------------------------------------- + +export const capabilitySetShape = z + .record(z.boolean()) + .describe('Provider-independent capability keys to booleans'); + +export const profileSummaryShape = z.object({ + profile: z.string(), + implementation: z.string(), + category: z.string(), + supportLevel: z.string().describe('Adapter-declared support level'), + enabled: z.boolean(), + model: z.string().nullable(), + networkBacked: z.boolean().describe('True when SpecBridge itself would leave this machine'), + localExecution: z.boolean(), + supportedOperations: z.array(z.string()), +}); + +export const detectionCapabilityShape = z.object({ + id: z.string(), + label: z.string(), + available: z.boolean(), + required: z.boolean(), + detail: z.string().optional(), +}); + +export const runnerDiagnosticShape = z.object({ + severity: z.enum(['info', 'warning', 'error']), + code: z.string(), + message: z.string(), +}); + +export const detectionViewShape = z.object({ + status: z.string(), + supportLevel: z.string().describe('Effective support level after detection'), + version: z.string().nullable(), + authentication: z.string(), + networkBacked: z.boolean(), + capabilities: z.array(detectionCapabilityShape), + detectedCapabilities: capabilitySetShape, + diagnostics: z.array(runnerDiagnosticShape), +}); +export type DetectionView = z.infer; + +const MAX_DIAGNOSTICS = 50; + +export function toDetectionView(detection: RunnerDetectionResult, verbose: boolean): DetectionView { + const diagnostics = ( + verbose + ? detection.diagnostics + : detection.diagnostics.filter((diagnostic) => diagnostic.severity !== 'info') + ).slice(0, MAX_DIAGNOSTICS); + return { + status: detection.status, + supportLevel: detection.supportLevel, + version: detection.version ?? null, + authentication: detection.authentication, + networkBacked: detection.networkBacked, + capabilities: detection.capabilities.map((capability) => ({ + id: String(capability.id), + label: capability.label, + available: capability.available, + required: capability.required, + ...(capability.detail !== undefined ? { detail: capability.detail } : {}), + })), + detectedCapabilities: detection.capabilitySet, + diagnostics: diagnostics.map((diagnostic) => ({ + severity: diagnostic.severity, + code: diagnostic.code, + message: diagnostic.message, + })), + }; +} + +export const conformanceSummaryShape = z.object({ + passed: z.boolean(), + productionConfirmed: z.boolean(), + failedChecks: z.number().int(), + skippedChecks: z.number().int(), + groups: z.array( + z.object({ + group: z.string(), + applicable: z.boolean(), + reason: z.string().optional(), + passed: z.boolean(), + skipped: z.number().int(), + }), + ), + note: z.string(), +}); +export type ConformanceSummaryView = z.infer; + +/** + * Invocation-free conformance summary against a throwaway scratch directory + * (never the user's repository). Checks needing provider invocation are + * reported as skipped — running them stays a CLI decision (`--network`). + */ +export async function invocationFreeConformanceSummary( + profile: RegisteredRunnerProfile, +): Promise { + const scratch = mkdtempSync(path.join(os.tmpdir(), 'specbridge-mcp-conformance-')); + let result: RunnerConformanceResult; + try { + result = await runRunnerConformance({ + profile, + workspaceRoot: scratch, + runDir: path.join(scratch, '.specbridge-conformance-runs'), + invocationsAllowed: false, + timeoutMs: RUNNER_PROBE_TIMEOUT_MS, + }); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + return { + passed: result.passed, + productionConfirmed: result.productionConfirmed, + failedChecks: result.failedChecks, + skippedChecks: result.skippedChecks, + groups: result.groups.map((group) => ({ + group: group.group, + applicable: group.applicable, + ...(group.reason !== undefined ? { reason: group.reason } : {}), + passed: group.passed, + skipped: group.skipped, + })), + note: + 'Invocation-free summary: checks that would invoke the provider are skipped here. ' + + 'Run "specbridge runner conformance --network" for the full suite.', + }; +} diff --git a/packages/mcp-server/src/tools/runner-show.ts b/packages/mcp-server/src/tools/runner-show.ts new file mode 100644 index 0000000..c46b707 --- /dev/null +++ b/packages/mcp-server/src/tools/runner-show.ts @@ -0,0 +1,140 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { + RUNNER_OPERATIONS, + checkOperationSupport, + redactedRunnerProfileConfig, + runnerProfileSummary, +} from '@specbridge/runners'; +import type { ServerContext } from '../context.js'; +import { registerDefinedTool } from './helpers.js'; +import { + RUNNER_PROBE_TIMEOUT_MS, + capabilitySetShape, + conformanceSummaryShape, + detectionViewShape, + invocationFreeConformanceSummary, + loadRunnerToolContext, + profileSummaryShape, + requireProfile, + toDetectionView, +} from './runner-shared.js'; + +/** + * runner_show — one profile in depth: redacted configuration, declared and + * detected capabilities, operation compatibility, invocation-free + * conformance summary, network boundary, limitations, and remediation. + * Read-only; never a model request. + */ + +const inputSchema = { + profile: z.string().min(1).max(120).describe('Runner profile name (e.g. "gemini-default")'), +}; + +const operationCompatibilityShape = z.object({ + operation: z.string(), + supported: z.boolean(), + missingCapabilities: z.array(z.string()), +}); + +const outputSchema = { + summary: profileSummaryShape, + configuration: z + .record(z.unknown()) + .describe('Redacted profile configuration (profiles can never store credential values)'), + declaredCapabilities: capabilitySetShape, + detection: detectionViewShape, + operationCompatibility: z + .array(operationCompatibilityShape) + .describe('Per-operation support from DETECTED capabilities'), + conformance: conformanceSummaryShape, + boundary: z.object({ + networkBacked: z.boolean(), + localExecution: z.boolean(), + constraints: z.array(z.string()), + }), + limitations: z.array(z.string()), + remediation: z.array(z.string()), +}; + +export function registerRunnerShowTool(server: McpServer, context: ServerContext): void { + registerDefinedTool(server, context, { + name: 'runner_show', + title: 'Show a runner profile', + description: + 'Show one runner profile: redacted configuration, declared and detected capabilities, ' + + 'operation compatibility, invocation-free conformance summary, network boundary, known ' + + 'limitations, and remediation. Read-only; never sends a model request.', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + inputSchema, + outputSchema, + handler: async (args) => { + const { workspace, registry } = loadRunnerToolContext(context); + const profile = requireProfile(registry, args.profile); + const summary = runnerProfileSummary(profile); + const detection = await profile.runner.detect({ + workspaceRoot: workspace.rootDir, + probeCapabilities: true, + timeoutMs: RUNNER_PROBE_TIMEOUT_MS, + }); + const detectionView = toDetectionView(detection, true); + const conformance = await invocationFreeConformanceSummary(profile); + + const operationCompatibility = RUNNER_OPERATIONS.filter( + (operation) => operation !== 'model-list' && operation !== 'runner-test', + ).map((operation) => { + const support = checkOperationSupport(operation, detection.capabilitySet); + return { + operation, + supported: support.supported, + missingCapabilities: [ + ...support.missingCapabilities, + ...support.unsatisfiedBoundaries.flat(), + ], + }; + }); + + const boundaryNote = profile.runner.executionBoundaryNote?.('implementation'); + const limitations = detectionView.diagnostics + .filter((diagnostic) => diagnostic.severity !== 'error') + .map((diagnostic) => diagnostic.message); + const remediation = detectionView.diagnostics + .filter((diagnostic) => diagnostic.severity === 'error') + .map((diagnostic) => diagnostic.message); + + const supported = operationCompatibility + .filter((entry) => entry.supported) + .map((entry) => entry.operation); + return { + text: + `Profile ${args.profile} (${summary.implementation}, ${summary.category}): ` + + `${summary.enabled ? 'enabled' : 'disabled'}, status ${detection.status}, ` + + `support ${detection.supportLevel}. Supported operations (detected): ` + + `${supported.join(', ') || '(none)'}.`, + structured: { + summary, + configuration: redactedRunnerProfileConfig(profile), + declaredCapabilities: profile.runner.declaredCapabilities, + detection: detectionView, + operationCompatibility, + conformance, + boundary: { + networkBacked: summary.networkBacked, + localExecution: summary.localExecution, + constraints: [ + ...(boundaryNote !== undefined ? [boundaryNote] : []), + 'No commits, no pushes, no checkbox updates by the provider; evidence stays provider-independent.', + ], + }, + limitations, + remediation, + }, + }; + }, + }); +} diff --git a/packages/mcp-server/src/version.ts b/packages/mcp-server/src/version.ts index 8daab11..aa78caf 100644 --- a/packages/mcp-server/src/version.ts +++ b/packages/mcp-server/src/version.ts @@ -8,7 +8,7 @@ */ export const MCP_SERVER_NAME = 'specbridge'; -export const MCP_SERVER_VERSION = '0.6.0'; +export const MCP_SERVER_VERSION = '0.6.1'; export const MCP_SERVER_TITLE = 'SpecBridge'; /** Pinned exact SDK dependency (see package.json; keep the two in sync). */ diff --git a/packages/reporting/package.json b/packages/reporting/package.json index f6535ec..45bb0d5 100644 --- a/packages/reporting/package.json +++ b/packages/reporting/package.json @@ -1,6 +1,6 @@ { "name": "@specbridge/reporting", - "version": "0.6.0", + "version": "0.6.1", "description": "Terminal, JSON, and self-contained HTML report rendering for SpecBridge.", "license": "MIT", "type": "module", diff --git a/packages/runners/package.json b/packages/runners/package.json index 8f4c950..61e91d2 100644 --- a/packages/runners/package.json +++ b/packages/runners/package.json @@ -1,6 +1,6 @@ { "name": "@specbridge/runners", - "version": "0.6.0", + "version": "0.6.1", "description": "Model- and agent-agnostic runner adapters for SpecBridge (mock, Claude Code, Codex, stubs).", "license": "MIT", "type": "module", diff --git a/packages/runners/src/antigravity-cli/runner.ts b/packages/runners/src/antigravity-cli/runner.ts new file mode 100644 index 0000000..c23c5ce --- /dev/null +++ b/packages/runners/src/antigravity-cli/runner.ts @@ -0,0 +1,282 @@ +import type { AntigravityProfileConfig, Diagnostic, RunnerStatus } from '@specbridge/core'; +import { antigravityProfileSchema } from '@specbridge/core'; +import type { + AgentRunner, + RunnerCapability, + RunnerDetectionContext, + RunnerDetectionResult, + RunnerExecutionOptions, + RunnerToolPolicy, + StageGenerationInput, + StageGenerationResult, + TaskExecutionInput, + TaskExecutionResult, +} from '../contract.js'; +import type { RunnerCapabilitySet, RunnerSupportLevel } from '../contracts/capabilities.js'; +import { capabilitySet } from '../contracts/capabilities.js'; +import { runnerError } from '../contracts/errors.js'; +import { runSafeProcess } from '../safe-process.js'; + +/** + * Antigravity CLI adapter (v0.6.1) — EXPERIMENTAL, capability detection only. + * + * Purpose: detect the executable, its version, and any DOCUMENTED headless / + * machine-readable capabilities, and report them transparently. Nothing is + * automated: SpecBridge never starts the interactive TUI, never allocates a + * pseudo-terminal, never injects keystrokes, never parses ANSI screen + * output, never logs in, never trusts a workspace, and never inspects + * private session files. No Gemini CLI flag, output format, or session + * layout is assumed — Antigravity is a different product. + * + * Stage generation, refinement, task execution, and resume are all disabled: + * the declared capability set is empty, so selection refuses every operation + * before any process could start. Even when detection finds promising + * tokens, the support level stays `experimental` in v0.6.1 — a stable, + * documented, headless structured-output contract plus passing conformance + * evidence is required before any operation can be considered, and that + * decision is deliberately NOT made by detection heuristics. + */ + +export const ANTIGRAVITY_DECLARED_CAPABILITIES: RunnerCapabilitySet = capabilitySet([]); + +/** Documented-capability probes: reported transparently, never acted on. */ +const ANTIGRAVITY_OBSERVATION_PROBES: { id: string; label: string; tokens: string[] }[] = [ + { id: 'headless', label: 'Documented headless invocation', tokens: ['--prompt', '--non-interactive', '--headless'] }, + { id: 'machine-readable', label: 'Documented machine-readable output', tokens: ['--output-format', '--json'] }, + { id: 'structured-final-output', label: 'Documented structured final output', tokens: ['json'] }, + { id: 'sandbox', label: 'Documented sandbox / permission controls', tokens: ['--sandbox', '--approval-mode'] }, + { id: 'workspace-write-control', label: 'Documented workspace-write controls', tokens: ['--allowed-tools', 'workspace-write'] }, + { id: 'session-identity', label: 'Documented session identity', tokens: ['--list-sessions', '--session'] }, + { id: 'resume', label: 'Documented session resume', tokens: ['--resume'] }, +]; + +const PROBE_TIMEOUT_MS = 15_000; + +function tokenPresent(helpText: string, token: string): boolean { + const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(^|[\\s,=<[|])${escaped}(?![\\w-])`, 'm').test(helpText); +} + +export class AntigravityCliRunner implements AgentRunner { + readonly name = 'antigravity-cli'; + readonly kind = 'antigravity-cli'; + readonly category = 'experimental'; + readonly declaredCapabilities = ANTIGRAVITY_DECLARED_CAPABILITIES; + /** Experimental in v0.6.1 — never selected automatically, never production. */ + readonly declaredSupportLevel: RunnerSupportLevel = 'experimental'; + private readonly config: AntigravityProfileConfig; + + constructor(config?: Partial) { + this.config = antigravityProfileSchema.parse({ runner: 'antigravity-cli', ...(config ?? {}) }); + } + + async detect(context: RunnerDetectionContext): Promise { + const diagnostics: Diagnostic[] = []; + const base: Pick< + RunnerDetectionResult, + 'runner' | 'kind' | 'executable' | 'authentication' | 'category' | 'capabilitySet' | 'networkBacked' + > = { + runner: this.name, + kind: 'antigravity-cli', + executable: this.config.command.executable, + // No safe offline status command is documented; credential files and + // private session stores are never read. + authentication: 'unknown', + category: this.category, + capabilitySet: ANTIGRAVITY_DECLARED_CAPABILITIES, + networkBacked: false, + }; + const emptyCapabilities = (): RunnerCapability[] => + ANTIGRAVITY_OBSERVATION_PROBES.map((probe) => ({ + id: probe.id, + label: probe.label, + available: false, + required: false, + })); + + if (!this.config.enabled) { + diagnostics.push({ + severity: 'error', + code: 'RUNNER_DISABLED', + message: + 'This Antigravity profile is disabled in .specbridge/config.json (enabled = false). ' + + 'It is experimental: enabling it only unlocks diagnostics, never automation.', + }); + return { + ...base, + status: 'misconfigured', + capabilities: emptyCapabilities(), + diagnostics, + supportLevel: 'experimental', + }; + } + + const timeoutMs = Math.min(context.timeoutMs ?? PROBE_TIMEOUT_MS, this.config.timeoutMs); + const invoke = (argv: string[]) => + runSafeProcess({ + executable: this.config.command.executable, + argv: [...this.config.command.args, ...argv], + cwd: process.cwd(), + timeoutMs, + maxStdoutBytes: 1024 * 1024, + maxStderrBytes: 256 * 1024, + }); + + // 1. Version probe. stdin is not connected; a build that insists on an + // interactive session simply hits the bounded timeout and is + // classified — no TTY, no PTY, no keystrokes, ever. + const versionResult = await invoke(['--version']); + if (versionResult.status === 'spawn-failed') { + diagnostics.push({ + severity: 'error', + code: 'RUNNER_EXECUTABLE_NOT_FOUND', + message: + `Antigravity executable "${this.config.command.executable}" could not be started. ` + + 'Install it yourself or set the profile command in .specbridge/config.json.', + }); + return { + ...base, + status: 'unavailable', + capabilities: emptyCapabilities(), + diagnostics, + supportLevel: 'experimental', + }; + } + if (versionResult.status === 'timeout') { + diagnostics.push({ + severity: 'error', + code: 'RUNNER_INTERACTIVE_ONLY', + message: + '"--version" did not return: the executable appears to start an interactive session. ' + + 'SpecBridge never automates a TUI (no PTY, no keystrokes, no screen scraping) — automation stays disabled.', + }); + return { + ...base, + status: 'incompatible', + capabilities: emptyCapabilities(), + diagnostics, + supportLevel: 'experimental', + }; + } + if (versionResult.status !== 'ok') { + diagnostics.push({ + severity: 'error', + code: 'RUNNER_VERSION_FAILED', + message: `"${this.config.command.executable} --version" ${versionResult.failureReason ?? 'produced no output'}.`, + }); + return { + ...base, + status: 'error', + capabilities: emptyCapabilities(), + diagnostics, + supportLevel: 'experimental', + }; + } + const version = versionResult.stdout.trim().split(/\r?\n/)[0]?.trim(); + + // 2. Help probe — observation only. + const help = await invoke(['--help']); + const helpText = `${help.stdout}\n${help.stderr}`; + const helpUsable = help.status === 'ok' && helpText.trim().length > 0; + const interactiveOnly = + help.status === 'timeout' || + (helpUsable && /interactive|tui/i.test(helpText) && !/--prompt|--non-interactive|--headless/i.test(helpText)); + + const capabilities: RunnerCapability[] = ANTIGRAVITY_OBSERVATION_PROBES.map((probe) => { + const available = helpUsable && probe.tokens.some((token) => tokenPresent(helpText, token)); + return { + id: probe.id, + label: probe.label, + available, + required: false, + detail: available + ? 'detected in help output — automation still stays disabled in v0.6.1' + : 'not proven for this installation', + }; + }); + + const notProven = capabilities.filter((capability) => !capability.available); + if (interactiveOnly) { + diagnostics.push({ + severity: 'warning', + code: 'RUNNER_INTERACTIVE_ONLY', + message: + 'This installation documents only an interactive workflow. SpecBridge never automates a TUI ' + + '(no PTY, no keystroke injection, no ANSI screen parsing).', + }); + } + if (notProven.length > 0) { + diagnostics.push({ + severity: 'info', + code: 'RUNNER_CAPABILITY_NOT_PROVEN', + message: `Not proven for this installation: ${notProven.map((capability) => capability.label.toLowerCase()).join('; ')}.`, + }); + } + diagnostics.push({ + severity: 'info', + code: 'RUNNER_EXPERIMENTAL', + message: + 'Antigravity support is experimental: executable and capability diagnostics only. ' + + 'Stage authoring, task execution, and resume are disabled until a documented, headless, ' + + 'structured-output contract passes the applicable conformance suite (not in v0.6.1).', + }); + + const status: RunnerStatus = helpUsable || help.status === 'timeout' ? 'available' : 'error'; + return { + ...base, + status, + ...(version !== undefined && version.length > 0 ? { version } : {}), + capabilities, + diagnostics, + supportLevel: 'experimental', + }; + } + + executionBoundaryNote(_policy: RunnerToolPolicy): string { + return 'Experimental: detection and diagnostics only; no authoring, no task execution, no automation.'; + } + + private refusal(): { + runner: string; + outcome: 'failed'; + failureReason: string; + rawStdout: string; + rawStderr: string; + durationMs: number; + warnings: string[]; + error: ReturnType; + } { + return { + runner: this.name, + outcome: 'failed', + failureReason: + 'the antigravity-cli adapter is experimental: it detects capabilities only and never executes authoring or tasks', + rawStdout: '', + rawStderr: '', + durationMs: 0, + warnings: [], + error: runnerError({ + code: 'unsupported_operation', + message: 'The experimental Antigravity adapter performs detection only in v0.6.1.', + remediation: [ + 'Use a claude-code, codex-cli, or gemini-cli profile for execution, or an authoring profile for spec drafting.', + ], + }), + }; + } + + /** Selection refuses every operation first; these are defense in depth. */ + generateStage( + _input: StageGenerationInput, + _execution: RunnerExecutionOptions, + ): Promise { + return Promise.resolve(this.refusal()); + } + + executeTask( + _input: TaskExecutionInput, + _execution: RunnerExecutionOptions, + ): Promise { + return Promise.resolve({ ...this.refusal(), resumeSupported: false }); + } +} diff --git a/packages/runners/src/conformance/conformance.ts b/packages/runners/src/conformance/conformance.ts index 8485116..b7c6de1 100644 --- a/packages/runners/src/conformance/conformance.ts +++ b/packages/runners/src/conformance/conformance.ts @@ -517,12 +517,17 @@ export async function runRunnerConformance( 0, ); const skippedChecks = groups.reduce((sum, group) => sum + group.skipped, 0); + // Preview/experimental adapters (v0.6.1: antigravity-cli) can pass their + // applicable checks but can never be CONFIRMED production by conformance — + // production assignment needs a production-declared adapter too. + const declaredProduction = + (context.profile.runner.declaredSupportLevel ?? 'production') === 'production'; return { runner: context.profile.runner.name, profile: context.profile.name, groups, passed: failedChecks === 0, - productionConfirmed: failedChecks === 0 && skippedChecks === 0, + productionConfirmed: failedChecks === 0 && skippedChecks === 0 && declaredProduction, skippedChecks, failedChecks, }; diff --git a/packages/runners/src/contract.ts b/packages/runners/src/contract.ts index ea5000a..703e072 100644 --- a/packages/runners/src/contract.ts +++ b/packages/runners/src/contract.ts @@ -260,6 +260,14 @@ export interface AgentRunner { * for authoring operations (see StageGenerationInput.correction). */ readonly supportsStructuredOutputCorrection?: boolean; + /** + * v0.6.1 (additive, optional — existing adapters are unaffected): the + * support level the adapter itself declares. Absent means `production` + * (the v0.6.0 behavior). `preview`/`experimental` adapters are never + * selected automatically and can never be confirmed production by + * conformance. + */ + readonly declaredSupportLevel?: RunnerSupportLevel; detect(context: RunnerDetectionContext): Promise; diff --git a/packages/runners/src/gemini-cli/detection.ts b/packages/runners/src/gemini-cli/detection.ts new file mode 100644 index 0000000..8455ac8 --- /dev/null +++ b/packages/runners/src/gemini-cli/detection.ts @@ -0,0 +1,372 @@ +import type { Diagnostic, GeminiProfileConfig, RunnerStatus } from '@specbridge/core'; +import type { RunnerAuthState, RunnerCapability, RunnerCapabilityId } from '../contract.js'; +import type { RunnerCapabilitySet } from '../contracts/capabilities.js'; +import { capabilitySet } from '../contracts/capabilities.js'; +import { runSafeProcess } from '../safe-process.js'; + +/** + * Gemini CLI executable, authentication, and capability detection (v0.6.1). + * + * Everything here is read-only: `--version` and `--help` only. Doctor-level + * detection NEVER sends a model request, never triggers an interactive + * login, never touches trusted-folder state, and never reads Google + * credential files. + * + * Capability detection probes help text for flag/value tokens instead of + * relying on one exact help layout, so newer/older CLI versions degrade + * gracefully. The Gemini CLI exposes no safe offline authentication-status + * command, so authentication is reported as `unknown` — a minimal + * authenticated probe is available through `runner test --network`. + */ + +export interface GeminiCapabilityProbe { + id: + | RunnerCapabilityId + | 'headless' + | 'output-json' + | 'output-stream-json' + | 'approval-mode' + | 'plan-mode' + | 'auto-edit-mode' + | 'sandbox' + | 'allowed-tools' + | 'extension-restriction' + | 'model-selection' + | 'session-list'; + label: string; + /** Any matching token in help output counts. */ + tokens: string[]; + required: boolean; + degradedNote?: string; +} + +export const GEMINI_CAPABILITY_PROBES: GeminiCapabilityProbe[] = [ + { + id: 'headless', + label: 'Headless prompt invocation (--prompt)', + tokens: ['--prompt'], + required: true, + }, + { + id: 'output-json', + label: 'Machine-readable output (--output-format json)', + tokens: ['--output-format'], + required: true, + }, + { + id: 'output-stream-json', + label: 'Streaming machine-readable events (stream-json)', + tokens: ['stream-json'], + required: false, + degradedNote: 'the single JSON result envelope is used instead of streamed events', + }, + { + id: 'approval-mode', + label: 'Approval-mode selection (--approval-mode)', + tokens: ['--approval-mode'], + required: true, + }, + { + id: 'plan-mode', + label: 'Read-only plan approval mode (plan)', + tokens: ['plan'], + required: false, + degradedNote: 'authoring needs a read-only tool allowlist instead of plan mode', + }, + { + id: 'auto-edit-mode', + label: 'Edit-only approval mode (auto_edit)', + tokens: ['auto_edit'], + required: false, + degradedNote: 'task execution is unavailable without a bounded edit approval mode', + }, + { + id: 'sandbox', + label: 'Sandboxed tool execution (--sandbox)', + tokens: ['--sandbox'], + required: false, + degradedNote: 'the tool allowlist is the only execution boundary', + }, + { + id: 'allowed-tools', + label: 'Tool allowlist (--allowed-tools)', + tokens: ['--allowed-tools'], + required: false, + degradedNote: 'the sandbox is the only execution boundary', + }, + { + id: 'extension-restriction', + label: 'Extension restriction (--extensions)', + tokens: ['--extensions'], + required: false, + degradedNote: 'installed extensions cannot be disabled for SpecBridge runs', + }, + { + id: 'model-selection', + label: 'Model selection (--model)', + tokens: ['--model'], + required: false, + degradedNote: 'the provider default model is used', + }, + { + id: 'session-list', + label: 'Session listing (--list-sessions)', + tokens: ['--list-sessions'], + required: false, + }, + { + id: 'resume', + label: 'Explicit session resume (--resume )', + tokens: ['--resume'], + required: false, + degradedNote: 'interrupted runs need a fresh attempt instead of a resume', + }, +]; + +/** + * Gemini CLI capabilities when a fully featured installation is available. + * Detection downgrades individual capabilities (never adds). There is no + * `supportsJsonSchema`: the CLI cannot constrain output with a schema, so + * structured output is a strict JSON-only response validated completely by + * SpecBridge (the conformance-approved fallback). + */ +export const GEMINI_DECLARED_CAPABILITIES: RunnerCapabilitySet = capabilitySet([ + 'stageGeneration', + 'stageRefinement', + 'taskExecution', + 'taskResume', + 'structuredFinalOutput', + 'streamingEvents', + 'repositoryRead', + 'repositoryWrite', + 'sandbox', + 'toolRestriction', + 'usageReporting', + 'requiresNetwork', + 'supportsCancellation', +]); + +export interface GeminiProbe { + executable: string; + commandArgs: string[]; + found: boolean; + version?: string; + authState: RunnerAuthState; + capabilities: RunnerCapability[]; + /** Tokens found in the probed help output. */ + supportedTokens: Set; + status: RunnerStatus; + diagnostics: Diagnostic[]; +} + +const PROBE_TIMEOUT_MS = 15_000; + +/** Match a token on a word boundary so `json` does not match `jsonl`. */ +function tokenPresent(helpText: string, token: string): boolean { + const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(^|[\\s,=<[|])${escaped}(?![\\w-])`, 'm').test(helpText); +} + +export async function probeGemini( + config: GeminiProfileConfig, + options?: { timeoutMs?: number; signal?: AbortSignal }, +): Promise { + const diagnostics: Diagnostic[] = []; + const timeoutMs = options?.timeoutMs ?? PROBE_TIMEOUT_MS; + const base = { + executable: config.command.executable, + commandArgs: config.command.args, + }; + + const invoke = (argv: string[]) => + runSafeProcess({ + executable: config.command.executable, + argv: [...config.command.args, ...argv], + cwd: process.cwd(), + timeoutMs, + ...(options?.signal !== undefined ? { signal: options.signal } : {}), + maxStdoutBytes: 1024 * 1024, + maxStderrBytes: 256 * 1024, + }); + + const emptyCapabilities = (): RunnerCapability[] => + GEMINI_CAPABILITY_PROBES.map((probe) => ({ + id: probe.id, + label: probe.label, + available: false, + required: probe.required, + })); + + // 1. Version probe — also proves the executable spawns at all. + const versionResult = await invoke(['--version']); + if (versionResult.status === 'spawn-failed') { + diagnostics.push({ + severity: 'error', + code: 'RUNNER_EXECUTABLE_NOT_FOUND', + message: + `Gemini CLI executable "${config.command.executable}" could not be started. ` + + 'Install the Gemini CLI (the user installs and authenticates it independently) ' + + 'or set the profile command in .specbridge/config.json.', + }); + return { + ...base, + found: false, + authState: 'unknown', + capabilities: emptyCapabilities(), + supportedTokens: new Set(), + status: 'unavailable', + diagnostics, + }; + } + if (versionResult.status !== 'ok') { + diagnostics.push({ + severity: 'error', + code: 'RUNNER_VERSION_FAILED', + message: `"${config.command.executable} --version" ${versionResult.failureReason ?? 'produced no output'}.`, + }); + return { + ...base, + found: true, + authState: 'unknown', + capabilities: emptyCapabilities(), + supportedTokens: new Set(), + status: 'error', + diagnostics, + }; + } + const version = versionResult.stdout.trim().split(/\r?\n/)[0]?.trim(); + + // 2. Help probe — bounded token search, tolerant of layout changes. + const help = await invoke(['--help']); + const helpText = `${help.stdout}\n${help.stderr}`; + const helpUsable = help.status === 'ok' && helpText.trim().length > 0; + if (!helpUsable) { + diagnostics.push({ + severity: 'error', + code: 'RUNNER_HELP_FAILED', + message: `"${config.command.executable} --help" ${help.failureReason ?? 'produced no output'}; capabilities cannot be verified.`, + }); + } + + const supportedTokens = new Set(); + const capabilities: RunnerCapability[] = GEMINI_CAPABILITY_PROBES.map((probe) => { + const available = helpUsable && probe.tokens.some((token) => tokenPresent(helpText, token)); + if (available) for (const token of probe.tokens) supportedTokens.add(token); + return { + id: probe.id, + label: probe.label, + available, + required: probe.required, + ...(available || probe.degradedNote === undefined ? {} : { detail: probe.degradedNote }), + }; + }); + + // 3. Authentication: the Gemini CLI exposes no safe offline status command. + // SpecBridge never reads OAuth caches or credential files, never starts + // the interactive login, and therefore reports `unknown`. + const authState: RunnerAuthState = 'unknown'; + if (helpUsable) { + diagnostics.push({ + severity: 'info', + code: 'RUNNER_AUTH_PROBE_UNSUPPORTED', + message: + 'Authentication cannot be verified without a model request; it is reported as unknown ' + + '(SpecBridge never reads Google credential files and never starts an interactive login). ' + + 'Use "specbridge runner test --network" for a minimal authenticated probe.', + }); + } + + const available = (id: GeminiCapabilityProbe['id']): boolean => + capabilities.find((capability) => capability.id === id)?.available === true; + + const missingRequired = capabilities.filter((c) => c.required && !c.available); + const authoringBoundary = available('plan-mode') || available('allowed-tools'); + let status: RunnerStatus; + if (missingRequired.length > 0) { + status = 'incompatible'; + diagnostics.push({ + severity: 'error', + code: 'RUNNER_MISSING_CAPABILITY', + message: + `This Gemini CLI version is missing required capabilities: ` + + `${missingRequired.map((c) => c.label).join(', ')}. ` + + 'Update the Gemini CLI to a version with headless prompts, machine-readable output, and approval-mode control.', + }); + } else if (helpUsable && !authoringBoundary) { + status = 'incompatible'; + diagnostics.push({ + severity: 'error', + code: 'RUNNER_MISSING_CAPABILITY', + message: + 'This Gemini CLI version offers neither a plan approval mode nor a tool allowlist, so a ' + + 'read-only authoring boundary cannot be established. SpecBridge never weakens the boundary ' + + '(and never uses YOLO) — update the Gemini CLI.', + }); + } else if (!helpUsable) { + status = 'error'; + } else { + status = 'available'; + for (const capability of capabilities.filter((c) => !c.required && !c.available)) { + diagnostics.push({ + severity: 'warning', + code: 'RUNNER_DEGRADED_CAPABILITY', + message: `Optional capability unavailable: ${capability.label}${capability.detail !== undefined ? ` — ${capability.detail}` : ''}.`, + }); + } + if (!available('auto-edit-mode') || (!available('allowed-tools') && !available('sandbox'))) { + diagnostics.push({ + severity: 'warning', + code: 'RUNNER_TASK_EXECUTION_UNAVAILABLE', + message: + 'Task execution is unavailable for this installation: file edits cannot be permitted ' + + 'without also permitting arbitrary shell commands (needs auto_edit plus a tool allowlist or sandbox). ' + + 'Authoring remains available. Use a claude-code or codex-cli profile for task execution.', + }); + } + } + + return { + ...base, + found: true, + ...(version !== undefined && version.length > 0 ? { version } : {}), + authState, + capabilities, + supportedTokens, + status, + diagnostics, + }; +} + +function probeAvailable(probe: GeminiProbe, id: GeminiCapabilityProbe['id']): boolean { + return probe.capabilities.find((capability) => capability.id === id)?.available === true; +} + +/** Downgrade declared capabilities to what the installed CLI actually has. */ +export function geminiCapabilitySet(probe: GeminiProbe): RunnerCapabilitySet { + if (!probe.found) return capabilitySet([]); + const set: RunnerCapabilitySet = { ...GEMINI_DECLARED_CAPABILITIES }; + const headless = + probeAvailable(probe, 'headless') && + probeAvailable(probe, 'output-json') && + probeAvailable(probe, 'approval-mode'); + const plan = probeAvailable(probe, 'plan-mode'); + const allowedTools = probeAvailable(probe, 'allowed-tools'); + const sandbox = probeAvailable(probe, 'sandbox'); + const autoEdit = probeAvailable(probe, 'auto-edit-mode'); + + // Authoring needs a proven read-only boundary: plan mode or a tool + // allowlist restricted to repository-reading operations. + set.stageGeneration = headless && (plan || allowedTools); + set.stageRefinement = set.stageGeneration; + set.structuredFinalOutput = headless; + set.streamingEvents = probeAvailable(probe, 'output-stream-json'); + set.sandbox = sandbox; + set.toolRestriction = allowedTools; + // Task execution needs a bounded edit policy: auto_edit (edits only are + // auto-approved; everything else — including shell — is refused headlessly) + // PLUS a tool allowlist or sandbox. YOLO is never an option. + set.taskExecution = headless && autoEdit && (allowedTools || sandbox); + set.repositoryWrite = set.taskExecution; + set.taskResume = set.taskExecution && probeAvailable(probe, 'resume'); + return set; +} diff --git a/packages/runners/src/gemini-cli/events.ts b/packages/runners/src/gemini-cli/events.ts new file mode 100644 index 0000000..11e6fc1 --- /dev/null +++ b/packages/runners/src/gemini-cli/events.ts @@ -0,0 +1,259 @@ +import { z } from 'zod'; +import type { NormalizedRunnerEvent, EventEnvelopeContext } from '../contracts/events.js'; +import { boundedPayloadText, normalizedRunnerEventSchema } from '../contracts/events.js'; +import type { RunnerUsage } from '../contracts/usage.js'; + +/** + * Gemini CLI machine-readable output parsing and normalization (v0.6.1). + * + * Two documented shapes are supported: + * - `--output-format stream-json`: JSON Lines, one event per line, ending + * in a `result` event carrying the final response text + * - `--output-format json`: one JSON envelope `{response, stats}` + * + * Parsing is tolerant — unknown event types are retained (size-limited) but + * only well-understood shapes are normalized. + * + * Reasoning boundary: `thought` events are provider thinking output. Their + * text is NEVER copied into normalized events, reports, or retained raw + * output; only the fact that a thought occurred (and its size) is kept. + */ + +/** Hard cap on retained raw/normalized events per invocation. */ +export const MAX_RETAINED_GEMINI_EVENTS = 5000; + +const geminiEventSchema = z + .object({ + type: z.string(), + session_id: z.string().optional(), + text: z.string().optional(), + name: z.string().optional(), + status: z.string().optional(), + path: z.string().optional(), + kind: z.string().optional(), + command: z.string().optional(), + input_tokens: z.number().optional(), + output_tokens: z.number().optional(), + cached_input_tokens: z.number().optional(), + response: z.string().optional(), + message: z.string().optional(), + }) + .passthrough(); +export type GeminiEvent = z.infer; + +/** The single-envelope `--output-format json` shape. */ +export const geminiJsonEnvelopeSchema = z + .object({ + response: z.string(), + stats: z + .object({ + session_id: z.string().optional(), + input_tokens: z.number().optional(), + output_tokens: z.number().optional(), + cached_input_tokens: z.number().optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); +export type GeminiJsonEnvelope = z.infer; + +export interface GeminiEventStream { + /** Parsed provider events, in order (capped at MAX_RETAINED_GEMINI_EVENTS). */ + events: GeminiEvent[]; + /** Lines that did not parse as JSON objects (count only; content unsafe). */ + unparseableLines: number; + truncated: boolean; + sessionId?: string; + /** The `result` event response text — the structured-result candidate. */ + finalResponse?: string; + usage?: Omit & { requestCount: number }; + /** error event messages, bounded. */ + errors: string[]; +} + +/** Parse a `--output-format stream-json` stdout stream. Never throws. */ +export function parseGeminiEventStream(stdout: string): GeminiEventStream { + const stream: GeminiEventStream = { + events: [], + unparseableLines: 0, + truncated: false, + errors: [], + }; + let inputTokens: number | null = null; + let cachedInputTokens: number | null = null; + let outputTokens: number | null = null; + let requests = 0; + + for (const line of stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || !trimmed.startsWith('{')) continue; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + stream.unparseableLines += 1; + continue; + } + const event = geminiEventSchema.safeParse(parsed); + if (!event.success) { + stream.unparseableLines += 1; + continue; + } + if (stream.events.length < MAX_RETAINED_GEMINI_EVENTS) { + stream.events.push(event.data); + } else { + stream.truncated = true; + } + + const data = event.data; + if (data.type === 'session.started' && data.session_id !== undefined) { + stream.sessionId = data.session_id; + } + if (data.type === 'usage') { + requests += 1; + inputTokens = (inputTokens ?? 0) + (data.input_tokens ?? 0); + cachedInputTokens = (cachedInputTokens ?? 0) + (data.cached_input_tokens ?? 0); + outputTokens = (outputTokens ?? 0) + (data.output_tokens ?? 0); + } + if (data.type === 'result' && data.response !== undefined) { + stream.finalResponse = data.response; + } + if (data.type === 'error') { + const message = data.message ?? data.text; + if (message !== undefined && stream.errors.length < 20) { + stream.errors.push(boundedPayloadText(message, 500)); + } + } + } + + if (requests > 0 || inputTokens !== null || outputTokens !== null) { + stream.usage = { + inputTokens, + cachedInputTokens, + outputTokens, + reasoningTokens: null, + requestCount: Math.max(1, requests), + }; + } + return stream; +} + +/** + * Redact thought content from a raw JSONL stdout stream before it is + * retained as a run artifact. Parsing happens BEFORE this: the retained + * bytes keep every event's structure but thought text is replaced with a + * length marker — only safe status metadata for reasoning is ever kept. + */ +export function redactGeminiStdoutForRetention(stdout: string): string { + return stdout + .split(/\r?\n/) + .map((line) => { + const trimmed = line.trim(); + if (!trimmed.startsWith('{') || !trimmed.includes('"thought"')) return line; + try { + const parsed = JSON.parse(trimmed) as { type?: string; text?: string }; + if (parsed.type === 'thought' && typeof parsed.text === 'string') { + parsed.text = `[redacted reasoning: ${parsed.text.length} chars]`; + return JSON.stringify(parsed); + } + } catch { + // Not JSON — leave the line untouched. + } + return line; + }) + .join('\n'); +} + +/** + * Normalize parsed Gemini events. Thought events become `message.completed` + * with a redacted payload — their content is intentionally absent. + */ +export function normalizeGeminiEvents( + stream: GeminiEventStream, + context: EventEnvelopeContext, + timestamp: () => string, +): NormalizedRunnerEvent[] { + const normalized: NormalizedRunnerEvent[] = []; + const push = ( + type: NormalizedRunnerEvent['type'], + providerEventType: string, + payload: Record, + ): void => { + if (normalized.length >= MAX_RETAINED_GEMINI_EVENTS) return; + normalized.push( + normalizedRunnerEventSchema.parse({ + type, + timestamp: timestamp(), + runner: context.runner, + profile: context.profile, + runId: context.runId, + attemptId: context.attemptId, + ...(context.providerSessionId !== undefined || stream.sessionId !== undefined + ? { providerSessionId: context.providerSessionId ?? stream.sessionId } + : {}), + providerEventType, + payload, + }), + ); + }; + + for (const event of stream.events) { + switch (event.type) { + case 'session.started': + push('session.started', event.type, { + ...(event.session_id !== undefined ? { sessionId: event.session_id } : {}), + }); + break; + case 'thought': + // Reasoning content is never normalized — status metadata only. + push('message.completed', event.type, { + redacted: true, + textLength: event.text?.length ?? 0, + }); + break; + case 'tool.started': + push('tool.started', event.type, { + ...(event.name !== undefined ? { tool: event.name } : {}), + ...(event.path !== undefined ? { path: boundedPayloadText(event.path, 500) } : {}), + }); + break; + case 'tool.completed': + push( + event.status === 'failed' || event.status === 'denied' ? 'tool.failed' : 'tool.completed', + event.type, + { + ...(event.name !== undefined ? { tool: event.name } : {}), + ...(event.status !== undefined ? { status: event.status } : {}), + }, + ); + break; + case 'file.edited': + push('file.changed', event.type, { + ...(event.path !== undefined ? { path: boundedPayloadText(event.path, 500) } : {}), + ...(event.kind !== undefined ? { kind: event.kind } : {}), + }); + break; + case 'usage': + push('usage.updated', event.type, { + inputTokens: event.input_tokens ?? null, + cachedInputTokens: event.cached_input_tokens ?? null, + outputTokens: event.output_tokens ?? null, + }); + break; + case 'result': + push('message.completed', event.type, { + textLength: event.response?.length ?? 0, + }); + break; + case 'error': + push('error', event.type, { + message: boundedPayloadText(event.message ?? event.text ?? 'error', 500), + }); + break; + default: + break; + } + } + return normalized; +} diff --git a/packages/runners/src/gemini-cli/invocation.ts b/packages/runners/src/gemini-cli/invocation.ts new file mode 100644 index 0000000..a39e803 --- /dev/null +++ b/packages/runners/src/gemini-cli/invocation.ts @@ -0,0 +1,235 @@ +import type { GeminiProfileConfig } from '@specbridge/core'; +import { SpecBridgeError } from '@specbridge/core'; +import type { RunnerExecutionOptions, RunnerToolPolicy } from '../contract.js'; +import type { SafeProcessResult } from '../safe-process.js'; +import { runSafeProcess } from '../safe-process.js'; +import type { GeminiProbe } from './detection.js'; + +/** + * Gemini CLI invocation: argument-vector construction and process execution. + * + * Hard rules (tested): + * - the argument vector is an array; no shell string is ever built + * - YOLO can never appear: not as a flag, not as an approval-mode value, + * whatever the configuration says (asserted pre-spawn) + * - authoring ALWAYS uses the plan approval mode (or, on installations + * without plan mode, a read-only tool allowlist) — never an edit mode + * - task execution uses auto_edit with a bounded tool set that excludes + * every shell-execution tool + * - workspace trust is never granted and extensions are disabled where + * supported + * - the prompt travels via stdin, never via a process-list-visible argument + * - resume uses an explicit session UUID only — never "latest", never an + * index + * - only flags detected in help output are passed (graceful degradation) + */ + +/** Flags that must never be passed to the Gemini CLI (asserted pre-spawn). */ +export const GEMINI_FORBIDDEN_ARGUMENTS = [ + '--yolo', + '-y', + '--dangerously-skip-permissions', + '--trust-folder', + '--trust', +]; + +/** Approval-mode values SpecBridge is allowed to pass. */ +export const GEMINI_ALLOWED_APPROVAL_MODES = ['plan', 'default', 'auto_edit'] as const; + +/** Repository-reading tools allowed during authoring. */ +export const GEMINI_READ_ONLY_TOOLS = [ + 'read_file', + 'read_many_files', + 'list_directory', + 'glob', + 'search_file_content', +]; + +/** File-editing tools additionally allowed during task execution. */ +export const GEMINI_EDIT_TOOLS = ['replace', 'write_file']; + +/** Tool names that must never be allowed (arbitrary execution). */ +export const GEMINI_FORBIDDEN_TOOLS = [ + 'run_shell_command', + 'shell', + 'bash', + 'execute_command', + 'terminal', +]; + +const SESSION_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** True when the value is an explicit session UUID (never "latest"/index). */ +export function isExplicitGeminiSessionId(value: string): boolean { + return SESSION_UUID_PATTERN.test(value); +} + +export interface GeminiInvocationPlan { + executable: string; + argv: string[]; + /** Prompt content delivered via stdin. */ + stdin: string; + outputFormat: 'json' | 'stream-json'; + /** The approval mode the plan uses (never yolo). */ + approvalMode: string; + /** The tool allowlist passed, when the CLI supports one. */ + allowedTools?: string[]; + /** Flags that were requested but skipped because the CLI lacks them. */ + skippedFlags: string[]; +} + +export interface BuildGeminiInvocationInput { + config: GeminiProfileConfig; + probe: GeminiProbe; + prompt: string; + toolPolicy: RunnerToolPolicy; + /** Resume an existing provider session (explicit UUID only, never "latest"). */ + resumeSessionId?: string; + execution: RunnerExecutionOptions; +} + +/** Defense in depth: no code path may assemble an unrestricted invocation. */ +export function assertNoForbiddenGeminiArguments(argv: readonly string[]): void { + for (const argument of argv) { + for (const forbidden of GEMINI_FORBIDDEN_ARGUMENTS) { + if (argument === forbidden) { + throw new SpecBridgeError( + 'INVALID_STATE', + `Refusing to invoke the Gemini CLI: the argument vector contains "${forbidden}". ` + + 'SpecBridge never uses YOLO, never skips approvals, and never auto-trusts a workspace.', + ); + } + } + } + const approvalIndex = argv.indexOf('--approval-mode'); + if (approvalIndex >= 0) { + const mode = argv[approvalIndex + 1]; + if (!GEMINI_ALLOWED_APPROVAL_MODES.includes(mode as (typeof GEMINI_ALLOWED_APPROVAL_MODES)[number])) { + throw new SpecBridgeError( + 'INVALID_STATE', + `Refusing to invoke the Gemini CLI with approval mode "${mode ?? '(missing)'}". ` + + `Only ${GEMINI_ALLOWED_APPROVAL_MODES.join(', ')} are ever used — never yolo.`, + ); + } + } + const toolsIndex = argv.indexOf('--allowed-tools'); + if (toolsIndex >= 0) { + const tools = (argv[toolsIndex + 1] ?? '').split(','); + for (const tool of tools) { + if (GEMINI_FORBIDDEN_TOOLS.includes(tool.trim().toLowerCase())) { + throw new SpecBridgeError( + 'INVALID_STATE', + `Refusing to invoke the Gemini CLI with allowed tool "${tool}". ` + + 'SpecBridge never grants the Gemini CLI arbitrary shell access.', + ); + } + } + } + const resumeIndex = argv.indexOf('--resume'); + if (resumeIndex >= 0) { + const session = argv[resumeIndex + 1]; + if (session === undefined || !isExplicitGeminiSessionId(session)) { + throw new SpecBridgeError( + 'INVALID_STATE', + `Refusing to resume the Gemini session "${session ?? '(missing)'}": resume requires an ` + + 'explicit session UUID — "latest", indexes, and ambiguous identifiers are never used.', + ); + } + } +} + +/** Build the full argument vector. Pure; safe to preview (no side effects). */ +export function buildGeminiInvocation(input: BuildGeminiInvocationInput): GeminiInvocationPlan { + const { config, probe, execution } = input; + const argv: string[] = [...config.command.args]; + const skippedFlags: string[] = []; + const supports = (token: string): boolean => probe.supportedTokens.has(token); + const implementation = input.toolPolicy === 'implementation'; + + // Headless mode; the prompt itself travels via stdin (process-list safety). + argv.push('--prompt'); + + const outputFormat: 'json' | 'stream-json' = supports('stream-json') ? 'stream-json' : 'json'; + argv.push('--output-format', outputFormat); + + // Authoring is ALWAYS plan (read-only). Task execution uses the configured + // bounded edit mode (auto_edit or default) — never yolo, which is neither a + // schema value nor accepted by the pre-spawn assertion. + const approvalMode = implementation + ? config.approvalModeForExecution + : config.approvalModeForAuthoring; + argv.push('--approval-mode', approvalMode); + + let allowedTools: string[] | undefined; + if (supports('--allowed-tools')) { + allowedTools = implementation + ? [ + ...GEMINI_READ_ONLY_TOOLS, + ...GEMINI_EDIT_TOOLS, + ...config.allowedTools.filter( + (tool) => !GEMINI_FORBIDDEN_TOOLS.includes(tool.toLowerCase()), + ), + ] + : [...GEMINI_READ_ONLY_TOOLS]; + argv.push('--allowed-tools', allowedTools.join(',')); + } else { + skippedFlags.push('--allowed-tools'); + } + + if (config.sandbox) { + if (supports('--sandbox')) argv.push('--sandbox'); + else skippedFlags.push('--sandbox'); + } + + if (config.disabledExtensions) { + if (supports('--extensions')) argv.push('--extensions', 'none'); + else skippedFlags.push('--extensions'); + } + + const model = execution.model ?? config.model; + if (model !== null && model !== undefined) { + if (supports('--model')) argv.push('--model', model); + else skippedFlags.push('--model'); + } + + if (input.resumeSessionId !== undefined) { + if (!isExplicitGeminiSessionId(input.resumeSessionId)) { + throw new SpecBridgeError( + 'INVALID_ARGUMENT', + `Cannot resume Gemini session "${input.resumeSessionId}": an explicit session UUID is required ` + + '("latest", indexes, and ambiguous identifiers are never used).', + ); + } + argv.push('--resume', input.resumeSessionId); + } + + assertNoForbiddenGeminiArguments(argv); + + return { + executable: config.command.executable, + argv, + stdin: input.prompt, + outputFormat, + approvalMode, + ...(allowedTools !== undefined ? { allowedTools } : {}), + skippedFlags, + }; +} + +export async function runGeminiInvocation( + plan: GeminiInvocationPlan, + config: GeminiProfileConfig, + execution: RunnerExecutionOptions, +): Promise { + assertNoForbiddenGeminiArguments(plan.argv); + return runSafeProcess({ + executable: plan.executable, + argv: plan.argv, + cwd: execution.workspaceRoot, + timeoutMs: execution.timeoutMs, + ...(execution.signal !== undefined ? { signal: execution.signal } : {}), + stdin: plan.stdin, + maxStdoutBytes: config.maxStdoutBytes, + maxStderrBytes: config.maxStderrBytes, + }); +} diff --git a/packages/runners/src/gemini-cli/runner.ts b/packages/runners/src/gemini-cli/runner.ts new file mode 100644 index 0000000..5447217 --- /dev/null +++ b/packages/runners/src/gemini-cli/runner.ts @@ -0,0 +1,703 @@ +import type { + ExecutionOutcome, + GeminiProfileConfig, + StageRunnerReport, + TaskRunnerReport, +} from '@specbridge/core'; +import { + geminiProfileSchema, + stageRunnerReportSchema, + taskRunnerReportSchema, +} from '@specbridge/core'; +import type { + AgentRunner, + RunnerDetectionContext, + RunnerDetectionResult, + RunnerExecutionOptions, + RunnerModelListResult, + RunnerSelfTestResult, + RunnerToolPolicy, + StageGenerationInput, + StageGenerationResult, + TaskExecutionInput, + TaskExecutionResult, + TaskResumeInput, +} from '../contract.js'; +import { effectiveSupportLevel } from '../contracts/capabilities.js'; +import type { NormalizedRunnerError } from '../contracts/errors.js'; +import { runnerError } from '../contracts/errors.js'; +import type { NormalizedRunnerEvent } from '../contracts/events.js'; +import type { RunnerUsage } from '../contracts/usage.js'; +import type { SafeProcessResult } from '../safe-process.js'; +import type { GeminiProbe } from './detection.js'; +import { GEMINI_DECLARED_CAPABILITIES, geminiCapabilitySet, probeGemini } from './detection.js'; +import type { GeminiInvocationPlan } from './invocation.js'; +import { + buildGeminiInvocation, + isExplicitGeminiSessionId, + runGeminiInvocation, +} from './invocation.js'; +import type { GeminiEventStream } from './events.js'; +import { + geminiJsonEnvelopeSchema, + normalizeGeminiEvents, + parseGeminiEventStream, + redactGeminiStdoutForRetention, +} from './events.js'; + +/** + * Gemini CLI runner (v0.6.1): invokes the locally installed `gemini` CLI in + * headless mode with machine-readable output. + * + * The local user installs and authenticates the Gemini CLI independently. + * SpecBridge only spawns the configured executable — it never collects, + * stores, proxies, or prints credentials, never reads Google credential + * files, never triggers an interactive login, never trusts a folder, and + * never uses YOLO or any other unrestricted approval mode (enforced at + * three layers: config schema, argv assembly, pre-spawn assertion). + * + * Boundaries: + * - stage generation / refinement: plan approval mode plus a read-only + * tool allowlist where supported (repository inspection only) + * - task execution / resume: auto_edit approval mode with a bounded tool + * set that excludes every shell-execution tool + * - provider-reported file changes and completion claims are CLAIMS; + * evidence comes from SpecBridge's own Git snapshots and trusted + * verification commands + */ + +interface GeminiMappedResult { + runner: string; + outcome: ExecutionOutcome; + failureReason?: string; + rawStdout: string; + rawStderr: string; + process?: SafeProcessResult['observation']; + sessionId?: string; + durationMs: number; + warnings: string[]; + normalizedEvents?: NormalizedRunnerEvent[]; + error?: NormalizedRunnerError; + usage?: RunnerUsage; + report?: StageRunnerReport | TaskRunnerReport; + invalidStructuredOutput?: string; +} + +/** Classify a nonzero-exit / stream failure into a normalized error. */ +export function classifyGeminiFailure( + stderr: string, + streamErrors: string[], +): NormalizedRunnerError { + const haystack = `${stderr}\n${streamErrors.join('\n')}`.toLowerCase(); + if (/please sign in|not logged in|login required|unauthorized|unauthenticated|401/.test(haystack)) { + return runnerError({ + code: 'authentication_required', + message: 'The Gemini CLI reported an authentication failure.', + remediation: [ + 'Authenticate the Gemini CLI yourself (SpecBridge never handles credentials and never starts a login flow).', + ], + }); + } + if (/resource_exhausted|quota exceeded|out of quota|usage limit/.test(haystack)) { + return runnerError({ + code: 'quota_exceeded', + message: 'The provider reported an exhausted quota or usage limit.', + remediation: ['Check your provider plan and usage, then retry explicitly.'], + }); + } + if (/rate limit|too many requests|429/.test(haystack)) { + return runnerError({ + code: 'rate_limited', + message: 'The provider reported a rate limit.', + remediation: ['Wait and retry explicitly.'], + providerCode: '429', + }); + } + if (/permission denied|approval (required|denied)|call rejected|not permitted/.test(haystack)) { + return runnerError({ + code: 'permission_denied', + message: 'The Gemini CLI reported a permission denial.', + remediation: [ + 'SpecBridge never bypasses approvals (and never uses YOLO); narrow the task so it needs only repository reads and file edits.', + ], + }); + } + if (/network|connection|dns|econn|etimedout/.test(haystack)) { + return runnerError({ + code: 'network_error', + message: 'The Gemini CLI reported a network failure.', + remediation: ['Check connectivity and retry explicitly.'], + }); + } + return runnerError({ + code: 'process_failed', + message: 'The Gemini CLI exited with a failure.', + remediation: ['Inspect the retained stderr and event log in the run directory.'], + }); +} + +const TASK_EXECUTION_REMEDIATION = [ + 'Authoring may remain available through the read-only boundary.', + 'Use a claude-code or codex-cli profile for task execution ("specbridge runner list" shows compatible profiles).', +]; + +export class GeminiCliRunner implements AgentRunner { + readonly name = 'gemini-cli'; + readonly kind = 'gemini-cli'; + readonly category = 'agent-cli'; + readonly declaredCapabilities = GEMINI_DECLARED_CAPABILITIES; + /** Orchestration may perform ONE structured-output correction retry. */ + readonly supportsStructuredOutputCorrection = true; + private readonly config: GeminiProfileConfig; + private probePromise: Promise | undefined; + + constructor(config?: Partial) { + this.config = geminiProfileSchema.parse({ runner: 'gemini-cli', ...(config ?? {}) }); + } + + /** Probe once per runner instance; detection is read-only but not free. */ + private probe(timeoutMs?: number): Promise { + this.probePromise ??= probeGemini( + this.config, + timeoutMs !== undefined ? { timeoutMs } : undefined, + ); + return this.probePromise; + } + + async detect(context: RunnerDetectionContext): Promise { + if (!this.config.enabled) { + return { + runner: this.name, + kind: this.kind, + status: 'misconfigured', + executable: this.config.command.executable, + authentication: 'unknown', + capabilities: [], + diagnostics: [ + { + severity: 'error', + code: 'RUNNER_DISABLED', + message: + 'This Gemini profile is disabled in .specbridge/config.json (enabled = false). ' + + 'Enable it explicitly to use the Gemini CLI.', + }, + ], + category: this.category, + capabilitySet: this.declaredCapabilities, + supportLevel: effectiveSupportLevel('production', 'misconfigured'), + networkBacked: false, + }; + } + const probe = await this.probe(context.timeoutMs); + return { + runner: this.name, + kind: this.kind, + status: probe.status, + executable: probe.executable, + ...(probe.version !== undefined ? { version: probe.version } : {}), + authentication: probe.authState, + capabilities: probe.capabilities, + diagnostics: probe.diagnostics, + category: this.category, + capabilitySet: geminiCapabilitySet(probe), + supportLevel: effectiveSupportLevel('production', probe.status), + // The Gemini CLI talks to its provider itself; SpecBridge's own + // transport is a local child process. + networkBacked: false, + }; + } + + executionBoundaryNote(policy: RunnerToolPolicy): string { + if (policy !== 'implementation') { + return 'Gemini plan mode / read-only tool allowlist: repository inspection only; no file writes; YOLO is never used.'; + } + return ( + `Gemini ${this.config.approvalModeForExecution} boundary: repository reads and file edits only; ` + + 'no arbitrary shell access; extensions disabled where supported; YOLO is never used.' + ); + } + + listModels(_context: RunnerDetectionContext): Promise { + return Promise.resolve({ + supported: false, + models: [], + detail: + 'The Gemini CLI has no officially supported local model-listing command that avoids a model request; ' + + 'SpecBridge never guesses provider model names. Configure "model" on the profile explicitly.', + }); + } + + async generateStage( + input: StageGenerationInput, + execution: RunnerExecutionOptions, + ): Promise { + const started = Date.now(); + const probe = await this.probe(); + const unavailable = this.unavailableResult(probe, started); + if (unavailable !== undefined) { + const { report: _report, ...rest } = unavailable; + return rest; + } + const detected = geminiCapabilitySet(probe); + if (!detected.stageGeneration) { + const { report: _refusalReport, ...refusal } = this.capabilityRefusal( + started, + 'authoring needs a proven read-only boundary (plan approval mode or a tool allowlist)', + ['Update the Gemini CLI to a version with plan mode or --allowed-tools.'], + ); + return refusal; + } + let prompt = input.prompt; + if (input.correction !== undefined) { + prompt = + `${input.prompt}\n\n` + + 'Your previous response was not a valid structured result. ' + + `Validation problems: ${input.correction.problems}. ` + + 'Return ONLY one corrected JSON document matching the required schema — no prose, no code fences.'; + } + const plan = buildGeminiInvocation({ + config: this.config, + probe, + prompt, + toolPolicy: input.toolPolicy, + execution, + }); + const processResult = await runGeminiInvocation(plan, this.config, execution); + const mapped = this.mapResult(processResult, plan, started, 'stage'); + const { report, ...rest } = mapped; + const stageReport = report as StageRunnerReport | undefined; + return { ...rest, ...(stageReport !== undefined ? { report: stageReport } : {}) }; + } + + async executeTask( + input: TaskExecutionInput, + execution: RunnerExecutionOptions, + ): Promise { + return this.runTask(input.prompt, execution, {}); + } + + async resumeTask( + input: TaskResumeInput, + execution: RunnerExecutionOptions, + ): Promise { + if (!isExplicitGeminiSessionId(input.sessionId)) { + return { + runner: this.name, + outcome: 'failed', + failureReason: `"${input.sessionId}" is not an explicit Gemini session UUID; "latest", indexes, and ambiguous identifiers are never resumed`, + rawStdout: '', + rawStderr: '', + durationMs: 0, + warnings: [], + resumeSupported: false, + error: runnerError({ + code: 'unsupported_operation', + message: 'Gemini resume requires the explicit session UUID captured from the original run.', + }), + }; + } + return this.runTask(input.prompt, execution, { resumeSessionId: input.sessionId }); + } + + private async runTask( + prompt: string, + execution: RunnerExecutionOptions, + session: { resumeSessionId?: string }, + ): Promise { + const started = Date.now(); + const probe = await this.probe(); + const unavailable = this.unavailableResult(probe, started); + if (unavailable !== undefined) { + const { report: _report, ...rest } = unavailable; + return { ...rest, resumeSupported: false }; + } + const detected = geminiCapabilitySet(probe); + if (!detected.taskExecution) { + const refusal = this.capabilityRefusal( + started, + 'file edits cannot be permitted without also permitting arbitrary shell commands ' + + '(needs the auto_edit approval mode plus a tool allowlist or sandbox); SpecBridge never relaxes ' + + 'this policy and never uses YOLO', + TASK_EXECUTION_REMEDIATION, + ); + const { report: _report, ...rest } = refusal; + return { ...rest, resumeSupported: false }; + } + if (session.resumeSessionId !== undefined && !detected.taskResume) { + const refusal = this.capabilityRefusal( + started, + 'this Gemini CLI version does not support explicit session resume; start a fresh attempt instead', + ['Re-run the task without --resume; a new attempt is recorded append-only.'], + ); + const { report: _report, ...rest } = refusal; + return { ...rest, resumeSupported: false }; + } + const plan = buildGeminiInvocation({ + config: this.config, + probe, + prompt, + toolPolicy: 'implementation', + ...(session.resumeSessionId !== undefined + ? { resumeSessionId: session.resumeSessionId } + : {}), + execution, + }); + const processResult = await runGeminiInvocation(plan, this.config, execution); + const mapped = this.mapResult(processResult, plan, started, 'task'); + + // Resume must continue the SAME session: a different reported identity is + // a discrepancy, never silently accepted as a successful resume. + if ( + session.resumeSessionId !== undefined && + mapped.sessionId !== undefined && + mapped.sessionId !== session.resumeSessionId + ) { + const { report: _report, ...rest } = mapped; + return { + ...rest, + outcome: 'failed', + failureReason: + `the provider continued session ${mapped.sessionId} instead of the requested ` + + `${session.resumeSessionId}; the resume is not claimed as successful`, + error: runnerError({ + code: 'api_error', + message: 'The Gemini session identity changed unexpectedly during resume.', + remediation: [ + 'Inspect the retained events, then start a fresh attempt (run lineage is preserved).', + ], + retryable: false, + providerCode: 'session-mismatch', + }), + resumeSupported: false, + }; + } + + const { report, sessionId, ...rest } = mapped; + const taskReport = report as TaskRunnerReport | undefined; + const effectiveSession = sessionId ?? session.resumeSessionId; + return { + ...rest, + ...(taskReport !== undefined ? { report: taskReport } : {}), + ...(effectiveSession !== undefined ? { sessionId: effectiveSession } : {}), + resumeSupported: + detected.taskResume && + effectiveSession !== undefined && + isExplicitGeminiSessionId(effectiveSession), + }; + } + + /** Minimal bounded structured-output probe (`runner test --network`). */ + async selfTest(execution: RunnerExecutionOptions): Promise { + const probe = await this.probe(); + if (probe.status !== 'available') { + return { ok: false, detail: `gemini-cli is not available (status: ${probe.status})` }; + } + const result = await this.generateStage( + { + specName: 'runner-self-test', + stage: 'requirements', + intent: 'generate', + prompt: + 'This is a connectivity self test. Do not read or modify any file. ' + + 'Reply with exactly one JSON document: {"schemaVersion":"1.0.0","stage":"requirements",' + + '"markdown":"# Self Test","summary":"self test"} and nothing else.\n\nStage to produce: requirements\n', + promptVersion: 'self-test', + toolPolicy: 'read-only', + }, + { ...execution, timeoutMs: Math.min(execution.timeoutMs, 120_000) }, + ); + return { + ok: result.outcome === 'completed' && result.report !== undefined, + detail: + result.outcome === 'completed' + ? 'structured output validated' + : (result.failureReason ?? `self test failed (${result.outcome})`), + ...(result.usage !== undefined ? { usage: result.usage } : {}), + ...(result.process !== undefined ? { process: result.process } : {}), + }; + } + + private capabilityRefusal( + started: number, + reason: string, + remediation: string[], + ): GeminiMappedResult { + return { + runner: this.name, + outcome: 'failed', + failureReason: `the installed Gemini CLI is incompatible with this operation: ${reason}`, + rawStdout: '', + rawStderr: '', + durationMs: Math.max(0, Date.now() - started), + warnings: [], + error: runnerError({ + code: 'runner_incompatible', + message: `The installed Gemini CLI lacks required safety capabilities: ${reason}.`, + remediation, + }), + }; + } + + private unavailableResult(probe: GeminiProbe, started: number): GeminiMappedResult | undefined { + if (probe.status === 'available') return undefined; + const error = + probe.status === 'incompatible' + ? runnerError({ + code: 'runner_incompatible', + message: 'The installed Gemini CLI version lacks required capabilities.', + remediation: ['Run "specbridge runner doctor" for the exact missing capabilities.'], + }) + : probe.status === 'misconfigured' + ? runnerError({ + code: 'runner_disabled', + message: 'This Gemini profile is disabled.', + remediation: ['Enable the profile in .specbridge/config.json explicitly.'], + }) + : probe.status === 'error' + ? runnerError({ + code: 'process_failed', + message: 'The Gemini CLI could not be probed.', + remediation: ['Run "specbridge runner doctor" for details.'], + }) + : runnerError({ + code: 'executable_not_found', + message: `The Gemini CLI executable "${this.config.command.executable}" was not found.`, + remediation: ['Install the Gemini CLI or fix the profile command.'], + }); + return { + runner: this.name, + outcome: 'failed', + failureReason: `the gemini-cli runner is not available (status: ${probe.status}); run "specbridge runner doctor" for details`, + rawStdout: '', + rawStderr: '', + durationMs: Date.now() - started, + warnings: probe.diagnostics.filter((d) => d.severity === 'error').map((d) => d.message), + error, + }; + } + + /** Map a finished process + machine-readable output to a structured result. */ + private mapResult( + processResult: SafeProcessResult, + plan: GeminiInvocationPlan, + started: number, + reportKind: 'stage' | 'task', + ): GeminiMappedResult { + const warnings: string[] = plan.skippedFlags.map( + (flag) => `flag ${flag} is unsupported by this Gemini CLI version and was skipped`, + ); + + let stream: GeminiEventStream | undefined; + let finalText: string | undefined; + let sessionId: string | undefined; + let usage: RunnerUsage | undefined; + let retainedStdout = processResult.stdout; + + if (plan.outputFormat === 'stream-json') { + stream = parseGeminiEventStream(processResult.stdout); + if (stream.truncated) { + warnings.push('the provider event stream exceeded the retention limit; older events were dropped'); + } + finalText = stream.finalResponse; + sessionId = stream.sessionId; + if (stream.usage !== undefined) { + usage = { + model: this.config.model, + inputTokens: stream.usage.inputTokens, + cachedInputTokens: stream.usage.cachedInputTokens, + outputTokens: stream.usage.outputTokens, + reasoningTokens: stream.usage.reasoningTokens, + requestCount: stream.usage.requestCount, + durationMs: Math.max(0, processResult.observation.durationMs), + }; + } + // Parsing already happened on the pristine stream; the RETAINED bytes + // carry only safe status metadata for thought items. + retainedStdout = redactGeminiStdoutForRetention(processResult.stdout); + } else { + const envelope = geminiJsonEnvelopeSchema.safeParse(safeJson(processResult.stdout)); + if (envelope.success) { + finalText = envelope.data.response; + sessionId = envelope.data.stats?.session_id; + if (envelope.data.stats !== undefined) { + usage = { + model: this.config.model, + inputTokens: envelope.data.stats.input_tokens ?? null, + cachedInputTokens: envelope.data.stats.cached_input_tokens ?? null, + outputTokens: envelope.data.stats.output_tokens ?? null, + reasoningTokens: null, + requestCount: 1, + durationMs: Math.max(0, processResult.observation.durationMs), + }; + } + } + } + + const normalizedEvents = + stream !== undefined + ? normalizeGeminiEvents( + stream, + { runner: this.name, profile: this.name, runId: 'pending', attemptId: 'pending' }, + () => new Date().toISOString(), + ) + : undefined; + + const base = { + runner: this.name, + rawStdout: retainedStdout, + rawStderr: processResult.stderr, + process: processResult.observation, + durationMs: Math.max(0, Date.now() - started), + warnings, + ...(normalizedEvents !== undefined ? { normalizedEvents } : {}), + ...(usage !== undefined ? { usage } : {}), + ...(sessionId !== undefined ? { sessionId } : {}), + }; + + switch (processResult.status) { + case 'timeout': + return { + ...base, + outcome: 'timed-out', + failureReason: processResult.failureReason ?? 'timeout', + error: runnerError({ + code: 'timed_out', + message: 'The Gemini process exceeded the configured timeout and was terminated.', + remediation: ['Increase the profile timeoutMs or narrow the task.'], + }), + }; + case 'cancelled': + return { + ...base, + outcome: 'cancelled', + failureReason: processResult.failureReason ?? 'cancelled', + error: runnerError({ + code: 'cancelled', + message: 'The Gemini process was cancelled and terminated.', + }), + }; + case 'output-limit': + return { + ...base, + outcome: 'failed', + failureReason: processResult.failureReason ?? 'output limit exceeded', + error: runnerError({ + code: 'output_limit_exceeded', + message: 'The Gemini process exceeded the configured output limit and was terminated.', + remediation: ['Raise maxStdoutBytes/maxStderrBytes on the profile if this was legitimate.'], + }), + }; + case 'spawn-failed': + return { + ...base, + outcome: 'failed', + failureReason: processResult.failureReason ?? 'spawn failed', + error: runnerError({ + code: 'executable_not_found', + message: `The Gemini CLI executable could not be started: ${processResult.failureReason ?? 'unknown spawn failure'}.`, + remediation: ['Install the Gemini CLI or fix the profile command.'], + }), + }; + case 'ok': + case 'nonzero-exit': + break; + } + + if (processResult.status === 'nonzero-exit') { + const error = classifyGeminiFailure(processResult.stderr, stream?.errors ?? []); + return { + ...base, + outcome: error.code === 'permission_denied' ? 'permission-denied' : 'failed', + failureReason: `${error.message} (exit ${processResult.observation.exitCode ?? 'unknown'})`, + error, + }; + } + + // Exit 0: require a strict structured final result. Prose around the + // JSON document is NOT accepted — the prompt contract requires JSON-only + // final responses, and the complete text is validated with Zod. JSON is + // never extracted from a substring or a Markdown fence. + if (finalText === undefined || finalText.trim().length === 0) { + return { + ...base, + outcome: 'malformed-output', + failureReason: + stream !== undefined && stream.errors.length > 0 + ? `the provider reported: ${stream.errors[0]}` + : 'the runner returned no final structured result', + error: runnerError({ + code: 'structured_output_invalid', + message: 'The Gemini run produced no final structured result.', + remediation: ['Inspect the retained output in the run directory.'], + }), + }; + } + const parsed = strictJsonParse(finalText); + if (parsed === undefined) { + return { + ...base, + outcome: 'malformed-output', + failureReason: 'the final response is not a bare JSON document (extra prose is not accepted)', + error: runnerError({ + code: 'structured_output_invalid', + message: 'The final Gemini response did not parse as a JSON document.', + }), + ...(reportKind === 'stage' + ? { invalidStructuredOutput: finalText.length > 100_000 ? finalText.slice(0, 100_000) : finalText } + : {}), + }; + } + const schema = reportKind === 'stage' ? stageRunnerReportSchema : taskRunnerReportSchema; + const validated = schema.safeParse(parsed); + if (!validated.success) { + const problems = validated.error.issues + .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`) + .join('; '); + return { + ...base, + outcome: 'malformed-output', + failureReason: `structured result does not match the report schema: ${problems}`, + error: runnerError({ + code: 'structured_output_invalid', + message: 'The final Gemini response did not match the required report schema.', + details: { problems: problems.slice(0, 2000) }, + }), + ...(reportKind === 'stage' + ? { invalidStructuredOutput: finalText.length > 100_000 ? finalText.slice(0, 100_000) : finalText } + : {}), + }; + } + + const report = validated.data as StageRunnerReport | TaskRunnerReport; + const outcome: ExecutionOutcome = + 'outcome' in report ? (report.outcome as ExecutionOutcome) : 'completed'; + return { + ...base, + outcome, + report, + ...(outcome === 'completed' || outcome === 'no-change' + ? {} + : { failureReason: `the agent reported "${outcome}"` }), + }; + } +} + +function safeJson(raw: string): unknown { + const trimmed = raw.trim(); + if (!trimmed.startsWith('{')) return undefined; + try { + return JSON.parse(trimmed); + } catch { + return undefined; + } +} + +function strictJsonParse(raw: string): unknown { + const trimmed = raw.trim(); + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return undefined; + try { + return JSON.parse(trimmed); + } catch { + return undefined; + } +} diff --git a/packages/runners/src/index.ts b/packages/runners/src/index.ts index cd265a2..6bab10c 100644 --- a/packages/runners/src/index.ts +++ b/packages/runners/src/index.ts @@ -10,6 +10,7 @@ export * from './contracts/usage.js'; export * from './contracts/normalize.js'; export * from './registry/runner-selection.js'; export * from './registry/fallback-policy.js'; +export * from './registry/matrix.js'; export * from './conformance/conformance.js'; export * from './shared/http-client.js'; export { @@ -65,7 +66,56 @@ export { type CodexEvent, type CodexEventStream, } from './codex-cli/events.js'; +export { GeminiCliRunner, classifyGeminiFailure } from './gemini-cli/runner.js'; +export { + probeGemini, + geminiCapabilitySet, + GEMINI_CAPABILITY_PROBES, + GEMINI_DECLARED_CAPABILITIES, + type GeminiProbe, +} from './gemini-cli/detection.js'; +export { + buildGeminiInvocation, + assertNoForbiddenGeminiArguments, + isExplicitGeminiSessionId, + GEMINI_FORBIDDEN_ARGUMENTS, + GEMINI_ALLOWED_APPROVAL_MODES, + GEMINI_READ_ONLY_TOOLS, + GEMINI_EDIT_TOOLS, + GEMINI_FORBIDDEN_TOOLS, + type GeminiInvocationPlan, + type BuildGeminiInvocationInput, +} from './gemini-cli/invocation.js'; +export { + parseGeminiEventStream, + normalizeGeminiEvents, + redactGeminiStdoutForRetention, + geminiJsonEnvelopeSchema, + MAX_RETAINED_GEMINI_EVENTS, + type GeminiEvent, + type GeminiEventStream, + type GeminiJsonEnvelope, +} from './gemini-cli/events.js'; export { OllamaRunner, OLLAMA_DECLARED_CAPABILITIES } from './ollama/runner.js'; +export { + OpenAiCompatibleRunner, + OPENAI_COMPATIBLE_DECLARED_CAPABILITIES, +} from './openai-compatible/runner.js'; +export { + buildOpenAiRequestBody, + parseOpenAiResponse, + redactSecretValue, + weakerStructuredOutputMode, + indicatesStructuredOutputUnsupported, + openAiModelsResponseSchema, + type OpenAiChatMessage, + type OpenAiRequestInput, + type ParsedProviderResponse, +} from './openai-compatible/client.js'; +export { + AntigravityCliRunner, + ANTIGRAVITY_DECLARED_CAPABILITIES, +} from './antigravity-cli/runner.js'; export { fetchOllamaVersion, fetchOllamaModels, diff --git a/packages/runners/src/openai-compatible/client.ts b/packages/runners/src/openai-compatible/client.ts new file mode 100644 index 0000000..88fb7ea --- /dev/null +++ b/packages/runners/src/openai-compatible/client.ts @@ -0,0 +1,253 @@ +import { z } from 'zod'; +import type { OpenAiCompatibleProfileConfig } from '@specbridge/core'; + +/** + * OpenAI-compatible request/response shapes (v0.6.1) — AUTHORING ONLY. + * + * Two API styles are supported: `chat-completions` (POST /chat/completions) + * and `responses` (POST /responses). Not every compatible endpoint + * implements both, or implements native structured output — the profile + * declares what its endpoint supports, and nothing is probed by paid + * inference. + * + * Credential rule: the configuration holds only an environment-variable + * NAME. The value is read at request time, sent as the Authorization + * header, and redacted from anything retained. + */ + +export interface OpenAiChatMessage { + role: 'user' | 'assistant' | 'system'; + content: string; +} + +export interface OpenAiRequestInput { + model: string; + messages: OpenAiChatMessage[]; + temperature: number; + /** Structured-output mode actually used for this request. */ + structuredOutput: 'json-schema' | 'json-object' | 'strict-json-prompt'; + /** JSON Schema for the report (json-schema mode only). */ + jsonSchema: Record; + schemaName: string; +} + +/** Build the POST body for the configured API style. */ +export function buildOpenAiRequestBody( + style: OpenAiCompatibleProfileConfig['apiStyle'], + input: OpenAiRequestInput, +): Record { + if (style === 'chat-completions') { + const responseFormat = + input.structuredOutput === 'json-schema' + ? { + response_format: { + type: 'json_schema', + json_schema: { name: input.schemaName, strict: true, schema: input.jsonSchema }, + }, + } + : input.structuredOutput === 'json-object' + ? { response_format: { type: 'json_object' } } + : {}; + return { + model: input.model, + messages: input.messages, + temperature: input.temperature, + stream: false, + ...responseFormat, + }; + } + // responses style + const textFormat = + input.structuredOutput === 'json-schema' + ? { + text: { + format: { + type: 'json_schema', + name: input.schemaName, + strict: true, + schema: input.jsonSchema, + }, + }, + } + : input.structuredOutput === 'json-object' + ? { text: { format: { type: 'json_object' } } } + : {}; + return { + model: input.model, + input: input.messages.map((message) => ({ + role: message.role, + content: [{ type: message.role === 'assistant' ? 'output_text' : 'input_text', text: message.content }], + })), + temperature: input.temperature, + stream: false, + ...textFormat, + }; +} + +const chatCompletionResponseSchema = z + .object({ + choices: z + .array( + z + .object({ + message: z.object({ content: z.string().nullable() }).passthrough(), + finish_reason: z.string().nullable().optional(), + }) + .passthrough(), + ) + .min(1), + model: z.string().optional(), + usage: z + .object({ + prompt_tokens: z.number().optional(), + completion_tokens: z.number().optional(), + prompt_tokens_details: z.object({ cached_tokens: z.number().optional() }).passthrough().optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +const responsesResponseSchema = z + .object({ + output: z + .array( + z + .object({ + type: z.string().optional(), + content: z + .array(z.object({ type: z.string().optional(), text: z.string().optional() }).passthrough()) + .optional(), + }) + .passthrough(), + ) + .optional(), + output_text: z.string().optional(), + model: z.string().optional(), + usage: z + .object({ + input_tokens: z.number().optional(), + output_tokens: z.number().optional(), + input_tokens_details: z.object({ cached_tokens: z.number().optional() }).passthrough().optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +export interface ParsedProviderResponse { + /** The complete assistant text (validated as a whole; never substring-extracted). */ + text?: string; + usage?: { + inputTokens: number | null; + cachedInputTokens: number | null; + outputTokens: number | null; + }; + model?: string; + /** Why no text could be extracted (safe, bounded). */ + problem?: string; +} + +/** Extract the assistant text and usage from a provider response body. */ +export function parseOpenAiResponse( + style: OpenAiCompatibleProfileConfig['apiStyle'], + bodyText: string, +): ParsedProviderResponse { + let parsed: unknown; + try { + parsed = JSON.parse(bodyText); + } catch { + return { problem: 'the endpoint response is not valid JSON' }; + } + if (style === 'chat-completions') { + const result = chatCompletionResponseSchema.safeParse(parsed); + if (!result.success) { + return { problem: 'the endpoint response does not match the chat-completions shape' }; + } + const content = result.data.choices[0]?.message.content; + return { + ...(content !== null && content !== undefined ? { text: content } : { problem: 'the response carries no message content' }), + ...(result.data.model !== undefined ? { model: result.data.model } : {}), + ...(result.data.usage !== undefined + ? { + usage: { + inputTokens: result.data.usage.prompt_tokens ?? null, + cachedInputTokens: result.data.usage.prompt_tokens_details?.cached_tokens ?? null, + outputTokens: result.data.usage.completion_tokens ?? null, + }, + } + : {}), + }; + } + const result = responsesResponseSchema.safeParse(parsed); + if (!result.success) { + return { problem: 'the endpoint response does not match the responses shape' }; + } + let text: string | undefined = result.data.output_text; + if (text === undefined && result.data.output !== undefined) { + const parts: string[] = []; + for (const item of result.data.output) { + if (item.type !== undefined && item.type !== 'message') continue; + for (const content of item.content ?? []) { + if ((content.type === undefined || content.type === 'output_text') && content.text !== undefined) { + parts.push(content.text); + } + } + } + if (parts.length > 0) text = parts.join(''); + } + return { + ...(text !== undefined ? { text } : { problem: 'the response carries no output text' }), + ...(result.data.model !== undefined ? { model: result.data.model } : {}), + ...(result.data.usage !== undefined + ? { + usage: { + inputTokens: result.data.usage.input_tokens ?? null, + cachedInputTokens: result.data.usage.input_tokens_details?.cached_tokens ?? null, + outputTokens: result.data.usage.output_tokens ?? null, + }, + } + : {}), + }; +} + +export const openAiModelsResponseSchema = z + .object({ + data: z + .array( + z + .object({ + id: z.string(), + owned_by: z.string().optional(), + created: z.number().optional(), + }) + .passthrough(), + ) + .default([]), + }) + .passthrough(); + +/** True when the error body indicates the structured-output mode is unsupported. */ +export function indicatesStructuredOutputUnsupported(status: number | undefined, bodyExcerpt: string | undefined): boolean { + if (status !== 400 && status !== 422) return false; + const text = (bodyExcerpt ?? '').toLowerCase(); + return /response_format|json_schema|json schema|structured output|text\.format/.test(text); +} + +/** + * Redact every occurrence of a secret value from retained text. Applied to + * anything persisted or surfaced (raw bodies, error excerpts, diagnostics). + */ +export function redactSecretValue(text: string, secret: string | undefined): string { + if (secret === undefined || secret.length === 0) return text; + return text.split(secret).join(''); +} + +/** The next weaker structured-output mode for the EXPLICIT fallback option. */ +export function weakerStructuredOutputMode( + mode: 'json-schema' | 'json-object' | 'strict-json-prompt', +): 'json-object' | 'strict-json-prompt' | undefined { + if (mode === 'json-schema') return 'json-object'; + if (mode === 'json-object') return 'strict-json-prompt'; + return undefined; +} diff --git a/packages/runners/src/openai-compatible/runner.ts b/packages/runners/src/openai-compatible/runner.ts new file mode 100644 index 0000000..7325f0a --- /dev/null +++ b/packages/runners/src/openai-compatible/runner.ts @@ -0,0 +1,758 @@ +import type { + Diagnostic, + OpenAiCompatibleProfileConfig, + StageRunnerReport, +} from '@specbridge/core'; +import { + STAGE_RUNNER_REPORT_JSON_SCHEMA, + openAiCompatibleProfileSchema, + stageRunnerReportSchema, + validateRunnerBaseUrl, +} from '@specbridge/core'; +import type { + AgentRunner, + RunnerCapability, + RunnerDetectionContext, + RunnerDetectionResult, + RunnerExecutionOptions, + RunnerModelListResult, + RunnerSelfTestResult, + RunnerToolPolicy, + StageGenerationInput, + StageGenerationResult, + TaskExecutionInput, + TaskExecutionResult, +} from '../contract.js'; +import type { RunnerCapabilitySet } from '../contracts/capabilities.js'; +import { capabilitySet } from '../contracts/capabilities.js'; +import type { NormalizedRunnerError } from '../contracts/errors.js'; +import { runnerError } from '../contracts/errors.js'; +import type { RunnerUsage } from '../contracts/usage.js'; +import type { SafeHttpResult } from '../shared/http-client.js'; +import { safeHttpRequest } from '../shared/http-client.js'; +import type { OpenAiChatMessage } from './client.js'; +import { + buildOpenAiRequestBody, + indicatesStructuredOutputUnsupported, + openAiModelsResponseSchema, + parseOpenAiResponse, + redactSecretValue, + weakerStructuredOutputMode, +} from './client.js'; + +/** + * OpenAI-compatible model-API runner (v0.6.1) — AUTHORING ONLY. + * + * Scope: stage generation, stage refinement, model listing (only when the + * profile declares a supported /models endpoint), schema-validated + * structured output. Explicitly unsupported: task execution, task resume, + * repository modification, autonomous tool loops, arbitrary function + * calling, shell execution. There is no coding agent wrapped around the + * generic API — the adapter sends bounded requests and returns candidates + * that SpecBridge validates and applies (or refuses) itself. + * + * Credential rule: the profile stores an environment-variable NAME only. + * The value is read at request time from exactly that variable, sent as + * the Authorization header (never across origins), redacted from every + * retained byte, and never stored. + */ + +export const OPENAI_COMPATIBLE_DECLARED_CAPABILITIES: RunnerCapabilitySet = capabilitySet([ + 'stageGeneration', + 'stageRefinement', + 'structuredFinalOutput', + 'usageReporting', + 'localOnly', + 'supportsSystemPrompt', + 'supportsJsonSchema', + 'supportsCancellation', +]); + +type StructuredOutputMode = OpenAiCompatibleProfileConfig['structuredOutput']; + +interface OpenAiFailure { + outcome: 'failed' | 'timed-out' | 'cancelled' | 'malformed-output'; + failureReason: string; + error: NormalizedRunnerError; +} + +function classifyHttpFailure( + result: Extract, + redact: (text: string) => string, +): OpenAiFailure { + switch (result.kind) { + case 'timeout': + return { + outcome: 'timed-out', + failureReason: result.detail, + error: runnerError({ code: 'timed_out', message: `The endpoint request timed out: ${result.detail}.` }), + }; + case 'cancelled': + return { + outcome: 'cancelled', + failureReason: result.detail, + error: runnerError({ code: 'cancelled', message: 'The endpoint request was cancelled.' }), + }; + case 'response-too-large': + return { + outcome: 'failed', + failureReason: result.detail, + error: runnerError({ + code: 'output_limit_exceeded', + message: 'The endpoint response exceeded the configured size limit.', + remediation: ['Raise maximumOutputBytes on the profile if this was legitimate.'], + }), + }; + case 'redirect-rejected': + return { + outcome: 'failed', + failureReason: result.detail, + error: runnerError({ + code: 'endpoint_unreachable', + message: `The endpoint redirect was refused: ${result.detail}.`, + remediation: ['Configure the final endpoint URL directly.'], + retryable: false, + }), + }; + case 'invalid-content-type': + return { + outcome: 'malformed-output', + failureReason: result.detail, + error: runnerError({ + code: 'api_error', + message: 'The endpoint returned an unexpected content type.', + retryable: false, + }), + }; + case 'http-error': { + const status = result.status ?? 0; + const excerpt = redact(result.bodyExcerpt ?? '').toLowerCase(); + if (status === 401 || status === 403) { + return { + outcome: 'failed', + failureReason: result.detail, + error: runnerError({ + code: 'authentication_required', + message: `The endpoint refused the request (HTTP ${status}).`, + remediation: [ + 'Set the configured API-key environment variable before running (SpecBridge never stores key values).', + ], + providerCode: String(status), + }), + }; + } + if (status === 429 && /insufficient_quota|quota|billing/.test(excerpt)) { + return { + outcome: 'failed', + failureReason: result.detail, + error: runnerError({ + code: 'quota_exceeded', + message: 'The endpoint reported an exhausted quota.', + remediation: ['Check your provider plan and usage, then retry explicitly.'], + providerCode: '429', + }), + }; + } + if (status === 429) { + return { + outcome: 'failed', + failureReason: result.detail, + error: runnerError({ + code: 'rate_limited', + message: 'The endpoint reported a rate limit (HTTP 429).', + providerCode: '429', + }), + }; + } + if (status === 404 && /model/.test(excerpt)) { + return { + outcome: 'failed', + failureReason: result.detail, + error: runnerError({ + code: 'model_not_found', + message: 'The configured model is not available on the endpoint.', + remediation: ['List models with "specbridge runner models " (when the endpoint supports it).'], + providerCode: '404', + }), + }; + } + return { + outcome: 'failed', + failureReason: result.detail, + error: runnerError({ + code: 'api_error', + message: `The endpoint answered HTTP ${status}.`, + providerCode: String(status), + retryable: status >= 500, + }), + }; + } + case 'unreachable': + return { + outcome: 'failed', + failureReason: result.detail, + error: runnerError({ + code: 'endpoint_unreachable', + message: 'The endpoint could not be reached.', + remediation: ['Start the local server or fix the profile baseUrl.'], + }), + }; + } +} + +export class OpenAiCompatibleRunner implements AgentRunner { + readonly name = 'openai-compatible'; + readonly kind = 'openai-compatible'; + readonly category = 'model-api'; + readonly declaredCapabilities: RunnerCapabilitySet; + /** Orchestration may perform ONE structured-output correction retry. */ + readonly supportsStructuredOutputCorrection = true; + private readonly config: OpenAiCompatibleProfileConfig; + + constructor(config?: Partial) { + this.config = openAiCompatibleProfileSchema.parse({ + runner: 'openai-compatible', + ...(config ?? {}), + }); + this.declaredCapabilities = { + ...OPENAI_COMPATIBLE_DECLARED_CAPABILITIES, + // Native JSON Schema constraining is a per-endpoint capability the + // profile declares through its structured-output mode. + supportsJsonSchema: this.config.structuredOutput === 'json-schema', + }; + } + + get baseUrl(): string { + return this.config.baseUrl; + } + + private urlValidation(): ReturnType { + return validateRunnerBaseUrl(this.config.baseUrl, { + allowInsecureHttp: this.config.allowInsecureHttp, + }); + } + + /** The API-key VALUE, read at request time only. Never stored, never logged. */ + private apiKeyValue(): string | undefined { + const variable = this.config.apiKeyEnvironmentVariable; + if (variable === null) return undefined; + const value = process.env[variable]; + return value !== undefined && value.length > 0 ? value : undefined; + } + + private redact(text: string): string { + return redactSecretValue(text, this.apiKeyValue()); + } + + private requestHeaders(): Record { + const headers: Record = { ...this.config.headers }; + const key = this.apiKeyValue(); + if (key !== undefined) headers['authorization'] = `Bearer ${key}`; + return headers; + } + + private endpointUrl(pathSuffix: string): string { + return `${this.config.baseUrl.replace(/\/+$/, '')}${pathSuffix}`; + } + + private profileCapabilities(loopback: boolean): RunnerCapabilitySet { + return { + ...this.declaredCapabilities, + localOnly: loopback, + requiresNetwork: !loopback, + }; + } + + async detect(context: RunnerDetectionContext): Promise { + const diagnostics: Diagnostic[] = []; + const url = this.urlValidation(); + const capabilities: RunnerCapability[] = []; + const keyVariable = this.config.apiKeyEnvironmentVariable; + const keyConfigured = keyVariable !== null; + const keyPresent = this.apiKeyValue() !== undefined; + const authentication = !keyConfigured ? 'not-applicable' : keyPresent ? 'unknown' : 'unauthenticated'; + const base: Pick< + RunnerDetectionResult, + 'runner' | 'kind' | 'executable' | 'authentication' | 'category' | 'capabilitySet' | 'networkBacked' + > = { + runner: this.name, + kind: 'openai-compatible', + executable: this.config.baseUrl, + authentication, + category: this.category, + capabilitySet: this.profileCapabilities(url.loopback), + networkBacked: !url.loopback, + }; + + if (!this.config.enabled) { + diagnostics.push({ + severity: 'error', + code: 'RUNNER_DISABLED', + message: + 'This openai-compatible profile is disabled in .specbridge/config.json (enabled = false). ' + + 'Enable it explicitly to use the endpoint for spec authoring.', + }); + return { ...base, status: 'misconfigured', capabilities, diagnostics, supportLevel: 'production' }; + } + if (!url.ok) { + for (const problem of url.problems) { + diagnostics.push({ severity: 'error', code: 'RUNNER_ENDPOINT_INVALID', message: `baseUrl: ${problem}` }); + } + return { ...base, status: 'misconfigured', capabilities, diagnostics, supportLevel: 'production' }; + } + if (!url.loopback) { + diagnostics.push({ + severity: 'warning', + code: 'RUNNER_NETWORK_BACKED', + message: `The endpoint ${url.hostname ?? ''} is not loopback: requests leave this machine (network-backed). Explicit selection is required.`, + }); + if (this.config.allowInsecureHttp && url.protocol === 'http:') { + diagnostics.push({ + severity: 'warning', + code: 'RUNNER_INSECURE_HTTP', + message: + 'INSECURE: allowInsecureHttp permits plain HTTP to a non-loopback endpoint. ' + + 'Prompts and responses travel unencrypted; use HTTPS outside private development networks.', + }); + } + } + if (keyConfigured && !keyPresent) { + diagnostics.push({ + severity: 'error', + code: 'RUNNER_API_KEY_VARIABLE_UNSET', + message: + `The configured API-key environment variable "${keyVariable ?? ''}" is not set. ` + + 'Export it before running (SpecBridge stores only the variable NAME, never a value).', + }); + } + + capabilities.push({ + id: 'structured-output', + label: `Structured output (${this.config.structuredOutput})`, + available: true, + required: true, + detail: 'the complete response is validated by SpecBridge with a bounded correction retry', + }); + capabilities.push({ + id: 'api-style', + label: `API style: ${this.config.apiStyle}`, + available: true, + required: true, + }); + + // Doctor never sends an inference request. A safe non-inference request + // (GET /models) runs only when the profile explicitly declares the + // endpoint supports it. + if (this.config.modelsEndpoint) { + const signal = context.timeoutMs !== undefined ? AbortSignal.timeout(context.timeoutMs) : undefined; + const models = await safeHttpRequest({ + method: 'GET', + url: this.endpointUrl('/models'), + timeoutMs: Math.min(context.timeoutMs ?? 15_000, 15_000), + maxResponseBytes: 1024 * 1024, + headers: this.requestHeaders(), + maxRedirects: 3, + ...(signal !== undefined ? { signal } : {}), + }); + if (!models.ok) { + if (models.kind === 'http-error' && (models.status === 401 || models.status === 403)) { + diagnostics.push({ + severity: 'error', + code: 'RUNNER_UNAUTHENTICATED', + message: `The endpoint refused GET /models (HTTP ${models.status}). Configure and export the API-key variable yourself.`, + }); + capabilities.push({ id: 'endpoint', label: 'Endpoint reachable', available: true, required: true }); + return { ...base, authentication: 'unauthenticated', status: 'unauthenticated', capabilities, diagnostics, supportLevel: 'production' }; + } + diagnostics.push({ + severity: 'error', + code: 'RUNNER_ENDPOINT_UNREACHABLE', + message: `The endpoint is unreachable: ${this.redact(models.detail)}. Start the server or fix the profile baseUrl.`, + }); + capabilities.push({ id: 'endpoint', label: 'Endpoint reachable', available: false, required: true }); + return { ...base, status: 'unavailable', capabilities, diagnostics, supportLevel: 'unavailable' }; + } + capabilities.push({ id: 'endpoint', label: 'Endpoint reachable (GET /models)', available: true, required: true }); + capabilities.push({ id: 'model-list', label: 'Model listing', available: true, required: false }); + } else { + diagnostics.push({ + severity: 'info', + code: 'RUNNER_REACHABILITY_NOT_PROBED', + message: + 'Endpoint reachability was not probed: the profile declares no safe non-inference request ' + + '(set "modelsEndpoint": true when the endpoint supports GET /models). ' + + 'Use "specbridge runner test --network" for a bounded inference probe.', + }); + } + + let status: RunnerDetectionResult['status'] = 'available'; + if (this.config.model === null) { + status = 'misconfigured'; + diagnostics.push({ + severity: 'error', + code: 'RUNNER_MODEL_NOT_CONFIGURED', + message: + 'No model is configured for this profile. SpecBridge never selects or guesses a model — ' + + 'set "model" explicitly (use "specbridge runner models " when the endpoint lists models).', + }); + capabilities.push({ id: 'configured-model', label: 'Configured model present', available: false, required: true }); + } else { + capabilities.push({ id: 'configured-model', label: 'Configured model present', available: true, required: true }); + } + if (keyConfigured && !keyPresent) status = 'misconfigured'; + + return { ...base, status, capabilities, diagnostics, supportLevel: 'production' }; + } + + executionBoundaryNote(_policy: RunnerToolPolicy): string { + return ( + 'Model API (authoring only): no repository access, no tools, no shell, no source modification; ' + + 'the returned document is an unapproved candidate.' + ); + } + + async listModels(context: RunnerDetectionContext): Promise { + if (!this.config.modelsEndpoint) { + return { + supported: false, + models: [], + detail: + 'This profile does not declare a supported /models endpoint (set "modelsEndpoint": true when it exists). ' + + 'SpecBridge never guesses model names and never lists models by inference.', + }; + } + const url = this.urlValidation(); + if (!url.ok) { + return { supported: true, models: [], detail: `baseUrl invalid: ${url.problems.join('; ')}` }; + } + const signal = context.timeoutMs !== undefined ? AbortSignal.timeout(context.timeoutMs) : undefined; + const result = await safeHttpRequest({ + method: 'GET', + url: this.endpointUrl('/models'), + timeoutMs: Math.min(context.timeoutMs ?? 15_000, 15_000), + maxResponseBytes: 1024 * 1024, + expectJson: true, + headers: this.requestHeaders(), + maxRedirects: 3, + ...(signal !== undefined ? { signal } : {}), + }); + if (!result.ok) { + return { supported: true, models: [], detail: `model listing failed: ${this.redact(result.detail)}` }; + } + const parsed = openAiModelsResponseSchema.safeParse(safeJson(result.bodyText)); + if (!parsed.success) { + return { supported: true, models: [], detail: 'the endpoint returned an unexpected model list shape' }; + } + return { + supported: true, + // Only fields the endpoint actually reports — capabilities are never + // inferred from a model name or provider branding. + models: parsed.data.data.map((model) => ({ + name: model.id, + ...(model.owned_by !== undefined ? { family: model.owned_by } : {}), + ...(model.created !== undefined + ? { modifiedAt: new Date(model.created * 1000).toISOString() } + : {}), + location: url.loopback ? ('local' as const) : ('remote' as const), + })), + }; + } + + async generateStage( + input: StageGenerationInput, + execution: RunnerExecutionOptions, + ): Promise { + const started = Date.now(); + const failure = (problem: OpenAiFailure, rawStdout = ''): StageGenerationResult => ({ + runner: this.name, + outcome: problem.outcome, + failureReason: problem.failureReason, + rawStdout, + rawStderr: '', + durationMs: Math.max(0, Date.now() - started), + warnings: [], + error: problem.error, + cost: { currency: null, amount: null, source: 'unavailable' }, + }); + + const url = this.urlValidation(); + if (!url.ok) { + return failure({ + outcome: 'failed', + failureReason: `the profile baseUrl is invalid: ${url.problems.join('; ')}`, + error: runnerError({ + code: 'invalid_configuration', + message: `The openai-compatible profile baseUrl is invalid: ${url.problems.join('; ')}`, + }), + }); + } + const model = execution.model ?? this.config.model; + if (model === null || model === undefined) { + return failure({ + outcome: 'failed', + failureReason: 'no model is configured for this profile', + error: runnerError({ + code: 'invalid_configuration', + message: 'No model is configured; SpecBridge never selects one automatically.', + remediation: ['Set "model" on the profile explicitly.'], + }), + }); + } + if (input.prompt.length > this.config.maximumInputCharacters) { + return failure({ + outcome: 'failed', + failureReason: `the assembled prompt (${input.prompt.length} characters) exceeds maximumInputCharacters (${this.config.maximumInputCharacters})`, + error: runnerError({ + code: 'invalid_configuration', + message: 'The authoring input exceeds the configured size limit for this profile.', + remediation: ['Reduce the spec/steering context or raise maximumInputCharacters explicitly.'], + }), + }); + } + + const messages: OpenAiChatMessage[] = [{ role: 'user', content: input.prompt }]; + if (input.correction !== undefined) { + messages.push( + { role: 'assistant', content: input.correction.previousOutput }, + { + role: 'user', + content: + 'Your previous response was not a valid structured result. ' + + `Validation problems: ${input.correction.problems}. ` + + 'Return ONLY one corrected JSON document matching the required schema — no prose, no code fences.', + }, + ); + } + + const attempt = await this.requestOnce(model, messages, this.config.structuredOutput, execution); + if (!attempt.ok) { + // An unsupported structured-output mode falls back ONLY when the + // profile explicitly allows it — never silently. + if ( + attempt.unsupportedMode && + this.config.allowStructuredOutputFallback && + weakerStructuredOutputMode(this.config.structuredOutput) !== undefined + ) { + const weaker = weakerStructuredOutputMode(this.config.structuredOutput) as StructuredOutputMode; + const retry = await this.requestOnce(model, messages, weaker, execution); + if (retry.ok) { + const result = this.mapCompleted(retry.body, retry.mode, model, started); + result.warnings.push( + `the endpoint rejected structured-output mode "${this.config.structuredOutput}"; ` + + `the profile explicitly allows fallback and "${weaker}" was used`, + ); + return result; + } + return failure(retry.failure, retry.retained ?? ''); + } + if (attempt.unsupportedMode) { + return failure( + { + outcome: 'failed', + failureReason: `the endpoint does not support structured-output mode "${this.config.structuredOutput}"`, + error: runnerError({ + code: 'structured_output_unsupported', + message: `The endpoint rejected structured-output mode "${this.config.structuredOutput}".`, + remediation: [ + 'Configure a mode the endpoint supports (json-object or strict-json-prompt), or set ' + + '"allowStructuredOutputFallback": true to permit the explicit downgrade.', + ], + }), + }, + attempt.retained ?? '', + ); + } + return failure(attempt.failure, attempt.retained ?? ''); + } + return this.mapCompleted(attempt.body, attempt.mode, model, started); + } + + private async requestOnce( + model: string, + messages: OpenAiChatMessage[], + mode: StructuredOutputMode, + execution: RunnerExecutionOptions, + ): Promise< + | { ok: true; body: string; mode: StructuredOutputMode } + | { ok: false; failure: OpenAiFailure; unsupportedMode: boolean; retained?: string } + > { + const path = this.config.apiStyle === 'chat-completions' ? '/chat/completions' : '/responses'; + const result = await safeHttpRequest({ + method: 'POST', + url: this.endpointUrl(path), + body: buildOpenAiRequestBody(this.config.apiStyle, { + model, + messages, + temperature: this.config.temperature, + structuredOutput: mode, + jsonSchema: STAGE_RUNNER_REPORT_JSON_SCHEMA, + schemaName: 'stage_runner_report', + }), + timeoutMs: execution.timeoutMs, + maxResponseBytes: this.config.maximumOutputBytes, + expectJson: true, + headers: this.requestHeaders(), + maxRedirects: 3, + ...(execution.signal !== undefined ? { signal: execution.signal } : {}), + }); + if (!result.ok) { + const unsupportedMode = + mode !== 'strict-json-prompt' && + result.kind === 'http-error' && + indicatesStructuredOutputUnsupported(result.status, result.bodyExcerpt); + return { + ok: false, + failure: classifyHttpFailure(result, (text) => this.redact(text)), + unsupportedMode, + ...(result.kind === 'http-error' && result.bodyExcerpt !== undefined + ? { retained: this.redact(result.bodyExcerpt) } + : {}), + }; + } + return { ok: true, body: result.bodyText, mode }; + } + + private mapCompleted( + bodyText: string, + mode: StructuredOutputMode, + model: string, + started: number, + ): StageGenerationResult { + const retained = this.redact(bodyText); + const parsed = parseOpenAiResponse(this.config.apiStyle, bodyText); + const usage: RunnerUsage = { + model: parsed.model ?? model, + inputTokens: parsed.usage?.inputTokens ?? null, + cachedInputTokens: parsed.usage?.cachedInputTokens ?? null, + outputTokens: parsed.usage?.outputTokens ?? null, + reasoningTokens: null, + requestCount: 1, + durationMs: Math.max(0, Date.now() - started), + }; + const base = { + runner: this.name, + rawStdout: retained, + rawStderr: '', + durationMs: Math.max(0, Date.now() - started), + warnings: [] as string[], + usage, + cost: { currency: null, amount: null, source: 'unavailable' as const }, + }; + if (parsed.text === undefined) { + return { + ...base, + outcome: 'malformed-output', + failureReason: parsed.problem ?? 'the endpoint returned no usable content', + error: runnerError({ + code: 'api_error', + message: `The endpoint response could not be used: ${parsed.problem ?? 'no content'}.`, + retryable: false, + }), + }; + } + // Strict structured output for EVERY mode: the complete response text + // must BE one JSON document. Markdown fences, prose, and substring + // extraction are never accepted, whatever the mode. + const candidate = strictJsonParse(parsed.text); + const report = candidate === undefined ? undefined : stageRunnerReportSchema.safeParse(candidate); + if (report === undefined || !report.success) { + const problems = + report !== undefined && !report.success + ? report.error.issues + .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`) + .join('; ') + : 'the response content is not a bare JSON document'; + return { + ...base, + outcome: 'malformed-output', + failureReason: `structured output invalid (${mode}): ${problems}`, + error: runnerError({ + code: 'structured_output_invalid', + message: 'The model response did not validate against the stage report schema.', + details: { problems: problems.slice(0, 2000) }, + }), + // Retained for inspection and the bounded correction retry; never + // applied. (Bounded: the transport already enforces response limits.) + invalidStructuredOutput: + parsed.text.length > 100_000 ? parsed.text.slice(0, 100_000) : parsed.text, + }; + } + return { + ...base, + outcome: 'completed', + report: report.data as StageRunnerReport, + }; + } + + /** + * Task execution is NOT a capability of a model-API runner. Selection + * rejects the operation before any request; this defensive implementation + * exists only to satisfy the AgentRunner interface and performs no HTTP + * request and no repository access. + */ + executeTask( + _input: TaskExecutionInput, + _execution: RunnerExecutionOptions, + ): Promise { + return Promise.resolve({ + runner: this.name, + outcome: 'failed', + failureReason: + 'the openai-compatible runner is authoring-only: it cannot execute implementation tasks and never modifies repository files', + rawStdout: '', + rawStderr: '', + durationMs: 0, + warnings: [], + resumeSupported: false, + error: runnerError({ + code: 'unsupported_operation', + message: 'Model API runners cannot execute implementation tasks.', + remediation: ['Use an agent CLI profile (claude-code or codex-cli) for task execution.'], + }), + cost: { currency: null, amount: null, source: 'unavailable' }, + }); + } + + /** Minimal bounded structured-output probe (`runner test --network`). */ + async selfTest(execution: RunnerExecutionOptions): Promise { + const result = await this.generateStage( + { + specName: 'runner-self-test', + stage: 'requirements', + intent: 'generate', + prompt: + 'This is a connectivity self test. Reply with exactly one JSON document: ' + + '{"schemaVersion":"1.0.0","stage":"requirements","markdown":"# Self Test","summary":"self test"} and nothing else.', + promptVersion: 'self-test', + toolPolicy: 'read-only', + }, + { ...execution, timeoutMs: Math.min(execution.timeoutMs, 60_000) }, + ); + return { + ok: result.outcome === 'completed' && result.report !== undefined, + detail: + result.outcome === 'completed' + ? 'structured output validated' + : (result.failureReason ?? `self test failed (${result.outcome})`), + ...(result.usage !== undefined ? { usage: result.usage } : {}), + }; + } +} + +function safeJson(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} + +function strictJsonParse(raw: string): unknown { + const trimmed = raw.trim(); + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return undefined; + try { + return JSON.parse(trimmed); + } catch { + return undefined; + } +} diff --git a/packages/runners/src/registry.ts b/packages/runners/src/registry.ts index 50aff5b..bfbd439 100644 --- a/packages/runners/src/registry.ts +++ b/packages/runners/src/registry.ts @@ -4,7 +4,10 @@ import type { AgentRunner } from './contract.js'; import { MockRunner } from './mock-runner.js'; import { ClaudeCodeRunner } from './claude-code/runner.js'; import { CodexCliRunner } from './codex-cli/runner.js'; +import { GeminiCliRunner } from './gemini-cli/runner.js'; import { OllamaRunner } from './ollama/runner.js'; +import { OpenAiCompatibleRunner } from './openai-compatible/runner.js'; +import { AntigravityCliRunner } from './antigravity-cli/runner.js'; /** * Profile-based runner registry (v0.6). @@ -79,15 +82,21 @@ export class RunnerRegistry { } } -/** Instantiate the production adapter for a profile configuration. */ +/** Instantiate the registered adapter for a profile configuration. */ export function instantiateRunner(config: RunnerProfileConfig): AgentRunner { switch (config.runner) { case 'claude-code': return new ClaudeCodeRunner(config); case 'codex-cli': return new CodexCliRunner(config); + case 'gemini-cli': + return new GeminiCliRunner(config); case 'ollama': return new OllamaRunner(config); + case 'openai-compatible': + return new OpenAiCompatibleRunner(config); + case 'antigravity-cli': + return new AntigravityCliRunner(config); case 'mock': return new MockRunner(config); } @@ -96,8 +105,7 @@ export function instantiateRunner(config: RunnerProfileConfig): AgentRunner { /** * Build the default registry from the resolved configuration: one profile * entry per configured profile (built-ins are always present; new-provider - * built-ins default to disabled). Deferred providers (Gemini, Antigravity, - * OpenAI-compatible, …) are NOT registered — they exist only on the roadmap. + * built-ins default to disabled and are never selected implicitly). */ export function createDefaultRunnerRegistry(config?: AgentConfig): RunnerRegistry { const resolved = config ?? defaultResolvedAgentConfig(); diff --git a/packages/runners/src/registry/matrix.ts b/packages/runners/src/registry/matrix.ts new file mode 100644 index 0000000..46b1c48 --- /dev/null +++ b/packages/runners/src/registry/matrix.ts @@ -0,0 +1,103 @@ +import type { RegisteredRunnerProfile } from '../registry.js'; +import type { RunnerCategory, RunnerSupportLevel } from '../contracts/capabilities.js'; +import { profileModel, profileOperations, profileTransport } from './runner-selection.js'; + +/** + * The authoritative runner capability matrix (v0.6.1). + * + * Generated from registered runner metadata (declared capabilities and + * adapter-declared support levels) — the SAME source feeds the CLI + * (`runner matrix`), the MCP `runner_matrix` tool, the README, and the + * documentation. There is deliberately no second matrix implementation. + */ + +export interface RunnerMatrixRow { + profile: string; + implementation: string; + category: RunnerCategory; + /** Adapter-declared support level (detection may downgrade at run time). */ + support: RunnerSupportLevel; + enabled: boolean; + author: boolean; + refine: boolean; + execute: boolean; + resume: boolean; + local: boolean; +} + +/** Build matrix rows from registered profiles, in registration order. */ +export function runnerMatrixRows(profiles: RegisteredRunnerProfile[]): RunnerMatrixRow[] { + return profiles.map((profile) => { + const operations = new Set(profileOperations(profile)); + return { + profile: profile.name, + implementation: profile.runner.name, + category: profile.runner.category, + support: profile.runner.declaredSupportLevel ?? 'production', + enabled: profile.config.enabled !== false, + author: operations.has('stage-generation'), + refine: operations.has('stage-refinement'), + execute: operations.has('task-execution'), + resume: operations.has('task-resume'), + local: profileTransport(profile.config).localExecution, + }; + }); +} + +/** Markdown capability matrix — the same source feeds docs and README. */ +export function renderRunnerMatrixMarkdown(rows: RunnerMatrixRow[]): string { + const lines = [ + '| Profile | Support | Author | Refine | Execute | Resume | Local |', + '|---------|---------|--------|--------|---------|--------|-------|', + ]; + for (const row of rows) { + const yn = (value: boolean): string => (value ? 'yes' : 'no'); + lines.push( + `| ${row.profile} | ${row.support} | ${yn(row.author)} | ${yn(row.refine)} | ${yn(row.execute)} | ${yn(row.resume)} | ${yn(row.local)} |`, + ); + } + return `${lines.join('\n')}\n`; +} + +/** One profile summary used by `runner list` and the MCP `runner_list` tool. */ +export interface RunnerProfileSummary { + profile: string; + implementation: string; + category: RunnerCategory; + supportLevel: RunnerSupportLevel; + enabled: boolean; + model: string | null; + networkBacked: boolean; + localExecution: boolean; + supportedOperations: string[]; +} + +export function runnerProfileSummary(profile: RegisteredRunnerProfile): RunnerProfileSummary { + const transport = profileTransport(profile.config); + return { + profile: profile.name, + implementation: profile.runner.name, + category: profile.runner.category, + supportLevel: profile.runner.declaredSupportLevel ?? 'production', + enabled: profile.config.enabled !== false, + model: profileModel(profile.config), + networkBacked: transport.networkBacked, + localExecution: transport.localExecution, + supportedOperations: profileOperations(profile), + }; +} + +/** + * Redacted profile configuration for displays. Profiles never store + * credentials (rejected by the schema); this is defense in depth for + * passthrough fields. + */ +export function redactedRunnerProfileConfig( + profile: RegisteredRunnerProfile, +): Record { + const redacted: Record = {}; + for (const [key, value] of Object.entries(profile.config as Record)) { + redacted[key] = /key|token|secret|password|credential/i.test(key) ? '' : value; + } + return redacted; +} diff --git a/packages/runners/src/registry/runner-selection.ts b/packages/runners/src/registry/runner-selection.ts index 31470ac..730b4d4 100644 --- a/packages/runners/src/registry/runner-selection.ts +++ b/packages/runners/src/registry/runner-selection.ts @@ -90,7 +90,7 @@ export function profileTransport(config: RunnerProfileConfig): { localExecution: boolean; endpoint?: string; } { - if (config.runner === 'ollama') { + if (config.runner === 'ollama' || config.runner === 'openai-compatible') { const url = validateRunnerBaseUrl(config.baseUrl, { allowInsecureHttp: config.allowInsecureHttp, }); @@ -108,14 +108,14 @@ export function profileTransport(config: RunnerProfileConfig): { } export function profileModel(config: RunnerProfileConfig): string | null { - if (config.runner === 'mock') return null; + if (config.runner === 'mock' || config.runner === 'antigravity-cli') return null; return config.model ?? null; } -function declaredSupportLevel(_profile: RegisteredRunnerProfile): RunnerSupportLevel { - // All v0.6.0 registered implementations are production; preview or - // experimental adapters would declare their level on the adapter itself. - return 'production'; +function declaredSupportLevel(profile: RegisteredRunnerProfile): RunnerSupportLevel { + // v0.6.0 adapters declare no level and are production; v0.6.1 preview or + // experimental adapters declare their level on the adapter itself. + return profile.runner.declaredSupportLevel ?? 'production'; } function constraintsFor(profile: RegisteredRunnerProfile, operation: RunnerOperation): string[] { @@ -124,9 +124,12 @@ function constraintsFor(profile: RegisteredRunnerProfile, operation: RunnerOpera operation === 'task-execution' || operation === 'task-resume' ? 'implementation' : 'read-only', ); if (boundary !== undefined) constraints.push(boundary); - if (profile.config.runner === 'ollama') { + if (profile.config.runner === 'ollama' || profile.config.runner === 'openai-compatible') { constraints.push('Task execution and repository writes are not capabilities of this runner.'); } + if (profile.config.runner === 'openai-compatible' && profile.config.allowInsecureHttp) { + constraints.push('INSECURE development override: plain HTTP to a remote endpoint is explicitly allowed.'); + } constraints.push('No commits, no pushes, no checkbox updates by the provider; evidence stays provider-independent.'); return constraints; } diff --git a/packages/runners/src/shared/http-client.ts b/packages/runners/src/shared/http-client.ts index df39e3a..f47a54e 100644 --- a/packages/runners/src/shared/http-client.ts +++ b/packages/runners/src/shared/http-client.ts @@ -1,15 +1,18 @@ import { Buffer } from 'node:buffer'; /** - * Safe bounded HTTP client for model-API runners (v0.6). + * Safe bounded HTTP client for model-API runners (v0.6, extended v0.6.1). * * Guarantees: * - total request timeout (connect + headers + body) * - AbortSignal cancellation (no request outlives its caller) * - response size limit enforced while STREAMING (the connection is * aborted at the limit; an oversized body is never buffered) - * - redirects are never followed (a redirect is a failure — a model - * endpoint must answer directly, not send data elsewhere) + * - redirects are rejected by default (a redirect is a failure — a model + * endpoint must answer directly, not send data elsewhere); a caller may + * opt into BOUNDED redirect following, where the Authorization header + * is never forwarded across origins, HTTPS never downgrades to HTTP, + * unsupported schemes are rejected, and safe metadata is recorded * - no credentials in URLs (validated before any request is built) * - errors are safe messages, never raw provider payload dumps */ @@ -24,6 +27,17 @@ export interface SafeHttpRequest { signal?: AbortSignal; /** Expected content type (substring match, e.g. "application/json"). */ expectJson?: boolean; + /** + * Extra request headers (v0.6.1). The Authorization header (and every + * other custom header) is dropped when a followed redirect crosses + * origins. Never logged; never included in results. + */ + headers?: Record; + /** + * Opt into bounded redirect following (v0.6.1). Absent = the v0.6.0 + * behavior: any redirect is rejected. + */ + maxRedirects?: number; } export type SafeHttpFailureKind = @@ -35,6 +49,15 @@ export type SafeHttpFailureKind = | 'invalid-content-type' | 'http-error'; +/** Safe metadata about followed redirects (no headers, no credentials). */ +export interface SafeRedirectMetadata { + count: number; + /** Final URL actually answered (never contains credentials). */ + finalUrl: string; + /** True when a cross-origin hop caused custom headers to be dropped. */ + crossOrigin: boolean; +} + export type SafeHttpResult = | { ok: true; @@ -42,6 +65,7 @@ export type SafeHttpResult = bodyText: string; bodyBytes: number; durationMs: number; + redirects?: SafeRedirectMetadata; } | { ok: false; @@ -86,58 +110,145 @@ async function readBounded( return { text: Buffer.concat(chunks).toString('utf8'), bytes: total }; } +export interface RedirectDecision { + ok: boolean; + detail?: string; + nextUrl?: URL; +} + +/** Validate one redirect hop against the safety rules (exported for tests). */ +export function checkRedirectTarget(current: URL, location: string): RedirectDecision { + let next: URL; + try { + next = new URL(location, current); + } catch { + return { ok: false, detail: `the redirect target "${location.slice(0, 200)}" is not a valid URL` }; + } + if (next.protocol !== 'http:' && next.protocol !== 'https:') { + return { + ok: false, + detail: `the redirect target uses the unsupported scheme "${next.protocol}"`, + }; + } + if (current.protocol === 'https:' && next.protocol === 'http:') { + return { + ok: false, + detail: 'the redirect would downgrade HTTPS to plain HTTP; downgrades are never followed', + }; + } + if (next.username !== '' || next.password !== '') { + return { ok: false, detail: 'the redirect target embeds credentials; it is never followed' }; + } + return { ok: true, nextUrl: next }; +} + /** One bounded HTTP request. Never throws for transport-level failures. */ export async function safeHttpRequest(request: SafeHttpRequest): Promise { const started = Date.now(); const duration = (): number => Math.max(0, Date.now() - started); const externalAborted = (): boolean => request.signal?.aborted === true; + const maxRedirects = request.maxRedirects ?? 0; + const initialUrl = new URL(request.url); + const initialOrigin = initialUrl.origin; + + let currentUrl = initialUrl; + let currentMethod: 'GET' | 'POST' = request.method; + let sendBody = request.body !== undefined; + let crossedOrigin = false; + let redirectCount = 0; let response: Response; - try { - response = await fetch(request.url, { - method: request.method, - redirect: 'manual', - signal: composeSignals(request.timeoutMs, request.signal), - ...(request.body !== undefined - ? { - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(request.body), - } - : {}), - }); - } catch (cause) { - if (externalAborted()) { - return { ok: false, kind: 'cancelled', detail: 'the request was cancelled', durationMs: duration() }; + + for (;;) { + const headers: Record = {}; + if (sendBody) headers['content-type'] = 'application/json'; + if (request.headers !== undefined && !crossedOrigin) { + // Custom headers (including Authorization) travel only while the + // request stays on the configured origin. + for (const [name, value] of Object.entries(request.headers)) headers[name] = value; } - if (cause instanceof Error && (cause.name === 'TimeoutError' || cause.name === 'AbortError')) { + try { + response = await fetch(currentUrl.toString(), { + method: currentMethod, + redirect: 'manual', + signal: composeSignals(request.timeoutMs, request.signal), + headers, + ...(sendBody ? { body: JSON.stringify(request.body) } : {}), + }); + } catch (cause) { + if (externalAborted()) { + return { ok: false, kind: 'cancelled', detail: 'the request was cancelled', durationMs: duration() }; + } + if (cause instanceof Error && (cause.name === 'TimeoutError' || cause.name === 'AbortError')) { + return { + ok: false, + kind: 'timeout', + detail: `the request did not complete within ${request.timeoutMs} ms`, + durationMs: duration(), + }; + } + const message = cause instanceof Error ? cause.message : String(cause); return { ok: false, - kind: 'timeout', - detail: `the request did not complete within ${request.timeoutMs} ms`, + kind: 'unreachable', + detail: `the endpoint could not be reached (${message.slice(0, 300)})`, durationMs: duration(), }; } - const message = cause instanceof Error ? cause.message : String(cause); - return { - ok: false, - kind: 'unreachable', - detail: `the endpoint could not be reached (${message.slice(0, 300)})`, - durationMs: duration(), - }; - } - // Redirects are rejected outright: following one could send spec content - // to a different host than the one the user configured. - if (response.status >= 300 && response.status < 400) { - return { - ok: false, - kind: 'redirect-rejected', - status: response.status, - detail: `the endpoint answered with a redirect (${response.status}); redirects are never followed`, - durationMs: duration(), - }; + if (response.status < 300 || response.status >= 400) break; + + // Redirect handling. Default (maxRedirects 0): rejected outright — + // following one could send spec content to a different host than the + // one the user configured. + if (redirectCount >= maxRedirects) { + return { + ok: false, + kind: 'redirect-rejected', + status: response.status, + detail: + maxRedirects === 0 + ? `the endpoint answered with a redirect (${response.status}); redirects are never followed` + : `the endpoint exceeded the bounded redirect limit of ${maxRedirects}`, + durationMs: duration(), + }; + } + const location = response.headers.get('location'); + if (location === null || location.length === 0) { + return { + ok: false, + kind: 'redirect-rejected', + status: response.status, + detail: `the endpoint answered with a redirect (${response.status}) without a target`, + durationMs: duration(), + }; + } + const decision = checkRedirectTarget(currentUrl, location); + if (!decision.ok || decision.nextUrl === undefined) { + return { + ok: false, + kind: 'redirect-rejected', + status: response.status, + detail: decision.detail ?? 'the redirect was rejected', + durationMs: duration(), + }; + } + redirectCount += 1; + if (decision.nextUrl.origin !== initialOrigin) crossedOrigin = true; + // 303 (and 301/302 on POST, per fetch semantics) switch to GET without a + // body; 307/308 preserve the method and body. + if (response.status === 303 || (currentMethod === 'POST' && (response.status === 301 || response.status === 302))) { + currentMethod = 'GET'; + sendBody = false; + } + currentUrl = decision.nextUrl; } + const redirects: SafeRedirectMetadata | undefined = + redirectCount > 0 + ? { count: redirectCount, finalUrl: currentUrl.toString(), crossOrigin: crossedOrigin } + : undefined; + let body: { text: string; bytes: number } | 'too-large'; try { body = await readBounded(response, request.maxResponseBytes); @@ -200,5 +311,6 @@ export async function safeHttpRequest(request: SafeHttpRequest): Promise { expect(result.stdout).toContain('codex-default'); expect(result.stdout).toContain('ollama-local'); expect(result.stdout).toContain('disabled'); - expect(result.stdout).not.toContain('openai-compatible'); + // v0.6.1: the new providers are registered but DISABLED by default. + expect(result.stdout).toContain('gemini-default'); + expect(result.stdout).toContain('openai-compatible-local'); + expect(result.stdout).toContain('antigravity'); }); it('runner doctor mock reports available with exit 0', async () => { diff --git a/tests/execution/gemini-execution.test.ts b/tests/execution/gemini-execution.test.ts new file mode 100644 index 0000000..1ca28e5 --- /dev/null +++ b/tests/execution/gemini-execution.test.ts @@ -0,0 +1,151 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { listAttempts, resumeRun, runApprovedTask } from '@specbridge/execution'; +import { EXECUTION_SPEC, failingCommand, git, setupExecutionFixtureV2 } from '../helpers-execution.js'; + +/** + * v0.6.1 Gemini task execution through the SAME shared orchestration as + * Claude and Codex: Git snapshots, trusted verification, evidence + * evaluation, verified-only checkbox completion, explicit-session resume. + * Fully offline (fake Gemini child process). + */ + +afterEach(() => { + delete process.env['FAKE_GEMINI_SCENARIO']; + delete process.env['FAKE_GEMINI_LOG']; +}); + +function tasksDocument(root: string): string { + return readFileSync(path.join(root, '.kiro', 'specs', EXECUTION_SPEC, 'tasks.md'), 'utf8'); +} + +describe('gemini task execution through shared orchestration', () => { + it('a verified gemini run updates exactly one checkbox from actual Git evidence', async () => { + process.env['FAKE_GEMINI_SCENARIO'] = 'success'; + const fixture = setupExecutionFixtureV2({ useFakeGemini: true, defaultRunner: 'gemini-default' }); + const before = tasksDocument(fixture.root); + const outcome = await runApprovedTask(fixture.deps, { specName: EXECUTION_SPEC, next: true }); + expect(outcome.kind).toBe('executed'); + if (outcome.kind !== 'executed') return; + const report = outcome.report; + expect(report.runner).toBe('gemini-default'); + expect(report.outcome).toBe('completed'); + expect(report.evidenceStatus).toBe('verified'); + expect(report.checkboxUpdated).toBe(true); + expect(report.sessionId).toBe('aaaaaaaa-1111-2222-3333-444444444444'); + // Actual git evidence, not provider claims. + expect(report.changedFiles.some((file) => file.path === 'src/fake-gemini-change.txt')).toBe(true); + expect(report.verification.ran).toBe(true); + // Exactly one checkbox changed; no commit or push happened. + const beforeLines = before.split('\n'); + const flipped = tasksDocument(fixture.root) + .split('\n') + .filter((line, index) => line !== beforeLines[index]); + expect(flipped).toHaveLength(1); + expect(flipped[0]).toContain('[x]'); + expect(git(fixture.root, 'log', '--oneline').trim().split('\n')).toHaveLength(1); + const attempts = listAttempts(fixture.workspace, report.runId); + expect(attempts).toHaveLength(1); + expect(attempts[0]?.runner).toBe('gemini-cli'); + expect(attempts[0]?.capabilitySnapshot.taskExecution).toBe(true); + }); + + it('a failed verifier leaves the checkbox unchanged (provider claims are not authority)', async () => { + process.env['FAKE_GEMINI_SCENARIO'] = 'success'; + const fixture = setupExecutionFixtureV2({ + useFakeGemini: true, + defaultRunner: 'gemini-default', + verificationCommands: [failingCommand()], + }); + const before = tasksDocument(fixture.root); + const outcome = await runApprovedTask(fixture.deps, { specName: EXECUTION_SPEC, next: true }); + expect(outcome.kind).toBe('executed'); + if (outcome.kind !== 'executed') return; + expect(outcome.report.evidenceStatus).toBe('implemented-unverified'); + expect(outcome.report.checkboxUpdated).toBe(false); + expect(tasksDocument(fixture.root)).toBe(before); + }); + + it('a gemini .kiro write prevents verification and is never rolled back', async () => { + process.env['FAKE_GEMINI_SCENARIO'] = 'protected-write'; + const fixture = setupExecutionFixtureV2({ useFakeGemini: true, defaultRunner: 'gemini-default' }); + const before = tasksDocument(fixture.root); + const outcome = await runApprovedTask(fixture.deps, { specName: EXECUTION_SPEC, next: true }); + expect(outcome.kind).toBe('executed'); + if (outcome.kind !== 'executed') return; + expect(outcome.report.evidenceStatus).not.toBe('verified'); + expect(outcome.report.violations.join(' ')).toContain('.kiro'); + expect(outcome.report.checkboxUpdated).toBe(false); + expect(tasksDocument(fixture.root)).toBe(before); + expect(existsSync(path.join(fixture.root, '.kiro', 'fake-gemini-rogue.txt'))).toBe(true); + }); + + it('a gemini tasks.md edit is caught and never verified', async () => { + process.env['FAKE_GEMINI_SCENARIO'] = 'kiro-tasks-write'; + const fixture = setupExecutionFixtureV2({ useFakeGemini: true, defaultRunner: 'gemini-default' }); + const outcome = await runApprovedTask(fixture.deps, { specName: EXECUTION_SPEC, next: true }); + expect(outcome.kind).toBe('executed'); + if (outcome.kind !== 'executed') return; + expect(outcome.report.evidenceStatus).not.toBe('verified'); + expect(outcome.report.violations.length).toBeGreaterThan(0); + }); + + it('provider claims alone (claimed tests never run) never verify a task', async () => { + process.env['FAKE_GEMINI_SCENARIO'] = 'claims-untested'; + const fixture = setupExecutionFixtureV2({ + useFakeGemini: true, + defaultRunner: 'gemini-default', + verificationCommands: [], + }); + const outcome = await runApprovedTask(fixture.deps, { specName: EXECUTION_SPEC, next: true }); + expect(outcome.kind).toBe('executed'); + if (outcome.kind !== 'executed') return; + expect(outcome.report.evidenceStatus).toBe('implemented-unverified'); + expect(outcome.report.checkboxUpdated).toBe(false); + }); + + it('malformed final output leaves the task unchecked with evidence preserved', async () => { + process.env['FAKE_GEMINI_SCENARIO'] = 'malformed'; + const fixture = setupExecutionFixtureV2({ useFakeGemini: true, defaultRunner: 'gemini-default' }); + const before = tasksDocument(fixture.root); + const outcome = await runApprovedTask(fixture.deps, { specName: EXECUTION_SPEC, next: true }); + expect(outcome.kind).toBe('executed'); + if (outcome.kind !== 'executed') return; + expect(outcome.report.evidenceStatus).not.toBe('verified'); + expect(outcome.report.checkboxUpdated).toBe(false); + expect(tasksDocument(fixture.root)).toBe(before); + expect(existsSync(outcome.report.evidencePath)).toBe(true); + }); + + it('gemini resume uses the explicit recorded session UUID and preserves lineage', async () => { + process.env['FAKE_GEMINI_SCENARIO'] = 'reports-blocked'; + const fixture = setupExecutionFixtureV2({ useFakeGemini: true, defaultRunner: 'gemini-default' }); + const first = await runApprovedTask(fixture.deps, { specName: EXECUTION_SPEC, next: true }); + expect(first.kind).toBe('executed'); + if (first.kind !== 'executed') return; + expect(first.report.evidenceStatus).toBe('blocked'); + expect(first.report.resumeSupported).toBe(true); + + process.env['FAKE_GEMINI_SCENARIO'] = 'resume-ok'; + const resume = await resumeRun(fixture.deps, { runId: first.report.runId }); + expect(resume.kind).toBe('executed'); + if (resume.kind !== 'executed') return; + expect(resume.report.evidenceStatus).toBe('verified'); + expect(resume.report.parentRunId).toBe(first.report.runId); + }); + + it('a missing gemini session refuses the resume honestly', async () => { + process.env['FAKE_GEMINI_SCENARIO'] = 'reports-blocked'; + const fixture = setupExecutionFixtureV2({ useFakeGemini: true, defaultRunner: 'gemini-default' }); + const first = await runApprovedTask(fixture.deps, { specName: EXECUTION_SPEC, next: true }); + if (first.kind !== 'executed') throw new Error('expected execution'); + + process.env['FAKE_GEMINI_SCENARIO'] = 'resume-missing-session'; + const resume = await resumeRun(fixture.deps, { runId: first.report.runId }); + expect(resume.kind).toBe('executed'); + if (resume.kind !== 'executed') return; + expect(resume.report.evidenceStatus).toBe('failed'); + expect(resume.report.checkboxUpdated).toBe(false); + }); +}); diff --git a/tests/execution/openai-execution.test.ts b/tests/execution/openai-execution.test.ts new file mode 100644 index 0000000..b4d3002 --- /dev/null +++ b/tests/execution/openai-execution.test.ts @@ -0,0 +1,141 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { analyzeSpec, requireSpec } from '@specbridge/compat-kiro'; +import { approveStage } from '@specbridge/workflow'; +import { stateStage } from '@specbridge/core'; +import { authorStage, listAttempts, runApprovedTask } from '@specbridge/execution'; +import type { ExecutionFixture } from '../helpers-execution.js'; +import { EXECUTION_SPEC, setupExecutionFixtureV2 } from '../helpers-execution.js'; +import type { FakeOpenAiServer } from '../helpers-fake-openai.js'; +import { startFakeOpenAi } from '../helpers-fake-openai.js'; + +/** + * v0.6.1 OpenAI-compatible authoring through the SAME shared orchestration + * gates as Ollama: draft candidates, explicit approval, attempt records, + * data-boundary reporting, and hard task-execution rejection before any + * HTTP request. Fully offline (fake loopback endpoint). + */ + +const servers: FakeOpenAiServer[] = []; + +async function fakeServer(options: Parameters[0] = {}): Promise { + const server = await startFakeOpenAi(options); + servers.push(server); + return server; +} + +afterEach(async () => { + while (servers.length > 0) await servers.pop()?.close(); +}); + +function approveOne(fixture: ExecutionFixture, stage: 'requirements' | 'design' | 'tasks'): void { + const spec = analyzeSpec(fixture.workspace, requireSpec(fixture.workspace, EXECUTION_SPEC)); + const result = approveStage(fixture.workspace, spec, { stage }, { clock: fixture.clock }); + if (!result.ok) throw new Error(result.message); +} + +describe('openai-compatible authoring through shared orchestration', () => { + it('generates a design stage that stays draft; SpecBridge writes the file', async () => { + const server = await fakeServer({ behaviors: ['valid-design'] }); + const fixture = setupExecutionFixtureV2({ + openAiBaseUrl: `${server.baseUrl}/v1`, + defaultRunner: 'mock', + approve: false, + }); + approveOne(fixture, 'requirements'); + const outcome = await authorStage(fixture.deps, { + specName: EXECUTION_SPEC, + stage: 'design', + intent: 'generate', + runnerName: 'openai-compatible-local', + }); + expect(outcome.kind).toBe('applied'); + if (outcome.kind !== 'applied') return; + expect(outcome.profile).toBe('openai-compatible-local'); + expect(readFileSync(outcome.filePath, 'utf8')).toContain('# Design Document'); + // The design stage remains unapproved after generation. + const spec = analyzeSpec(fixture.workspace, requireSpec(fixture.workspace, EXECUTION_SPEC)); + const designStage = spec.state !== undefined ? stateStage(spec.state, 'design') : undefined; + expect(designStage?.status).not.toBe('approved'); + // Exactly one loopback request; SpecBridge wrote the document itself. + expect(server.inferenceCalls()).toHaveLength(1); + const attempts = listAttempts(fixture.workspace, outcome.runId); + expect(attempts).toHaveLength(1); + expect(attempts[0]?.boundary).toBe('loopback-endpoint'); + expect(attempts[0]?.model).toBe('fake-oai-model'); + }); + + it('stage refinement works with an explicit instruction', async () => { + const server = await fakeServer({ behaviors: ['valid-design'] }); + const fixture = setupExecutionFixtureV2({ + openAiBaseUrl: `${server.baseUrl}/v1`, + defaultRunner: 'mock', + approve: false, + }); + approveOne(fixture, 'requirements'); + const generated = await authorStage(fixture.deps, { + specName: EXECUTION_SPEC, + stage: 'design', + intent: 'generate', + runnerName: 'openai-compatible-local', + }); + expect(generated.kind).toBe('applied'); + const refined = await authorStage(fixture.deps, { + specName: EXECUTION_SPEC, + stage: 'design', + intent: 'refine', + instruction: 'Add explicit recovery behavior.', + runnerName: 'openai-compatible-local', + }); + expect(refined.kind).toBe('applied'); + expect(server.inferenceCalls()).toHaveLength(2); + }); + + it('dry-run reports the endpoint and data boundary and sends NO request', async () => { + const server = await fakeServer(); + const fixture = setupExecutionFixtureV2({ + openAiBaseUrl: `${server.baseUrl}/v1`, + defaultRunner: 'mock', + approve: false, + }); + approveOne(fixture, 'requirements'); + const outcome = await authorStage(fixture.deps, { + specName: EXECUTION_SPEC, + stage: 'design', + intent: 'generate', + runnerName: 'openai-compatible-local', + dryRun: true, + }); + expect(outcome.kind).toBe('dry-run'); + if (outcome.kind !== 'dry-run') return; + const boundary = outcome.plan.dataBoundary; + expect(boundary?.endpoint).toBe(`${server.baseUrl}/v1`); + expect(boundary?.networkRequestWillOccur).toBe(true); + expect(boundary?.model).toBe('fake-oai-model'); + expect(boundary?.inputCharacters).toBeGreaterThan(0); + expect(boundary?.documents.length).toBeGreaterThan(0); + // Nothing left this process: no HTTP request of any kind. + expect(server.requests).toHaveLength(0); + }); + + it('task execution is rejected before any HTTP request or file change', async () => { + const server = await fakeServer(); + const fixture = setupExecutionFixtureV2({ + openAiBaseUrl: `${server.baseUrl}/v1`, + defaultRunner: 'mock', + }); + const outcome = await runApprovedTask(fixture.deps, { + specName: EXECUTION_SPEC, + next: true, + runnerName: 'openai-compatible-local', + }); + expect(outcome.kind).toBe('preflight-failed'); + if (outcome.kind !== 'preflight-failed') return; + expect(outcome.preflight.failure?.code).toBe('runner-not-selectable'); + expect(outcome.preflight.failure?.selection?.missingCapabilities).toContain('taskExecution'); + // NOTHING happened: no HTTP request, no run record. + expect(server.requests).toHaveLength(0); + expect(existsSync(path.join(fixture.root, '.specbridge', 'runs'))).toBe(false); + }); +}); diff --git a/tests/fixtures/fake-antigravity/fake-antigravity.mjs b/tests/fixtures/fake-antigravity/fake-antigravity.mjs new file mode 100644 index 0000000..c2484b5 --- /dev/null +++ b/tests/fixtures/fake-antigravity/fake-antigravity.mjs @@ -0,0 +1,63 @@ +/** + * Fake Antigravity CLI for process-level integration tests. + * + * Invoked as `node fake-antigravity.mjs `. The scenario comes from the + * FAKE_ANTIGRAVITY_SCENARIO environment variable; every invocation can be + * recorded to FAKE_ANTIGRAVITY_LOG so tests can prove the adapter only ever + * runs `--version` and `--help`. Fully offline; no network, no model. + * + * IMPORTANT: any invocation other than --version/--help HANGS, simulating an + * interactive TUI taking over — the adapter must never reach that path. + */ +import { appendFileSync } from 'node:fs'; + +const args = process.argv.slice(2); +const scenario = process.env.FAKE_ANTIGRAVITY_SCENARIO ?? 'interactive-only'; + +if (process.env.FAKE_ANTIGRAVITY_LOG) { + appendFileSync(process.env.FAKE_ANTIGRAVITY_LOG, `${JSON.stringify({ argv: args })}\n`, 'utf8'); +} + +/** Block forever (until the parent kills us). */ +function sleepForever() { + setInterval(() => {}, 1000); + return new Promise(() => {}); +} + +if (args.includes('--version')) { + if (scenario === 'interactive-hang') { + // The build ignores --version and enters its interactive session. + process.stderr.write('launching antigravity interactive session...\n'); + await sleepForever(); + } + process.stdout.write('antigravity 0.3.1 (fake)\n'); + process.exit(0); +} + +if (args.includes('--help')) { + if (scenario === 'no-help') { + process.stderr.write('unknown flag: --help\n'); + process.exit(64); + } + const lines = ['Antigravity (fake)', '', 'Usage: agy [options]', '', 'Options:']; + lines.push(' --version Print version'); + if (scenario === 'headless-claimed' || scenario === 'documented-structured') { + lines.push(' --prompt Run one prompt without the interactive session'); + } + if (scenario === 'documented-structured') { + lines.push(' --output-format Output format: text | json'); + lines.push(' --list-sessions List saved sessions'); + lines.push(' --resume Resume a saved session'); + } + if (scenario === 'interactive-only' || scenario === 'headless-claimed') { + lines.push(''); + lines.push('Run agy without arguments to start the interactive TUI session.'); + } + process.stdout.write(`${lines.join('\n')}\n`); + process.exit(0); +} + +// Anything else: the interactive TUI would take over the terminal. The fake +// hangs so any accidental automation attempt fails tests via timeout. +process.stderr.write('fake-antigravity: starting interactive TUI...\n'); +await sleepForever(); diff --git a/tests/fixtures/fake-gemini/fake-gemini.mjs b/tests/fixtures/fake-gemini/fake-gemini.mjs new file mode 100644 index 0000000..e054f7c --- /dev/null +++ b/tests/fixtures/fake-gemini/fake-gemini.mjs @@ -0,0 +1,394 @@ +/** + * Fake Gemini CLI for process-level integration tests. + * + * Invoked as `node fake-gemini.mjs ` (configured via the gemini profile + * command: executable = process.execPath, args = [this file]). The scenario + * comes from the FAKE_GEMINI_SCENARIO environment variable; every headless + * invocation can be recorded to FAKE_GEMINI_LOG for argv assertions. Fully + * offline: no network, no model, no credentials. + * + * Emits the documented headless output shapes: + * --output-format json one JSON envelope {response, stats} + * --output-format stream-json JSONL events ending in {type:"result"} + */ +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, writeSync } from 'node:fs'; +import path from 'node:path'; + +const args = process.argv.slice(2); +const scenario = process.env.FAKE_GEMINI_SCENARIO ?? 'success'; + +function argValue(flag) { + const index = args.indexOf(flag); + return index >= 0 && index + 1 < args.length ? args[index + 1] : undefined; +} + +/** Block forever (until the parent kills us). */ +function sleepForever() { + setInterval(() => {}, 1000); + return new Promise(() => {}); +} + +function writeBlocking(fd, text) { + const buffer = Buffer.from(text); + let offset = 0; + while (offset < buffer.length) offset += writeSync(fd, buffer, offset); +} + +// --------------------------------------------------------------------------- +// version / help probes +// --------------------------------------------------------------------------- + +if (args.includes('--version')) { + if (scenario === 'version-timeout') await sleepForever(); + process.stdout.write('0.9.9 (fake gemini)\n'); + process.exit(0); +} + +if (args.includes('--help')) { + const lines = ['Gemini CLI (fake)', '', 'Usage: gemini [options]', '', 'Options:']; + const flag = (text) => lines.push(` ${text}`); + flag('--version Print version'); + if (scenario !== 'no-headless') { + flag('-p, --prompt Run headless: read the prompt from stdin and print the result'); + } + if (scenario !== 'no-json') { + const formats = scenario === 'no-stream-json' ? 'text | json' : 'text | json | stream-json'; + flag(`-o, --output-format Output format: ${formats}`); + } + { + const modes = []; + if (scenario !== 'no-plan') modes.push('plan'); + modes.push('default'); + if (scenario !== 'no-auto-edit') modes.push('auto_edit'); + modes.push('yolo'); + flag(`--approval-mode Approval mode: ${modes.join(' | ')}`); + } + if (scenario !== 'no-sandbox' && scenario !== 'unsafe-edit-policy') { + flag('-s, --sandbox Run tools in a sandbox'); + } + if (scenario !== 'no-allowed-tools' && scenario !== 'unsafe-edit-policy') { + flag('--allowed-tools Comma-separated list of tools the agent may use'); + } + if (scenario !== 'no-extensions') { + flag('-e, --extensions Extensions to enable ("none" disables all)'); + } + flag('-m, --model Model override'); + if (scenario !== 'no-resume') { + flag('--resume Resume a saved session by its UUID'); + flag('--list-sessions List saved sessions'); + } + flag('--yolo Auto-approve every action (DANGEROUS)'); + process.stdout.write(`${lines.join('\n')}\n`); + process.exit(0); +} + +if (args.includes('--list-sessions')) { + process.stdout.write( + 'aaaaaaaa-1111-2222-3333-444444444444 2026-07-01 settings work\n' + + 'bbbbbbbb-5555-6666-7777-888888888888 2026-07-02 bugfix session\n', + ); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// headless execution +// --------------------------------------------------------------------------- + +if (!args.includes('--prompt') && !args.includes('-p')) { + // Interactive invocation: a real TUI would take over the terminal. The + // fake hangs so any accidental interactive start fails tests via timeout. + process.stderr.write('fake-gemini: starting interactive session (no --prompt given)\n'); + await sleepForever(); +} + +const outputFormat = argValue('--output-format') ?? argValue('-o') ?? 'text'; +const approvalMode = argValue('--approval-mode') ?? 'default'; +const allowedToolsRaw = argValue('--allowed-tools'); +const allowedTools = allowedToolsRaw === undefined ? undefined : allowedToolsRaw.split(','); +const sandboxed = args.includes('--sandbox') || args.includes('-s'); +const resumeSessionId = argValue('--resume'); +const stdin = readFileSync(0, 'utf8'); + +if (process.env.FAKE_GEMINI_LOG) { + appendFileSync( + process.env.FAKE_GEMINI_LOG, + `${JSON.stringify({ + argv: args, + outputFormat, + approvalMode, + allowedTools: allowedTools ?? null, + sandboxed, + resumeSessionId: resumeSessionId ?? null, + stdinBytes: Buffer.byteLength(stdin, 'utf8'), + })}\n`, + 'utf8', + ); +} + +if (scenario === 'exec-timeout') await sleepForever(); + +if (scenario === 'auth-error') { + process.stderr.write('Error: please sign in — 401 unauthorized. Run gemini and complete the login flow.\n'); + process.exit(1); +} +if (scenario === 'permission-denied') { + process.stderr.write('Error: tool call rejected: permission denied by approval policy\n'); + process.exit(1); +} +if (scenario === 'quota-exceeded') { + process.stderr.write('Error: RESOURCE_EXHAUSTED: quota exceeded for this project\n'); + process.exit(1); +} +if (scenario === 'rate-limit') { + process.stderr.write('Error: 429 too many requests: rate limit reached, slow down\n'); + process.exit(1); +} +if (scenario === 'nonzero-exit') { + process.stderr.write('fake-gemini: simulated internal failure\n'); + process.exit(3); +} +if (scenario === 'huge-stdout') { + try { + const chunk = 'g'.repeat(64 * 1024); + for (let i = 0; i < 400; i += 1) writeBlocking(1, chunk); + } catch { + process.exit(1); // EPIPE: the parent enforced its output limit + } + process.exit(0); +} +if (scenario === 'huge-stderr') { + try { + const chunk = 'e'.repeat(64 * 1024); + for (let i = 0; i < 100; i += 1) writeBlocking(2, chunk); + } catch { + process.exit(1); + } + process.exit(0); +} + +if (resumeSessionId !== undefined && scenario === 'resume-missing-session') { + process.stderr.write(`Error: no saved session with id ${resumeSessionId}\n`); + process.exit(1); +} + +const sessionId = + scenario === 'resume-session-mismatch' + ? 'ffffffff-0000-0000-0000-000000000000' + : (resumeSessionId ?? 'aaaaaaaa-1111-2222-3333-444444444444'); + +const streaming = outputFormat === 'stream-json'; +const events = []; +function emit(event) { + if (streaming) process.stdout.write(`${JSON.stringify(event)}\n`); + else events.push(event); +} + +let usage = { input_tokens: 900, output_tokens: 210 }; + +function finish(finalResponse, exitCode = 0) { + if (streaming) { + emit({ type: 'usage', ...usage }); + if (finalResponse !== undefined) emit({ type: 'result', response: finalResponse }); + } else if (outputFormat === 'json') { + if (finalResponse !== undefined) { + process.stdout.write( + `${JSON.stringify({ response: finalResponse, stats: { session_id: sessionId, ...usage } })}\n`, + ); + } + } else if (finalResponse !== undefined) { + process.stdout.write(`${finalResponse}\n`); + } + process.exit(exitCode); +} + +emit({ type: 'session.started', session_id: sessionId }); +// Thought event: SpecBridge must never copy this text anywhere. +emit({ type: 'thought', text: 'GEMINI-REASONING-SECRET planning the work step by step' }); + +const stageMatch = /Stage to produce: (\w+)/.exec(stdin); + +function stageMarkdownFor(stage) { + if (scenario === 'stage-invalid') { + return '# Requirements Document\n\nAs a , I want , so that .\n'; + } + switch (stage) { + case 'requirements': + return [ + '# Requirements Document', + '', + '## Introduction', + '', + 'Requirements produced by the fake Gemini CLI for tests.', + '', + '## Requirements', + '', + '### Requirement 1: Persist settings', + '', + '**User Story:** As a user, I want settings saved, so that they survive restarts.', + '', + '#### Acceptance Criteria', + '', + '1. WHEN the user saves a setting, THE SYSTEM SHALL persist it before confirming success.', + '2. IF the persistence layer is unavailable, THEN THE SYSTEM SHALL report an error and keep the previous value.', + '', + '## Out of Scope', + '', + '- Cross-device synchronization is excluded.', + '', + '## Non-Functional Requirements', + '', + '- Saving SHALL complete within 200 ms on the reference environment.', + '', + ].join('\n'); + case 'design': + return [ + '# Design Document', + '', + '## Overview', + '', + 'Fake Gemini design overview.', + '', + '## Architecture', + '', + 'A settings store module behind the service interface.', + '', + '## Components and Interfaces', + '', + '- Settings store with read and write operations.', + '', + '## Error Handling', + '', + 'Typed errors; previous value preserved.', + '', + '## Security Considerations', + '', + 'Input validation before persistence.', + '', + '## Testing Strategy', + '', + 'Unit and integration tests.', + '', + '## Risks and Trade-offs', + '', + '- File-backed store favors simplicity.', + '', + ].join('\n'); + default: + return `# ${stage}\n\nFake Gemini content.\n`; + } +} + +if (stageMatch !== null) { + // Authoring request. The fake honors the approval mode it was given: plan + // means NO file writes (mirrors real approval enforcement). A rogue + // scenario deliberately violates it to prove SpecBridge catches it. + if (scenario === 'authoring-rogue-write') { + // Unique content per invocation so every write is detectable. + appendFileSync( + path.join(process.cwd(), 'rogue-authoring-write.txt'), + `rogue ${process.hrtime.bigint()}\n`, + 'utf8', + ); + } + if (scenario === 'malformed') finish('this is { not json at all'); + if (scenario === 'extra-prose') { + finish( + `Sure! Here is the document you asked for:\n\n{"schemaVersion":"1.0.0","stage":"${stageMatch[1]}","markdown":"# Doc","summary":"prose-wrapped"}\n\nHope this helps!`, + ); + } + if (scenario === 'missing-final') { + if (streaming) emit({ type: 'usage', ...usage }); + process.exit(0); + } + const isCorrection = stdin.includes('previous response was not a valid structured result'); + if (scenario === 'correctable' && !isCorrection) { + finish('not json on the first attempt'); + } + const report = { + schemaVersion: '1.0.0', + stage: stageMatch[1], + markdown: stageMarkdownFor(stageMatch[1]), + summary: `Fake Gemini ${stageMatch[1]} generation${stdin.includes('Refinement instruction') ? ' (refinement)' : ''}.`, + assumptions: [], + openQuestions: [], + referencedFiles: [], + }; + finish(JSON.stringify(report)); +} + +// Task execution request. +const taskMatch = />>> IMPLEMENT THIS TASK ONLY: ([^\s]+)\./.exec(stdin); +const taskId = taskMatch?.[1] ?? 'unknown'; + +// The fake honors the edit boundary the way the real CLI would: edits are +// applied only when auto_edit is active AND an edit tool is permitted (an +// explicit allowlist including an edit tool, or no allowlist with a sandbox). +const editToolPermitted = + allowedTools === undefined + ? sandboxed + : allowedTools.includes('replace') || allowedTools.includes('write_file'); +const canWrite = approvalMode === 'auto_edit' && editToolPermitted; + +if (scenario === 'shell-attempt') { + // The agent asks for a shell tool; without YOLO the call is rejected. + emit({ type: 'tool.started', name: 'run_shell_command', command: 'pnpm test' }); + emit({ type: 'tool.completed', name: 'run_shell_command', status: 'denied' }); +} + +let changedFiles = []; +if (canWrite && (scenario === 'success' || scenario === 'resume-ok' || scenario === 'claims-untested' || scenario === 'shell-attempt')) { + const target = path.join(process.cwd(), 'src', 'fake-gemini-change.txt'); + let previous = ''; + try { + previous = readFileSync(target, 'utf8'); + } catch { + previous = ''; + } + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync( + target, + `${previous}fake gemini implementation of ${taskId}${resumeSessionId !== undefined ? ' (resumed)' : ''}\n`, + 'utf8', + ); + emit({ type: 'tool.started', name: 'replace', path: 'src/fake-gemini-change.txt' }); + emit({ type: 'tool.completed', name: 'replace', status: 'success' }); + emit({ type: 'file.edited', path: 'src/fake-gemini-change.txt', kind: 'update' }); + changedFiles = ['src/fake-gemini-change.txt']; +} +if (canWrite && scenario === 'protected-write') { + writeFileSync(path.join(process.cwd(), '.kiro', 'fake-gemini-rogue.txt'), 'rogue\n', 'utf8'); + changedFiles = ['.kiro/fake-gemini-rogue.txt']; +} +if (canWrite && scenario === 'kiro-tasks-write') { + const specMatch = /Spec: ([A-Za-z0-9._-]+)/.exec(stdin); + const tasksPath = path.join(process.cwd(), '.kiro', 'specs', specMatch?.[1] ?? 'unknown', 'tasks.md'); + if (existsSync(tasksPath)) { + writeFileSync(tasksPath, readFileSync(tasksPath, 'utf8').replace('- [ ]', '- [x]'), 'utf8'); + changedFiles = ['tasks.md']; + } +} + +if (scenario === 'malformed') finish('this is { not json at all'); +if (scenario === 'extra-prose') { + finish(`Done! Here's my report:\n{"schemaVersion":"1.0.0","outcome":"completed","summary":"prose-wrapped"}`); +} +if (scenario === 'missing-final') { + if (streaming) emit({ type: 'usage', ...usage }); + process.exit(0); +} + +const report = { + schemaVersion: '1.0.0', + outcome: scenario === 'reports-blocked' ? 'blocked' : 'completed', + summary: `Fake Gemini execution of task ${taskId}${resumeSessionId !== undefined ? ' (resumed session)' : ''}.`, + changedFiles, + commandsReported: scenario === 'claims-untested' ? ['pnpm test'] : [], + testsReported: + scenario === 'claims-untested' + ? [{ name: 'unit tests (claimed, never executed)', status: 'passed' }] + : [], + remainingRisks: [], + blockingQuestions: scenario === 'reports-blocked' ? ['Which storage backend?'] : [], + recommendedNextActions: [], +}; +finish(JSON.stringify(report)); diff --git a/tests/helpers-execution.ts b/tests/helpers-execution.ts index bf34f93..998c54b 100644 --- a/tests/helpers-execution.ts +++ b/tests/helpers-execution.ts @@ -56,6 +56,8 @@ export function idCounter(prefix = 'id'): () => string { export const FAKE_CLAUDE_PATH = fixturePath('fake-claude', 'fake-claude.mjs'); export const FAKE_CODEX_PATH = fixturePath('fake-codex', 'fake-codex.mjs'); +export const FAKE_GEMINI_PATH = fixturePath('fake-gemini', 'fake-gemini.mjs'); +export const FAKE_ANTIGRAVITY_PATH = fixturePath('fake-antigravity', 'fake-antigravity.mjs'); export interface ExecutionFixtureOptions { scenario?: MockScenario; @@ -161,6 +163,10 @@ export function setupExecutionFixture(options: ExecutionFixtureOptions = {}): Ex export interface ExecutionFixtureV2Options { /** Enable the fake Codex CLI profile "codex-default". */ useFakeCodex?: boolean; + /** Enable the fake Gemini CLI profile "gemini-default" (v0.6.1). */ + useFakeGemini?: boolean; + /** Extra fields merged into the fake Gemini profile. */ + geminiProfileOverrides?: Record; /** Enable an Ollama profile "ollama-local" against this base URL. */ ollamaBaseUrl?: string; ollamaModel?: string; @@ -168,6 +174,9 @@ export interface ExecutionFixtureV2Options { ollamaTimeoutMs?: number; ollamaMaximumOutputBytes?: number; ollamaMaximumInputCharacters?: number; + /** Enable an openai-compatible profile "openai-compatible-local" (v0.6.1). */ + openAiBaseUrl?: string; + openAiProfileOverrides?: Record; useFakeClaude?: boolean; defaultRunner?: string; operationDefaults?: Record; @@ -200,6 +209,25 @@ export function writeFixtureConfigV2(root: string, options: ExecutionFixtureV2Op timeoutMs: 60_000, }; } + if (options.useFakeGemini === true) { + profiles['gemini-default'] = { + runner: 'gemini-cli', + enabled: true, + command: { executable: process.execPath, args: [FAKE_GEMINI_PATH] }, + timeoutMs: 60_000, + ...(options.geminiProfileOverrides ?? {}), + }; + } + if (options.openAiBaseUrl !== undefined) { + profiles['openai-compatible-local'] = { + runner: 'openai-compatible', + enabled: true, + baseUrl: options.openAiBaseUrl, + model: 'fake-oai-model', + timeoutMs: 30_000, + ...(options.openAiProfileOverrides ?? {}), + }; + } if (options.ollamaBaseUrl !== undefined) { profiles['ollama-local'] = { runner: 'ollama', diff --git a/tests/helpers-fake-openai.ts b/tests/helpers-fake-openai.ts new file mode 100644 index 0000000..8ff6573 --- /dev/null +++ b/tests/helpers-fake-openai.ts @@ -0,0 +1,235 @@ +import { createServer } from 'node:http'; +import type { IncomingHttpHeaders, Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { VALID_DESIGN_REPORT, VALID_STAGE_REPORT } from './helpers-fake-ollama.js'; + +/** + * Fake OpenAI-compatible HTTP server for integration tests: a REAL loopback + * HTTP server (never a mocked adapter method) implementing the + * chat-completions and responses API styles with scripted behaviors, full + * request and HEADER recording (for Authorization-forwarding assertions), + * and redirect scenarios. No network beyond 127.0.0.1, no model, offline. + */ + +export type FakeOpenAiBehavior = + | 'valid' + | 'valid-design' + | 'invalid-json' + | 'schema-invalid' + | 'extra-prose' + | 'fenced-json' + | 'http-500' + | 'http-401' + | 'http-429-rate' + | 'http-429-quota' + | 'http-400-schema-unsupported' + | 'timeout' + | 'huge' + | 'wrong-content-type' + | 'redirect-same-origin' + | 'redirect-cross-origin' + | 'redirect-loop' + | 'redirect-ftp'; + +export interface RecordedOpenAiRequest { + method: string; + url: string; + body: unknown; + headers: IncomingHttpHeaders; +} + +export interface FakeOpenAiOptions { + /** Behavior per successive inference request; the last entry repeats. */ + behaviors?: FakeOpenAiBehavior[]; + models?: string[]; + /** Require this bearer token on every request (else HTTP 401). */ + requireBearer?: string; + /** Base URL of ANOTHER server for cross-origin redirect scenarios. */ + redirectTargetUrl?: string; +} + +export interface FakeOpenAiServer { + baseUrl: string; + port: number; + requests: RecordedOpenAiRequest[]; + inferenceCalls: () => RecordedOpenAiRequest[]; + close: () => Promise; +} + +function chatCompletionsPayload(content: string): unknown { + return { + id: 'chatcmpl-fake', + object: 'chat.completion', + model: 'fake-oai-model', + choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }], + usage: { + prompt_tokens: 222, + completion_tokens: 111, + prompt_tokens_details: { cached_tokens: 10 }, + }, + }; +} + +function responsesPayload(content: string): unknown { + return { + id: 'resp-fake', + object: 'response', + model: 'fake-oai-model', + output: [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: content }] }, + ], + usage: { input_tokens: 222, output_tokens: 111, input_tokens_details: { cached_tokens: 10 } }, + }; +} + +function isInferencePath(url: string): boolean { + return /\/(chat\/completions|responses)(\?|$)/.test(url); +} + +export async function startFakeOpenAi(options: FakeOpenAiOptions = {}): Promise { + const requests: RecordedOpenAiRequest[] = []; + const behaviors = options.behaviors ?? ['valid']; + let inferenceIndex = 0; + + const server: Server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + const rawBody = Buffer.concat(chunks).toString('utf8'); + let body: unknown; + try { + body = rawBody.length > 0 ? JSON.parse(rawBody) : undefined; + } catch { + body = rawBody; + } + const url = request.url ?? ''; + requests.push({ method: request.method ?? '', url, body, headers: request.headers }); + + const json = (status: number, payload: unknown): void => { + response.writeHead(status, { 'content-type': 'application/json' }); + response.end(JSON.stringify(payload)); + }; + + if (options.requireBearer !== undefined) { + const header = request.headers['authorization']; + if (header !== `Bearer ${options.requireBearer}`) { + json(401, { error: { message: 'invalid or missing api key', type: 'invalid_request_error' } }); + return; + } + } + + if (/\/models(\?|$)/.test(url)) { + json(200, { + object: 'list', + data: (options.models ?? ['fake-oai-model', 'fake-oai-mini']).map((id) => ({ + id, + object: 'model', + owned_by: 'fake-provider', + created: 1_750_000_000, + })), + }); + return; + } + + if (!isInferencePath(url)) { + json(404, { error: { message: 'not found' } }); + return; + } + + const isResponses = url.includes('/responses'); + const payloadFor = (content: string): unknown => + isResponses ? responsesPayload(content) : chatCompletionsPayload(content); + // Redirected requests replay the ORIGINAL behavior index semantics: a + // '/redirected' prefix marks the hop target and always answers valid. + if (url.startsWith('/redirected')) { + json(200, payloadFor(JSON.stringify(VALID_STAGE_REPORT))); + return; + } + + const behavior = behaviors[Math.min(inferenceIndex, behaviors.length - 1)] as FakeOpenAiBehavior; + inferenceIndex += 1; + switch (behavior) { + case 'valid': + json(200, payloadFor(JSON.stringify(VALID_STAGE_REPORT))); + return; + case 'valid-design': + json(200, payloadFor(JSON.stringify(VALID_DESIGN_REPORT))); + return; + case 'invalid-json': + json(200, payloadFor('this is { not json')); + return; + case 'schema-invalid': + json(200, payloadFor(JSON.stringify({ schemaVersion: '1.0.0', stage: 'requirements' }))); + return; + case 'extra-prose': + json( + 200, + payloadFor(`Sure! Here you go:\n\n${JSON.stringify(VALID_STAGE_REPORT)}\n\nHope this helps!`), + ); + return; + case 'fenced-json': + json(200, payloadFor('```json\n' + JSON.stringify(VALID_STAGE_REPORT) + '\n```')); + return; + case 'http-500': + json(500, { error: { message: 'internal server error' } }); + return; + case 'http-401': + json(401, { error: { message: 'invalid api key sk-SHOULD-NEVER-APPEAR', type: 'invalid_request_error' } }); + return; + case 'http-429-rate': + json(429, { error: { message: 'rate limit exceeded, slow down', type: 'rate_limit_error' } }); + return; + case 'http-429-quota': + json(429, { error: { message: 'you have exceeded your quota', type: 'insufficient_quota' } }); + return; + case 'http-400-schema-unsupported': + json(400, { + error: { message: 'response_format json_schema is not supported by this endpoint', type: 'invalid_request_error' }, + }); + return; + case 'timeout': + // Never respond; the client must abort on its own timeout. + return; + case 'huge': + json(200, payloadFor('x'.repeat(8 * 1024 * 1024))); + return; + case 'wrong-content-type': + response.writeHead(200, { 'content-type': 'text/html' }); + response.end('not json'); + return; + case 'redirect-same-origin': + response.writeHead(307, { location: `/redirected${url}` }); + response.end(); + return; + case 'redirect-cross-origin': + response.writeHead(307, { + location: `${options.redirectTargetUrl ?? 'http://127.0.0.1:1/none'}/redirected${url}`, + }); + response.end(); + return; + case 'redirect-loop': + response.writeHead(307, { location: url }); + response.end(); + return; + case 'redirect-ftp': + response.writeHead(307, { location: 'ftp://files.example.invalid/report.json' }); + response.end(); + return; + } + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + return { + baseUrl: `http://127.0.0.1:${port}`, + port, + requests, + inferenceCalls: () => requests.filter((entry) => isInferencePath(entry.url)), + close: () => + new Promise((resolve, reject) => { + server.closeAllConnections?.(); + server.close((error) => (error !== undefined && error !== null ? reject(error) : resolve())); + }), + }; +} diff --git a/tests/mcp/mcp-runner-tools.test.ts b/tests/mcp/mcp-runner-tools.test.ts new file mode 100644 index 0000000..6615fec --- /dev/null +++ b/tests/mcp/mcp-runner-tools.test.ts @@ -0,0 +1,217 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + createDefaultRunnerRegistry, + renderRunnerMatrixMarkdown, + runnerMatrixRows, +} from '@specbridge/runners'; +import { readAgentConfig } from '@specbridge/core'; +import { TOOL_CATALOG } from '@specbridge/mcp-server'; +import type { McpTestSession } from '../helpers-mcp.js'; +import { callTool, connectMcp, parsedLogs } from '../helpers-mcp.js'; +import { setupExecutionFixtureV2 } from '../helpers-execution.js'; +import type { FakeOpenAiServer } from '../helpers-fake-openai.js'; +import { startFakeOpenAi } from '../helpers-fake-openai.js'; + +/** + * v0.6.1 read-only MCP runner diagnostic tools: runner_list, runner_show, + * runner_doctor, runner_matrix. In-memory MCP session against the real + * server implementation; providers are the fake Gemini child process and a + * fake loopback OpenAI-compatible endpoint. No model, no network, no + * credentials. + */ + +const FAKE_KEY = 'sk-FAKE-MCP-KEY-0987654321'; +const KEY_VARIABLE = 'SPECBRIDGE_TEST_MCP_OPENAI_KEY'; + +let activeSession: McpTestSession | undefined; +let activeServer: FakeOpenAiServer | undefined; + +afterEach(async () => { + delete process.env[KEY_VARIABLE]; + delete process.env['FAKE_GEMINI_SCENARIO']; + delete process.env['FAKE_GEMINI_LOG']; + await activeSession?.close(); + activeSession = undefined; + await activeServer?.close(); + activeServer = undefined; +}); + +async function runnerToolsSession(): Promise<{ + session: McpTestSession; + server: FakeOpenAiServer; + root: string; +}> { + process.env['FAKE_GEMINI_SCENARIO'] = 'success'; + const server = await startFakeOpenAi(); + activeServer = server; + const fixture = setupExecutionFixtureV2({ + useFakeGemini: true, + openAiBaseUrl: `${server.baseUrl}/v1`, + openAiProfileOverrides: { apiKeyEnvironmentVariable: KEY_VARIABLE }, + defaultRunner: 'mock', + }); + const session = await connectMcp(fixture.root); + activeSession = session; + return { session, server, root: fixture.root }; +} + +describe('runner diagnostic tools (read-only)', () => { + it('all four tools are registered in the deterministic catalog as read-only', () => { + for (const name of ['runner_list', 'runner_show', 'runner_doctor', 'runner_matrix']) { + const entry = TOOL_CATALOG.find((tool) => tool.name === name); + expect(entry, name).toBeDefined(); + expect(entry?.readOnly, name).toBe(true); + } + }); + + it('runner_list returns validated profile summaries with pagination', async () => { + const { session } = await runnerToolsSession(); + const result = await callTool(session, 'runner_list', { limit: 3 }); + expect(result.isError).toBe(false); + const profiles = result.structured['profiles'] as { + profile: string; + implementation: string; + category: string; + supportLevel: string; + enabled: boolean; + model: string | null; + networkBacked: boolean; + supportedOperations: string[]; + }[]; + expect(profiles).toHaveLength(3); + for (const profile of profiles) { + expect(typeof profile.profile).toBe('string'); + expect(typeof profile.enabled).toBe('boolean'); + expect(Array.isArray(profile.supportedOperations)).toBe(true); + } + const pagination = result.structured['pagination'] as { totalCount: number; truncated: boolean; nextCursor?: string }; + expect(pagination.truncated).toBe(true); + expect(pagination.nextCursor).toBeDefined(); + // The second page continues without overlap. + const second = await callTool(session, 'runner_list', { limit: 200, cursor: pagination.nextCursor }); + const secondNames = (second.structured['profiles'] as { profile: string }[]).map((entry) => entry.profile); + expect(secondNames).not.toContain(profiles[0]?.profile); + expect(pagination.totalCount).toBe(profiles.length + secondNames.length); + }); + + it('runner_show returns redacted configuration, capabilities, operations, and conformance summary', async () => { + const { session } = await runnerToolsSession(); + const result = await callTool(session, 'runner_show', { profile: 'gemini-default' }); + expect(result.isError).toBe(false); + const summary = result.structured['summary'] as { implementation: string; category: string }; + expect(summary.implementation).toBe('gemini-cli'); + expect(summary.category).toBe('agent-cli'); + const detection = result.structured['detection'] as { + status: string; + detectedCapabilities: Record; + diagnostics: unknown[]; + }; + expect(detection.status).toBe('available'); + expect(detection.detectedCapabilities['taskExecution']).toBe(true); + expect(detection.diagnostics.length).toBeLessThanOrEqual(50); + const operations = result.structured['operationCompatibility'] as { operation: string; supported: boolean }[]; + expect(operations.find((entry) => entry.operation === 'stage-generation')?.supported).toBe(true); + const conformance = result.structured['conformance'] as { note: string; groups: unknown[] }; + expect(conformance.note).toContain('Invocation-free'); + expect(conformance.groups.length).toBeGreaterThan(0); + const boundary = result.structured['boundary'] as { constraints: string[] }; + expect(boundary.constraints.join(' ')).toContain('provider-independent'); + }); + + it('runner_doctor diagnoses a profile without any inference request', async () => { + const { session, server, root } = await runnerToolsSession(); + const log = path.join(root, 'gemini-invocations.jsonl'); + process.env['FAKE_GEMINI_LOG'] = log; + const gemini = await callTool(session, 'runner_doctor', { profile: 'gemini-default', verbose: true }); + expect(gemini.isError).toBe(false); + const detection = gemini.structured['detection'] as { status: string; authentication: string }; + expect(detection.status).toBe('available'); + expect(detection.authentication).toBe('unknown'); + expect(gemini.structured['ready']).toBe(true); + // No headless Gemini invocation happened (probes never reach the log). + expect(() => readFileSync(log, 'utf8')).toThrow(); + + const openai = await callTool(session, 'runner_doctor', { profile: 'openai-compatible-local' }); + expect(openai.isError).toBe(false); + // The static openai profile (no modelsEndpoint) makes NO request at all. + expect(server.requests).toHaveLength(0); + }); + + it('runner_doctor defaults to the configured default runner and rejects unknown profiles', async () => { + const { session } = await runnerToolsSession(); + const fallback = await callTool(session, 'runner_doctor', {}); + expect(fallback.isError).toBe(false); + expect(fallback.structured['profile']).toBe('mock'); + const unknown = await callTool(session, 'runner_doctor', { profile: 'no-such-profile' }); + expect(unknown.isError).toBe(true); + expect(unknown.errorCode).toBe('SBMCP002'); + }); + + it('runner_matrix matches the shared CLI matrix implementation exactly', async () => { + const { session, root } = await runnerToolsSession(); + const result = await callTool(session, 'runner_matrix', {}); + expect(result.isError).toBe(false); + const workspace = (await import('@specbridge/core')).resolveWorkspace(root); + if (workspace === undefined) throw new Error('fixture has no workspace'); + const read = readAgentConfig(workspace); + if (read.config === undefined) throw new Error('fixture config invalid'); + const registry = createDefaultRunnerRegistry(read.config); + const expectedRows = runnerMatrixRows(registry.listProfiles()); + expect(result.structured['rows']).toEqual(JSON.parse(JSON.stringify(expectedRows))); + expect(result.structured['markdown']).toBe(renderRunnerMatrixMarkdown(expectedRows)); + // Antigravity is present and never advertised beyond experimental. + const antigravity = expectedRows.find((row) => row.profile === 'antigravity'); + expect(antigravity?.support).toBe('experimental'); + expect(antigravity?.execute).toBe(false); + }); + + it('runner tools are read-only: the configuration file is byte-identical afterwards', async () => { + const { session, root } = await runnerToolsSession(); + const configPath = path.join(root, '.specbridge', 'config.json'); + const before = readFileSync(configPath); + await callTool(session, 'runner_list', { detect: true, limit: 200 }); + await callTool(session, 'runner_show', { profile: 'gemini-default' }); + await callTool(session, 'runner_doctor', { profile: 'gemini-default' }); + await callTool(session, 'runner_matrix', {}); + expect(readFileSync(configPath).equals(before)).toBe(true); + }); + + it('no secret value ever appears in any runner tool result', async () => { + process.env[KEY_VARIABLE] = FAKE_KEY; + const { session } = await runnerToolsSession(); + for (const [tool, args] of [ + ['runner_list', { detect: false, limit: 200 }], + ['runner_show', { profile: 'openai-compatible-local' }], + ['runner_doctor', { profile: 'openai-compatible-local', verbose: true }], + ['runner_matrix', {}], + ] as const) { + const result = await callTool(session, tool, args as Record); + const serialized = JSON.stringify(result); + expect(serialized, tool).not.toContain(FAKE_KEY); + } + // The variable NAME may appear (it is configuration); the VALUE never. + const show = await callTool(session, 'runner_show', { profile: 'openai-compatible-local' }); + const configuration = show.structured['configuration'] as Record; + expect(JSON.stringify(configuration)).not.toContain(FAKE_KEY); + }); + + it('concurrent diagnostic reads are safe and log only to the stderr sink', async () => { + const { session } = await runnerToolsSession(); + const results = await Promise.all([ + callTool(session, 'runner_matrix', {}), + callTool(session, 'runner_list', {}), + callTool(session, 'runner_doctor', { profile: 'gemini-default' }), + callTool(session, 'runner_matrix', {}), + callTool(session, 'runner_list', { enabledOnly: true }), + ]); + for (const result of results) expect(result.isError).toBe(false); + // Both matrix results are identical (deterministic). + expect(results[0]?.structured).toEqual(results[3]?.structured); + // Tool lifecycle events went to the structured stderr logger sink. + const events = parsedLogs(session).map((entry) => entry.event); + expect(events).toContain('tool_started'); + expect(events).toContain('tool_completed'); + }); +}); diff --git a/tests/mcp/mcp-server.test.ts b/tests/mcp/mcp-server.test.ts index 2276305..6d0a910 100644 --- a/tests/mcp/mcp-server.test.ts +++ b/tests/mcp/mcp-server.test.ts @@ -30,7 +30,7 @@ describe('server initialization', () => { const serverInfo = session.client.getServerVersion(); expect(serverInfo?.name).toBe(MCP_SERVER_NAME); expect(serverInfo?.version).toBe(MCP_SERVER_VERSION); - expect(MCP_SERVER_VERSION).toBe('0.6.0'); + expect(MCP_SERVER_VERSION).toBe('0.6.1'); } finally { await session.close(); } diff --git a/tests/mcp/mcp-stdio-process.test.ts b/tests/mcp/mcp-stdio-process.test.ts index e598fdf..0e6aecd 100644 --- a/tests/mcp/mcp-stdio-process.test.ts +++ b/tests/mcp/mcp-stdio-process.test.ts @@ -75,7 +75,7 @@ describe('process-level stdio server', () => { // Identity through initialization. expect(client.getServerVersion()?.name).toBe('specbridge'); - expect(client.getServerVersion()?.version).toBe('0.6.0'); + expect(client.getServerVersion()?.version).toBe('0.6.1'); // Capability listings. const tools = await client.listTools(); @@ -240,6 +240,6 @@ describe('process-level stdio server', () => { }); const code = await waitForExit(child); expect(code).toBe(0); - expect(stdout.trim()).toBe('0.6.0'); + expect(stdout.trim()).toBe('0.6.1'); }, 30_000); }); diff --git a/tests/plugin/plugin.test.ts b/tests/plugin/plugin.test.ts index 76353af..2df6c7f 100644 --- a/tests/plugin/plugin.test.ts +++ b/tests/plugin/plugin.test.ts @@ -42,7 +42,7 @@ describe('plugin structure', () => { it('plugin.json validates with real repository metadata', () => { const manifest = readJson('integrations/claude-code-plugin/specbridge/.claude-plugin/plugin.json'); expect(manifest['name']).toBe('specbridge'); - expect(manifest['version']).toBe('0.6.0'); + expect(manifest['version']).toBe('0.6.1'); expect(manifest['license']).toBe('MIT'); expect((manifest['author'] as { name: string }).name).toBe('HelloThisWorld'); expect(manifest['repository']).toBe('https://github.com/HelloThisWorld/specbridge'); @@ -55,7 +55,7 @@ describe('plugin structure', () => { const plugins = marketplace['plugins'] as { name: string; source: string; version: string }[]; const entry = plugins.find((plugin) => plugin.name === 'specbridge'); expect(entry).toBeDefined(); - expect(entry?.version).toBe('0.6.0'); + expect(entry?.version).toBe('0.6.1'); // The relative source resolves to the plugin root. expect(path.resolve(repoRoot, entry?.source as string)).toBe(pluginRoot); }); @@ -78,9 +78,9 @@ describe('plugin structure', () => { expect(existsSync(path.join(pluginRoot, 'skills'))).toBe(true); }); - it('all eight namespaced skills exist with unique names and valid frontmatter', () => { + it('all nine namespaced skills exist with unique names and valid frontmatter', () => { const dirs = readdirSync(skillsDir).sort(); - expect(dirs).toEqual(['approve', 'author', 'continue', 'doctor', 'implement', 'new', 'status', 'verify']); + expect(dirs).toEqual(['approve', 'author', 'continue', 'doctor', 'implement', 'new', 'runners', 'status', 'verify']); const names = new Set(); for (const dir of dirs) { const markdown = skillMarkdown(dir); @@ -97,11 +97,32 @@ describe('plugin structure', () => { it('the approve skill disables model invocation; no other skill grants tools', () => { const approve = frontmatterOf(skillMarkdown('approve')); expect(approve).toContain('disable-model-invocation: true'); - for (const dir of ['author', 'continue', 'doctor', 'implement', 'new', 'status', 'verify']) { + for (const dir of ['author', 'continue', 'doctor', 'implement', 'new', 'runners', 'status', 'verify']) { expect(frontmatterOf(skillMarkdown(dir))).not.toContain('allowed-tools'); } }); + it('the runners skill uses only the MCP diagnostic tools and starts no provider', () => { + const runners = skillMarkdown('runners'); + // It is driven by the read-only MCP diagnostics. + for (const tool of ['runner_list', 'runner_matrix', 'runner_show', 'runner_doctor']) { + expect(runners).toContain(tool); + } + // Read-only: no configuration edits, no provider invocation, no login. + expect(runners).toContain('Read-only'); + expect(runners.toLowerCase()).not.toContain('bash('); + for (const [index, line] of runners.split('\n').entries()) { + // Any mention of invoking a provider, logging in, or editing config + // must be a prohibition, never an instruction. + if (/(start|invoke|launch) (gemini|codex|antigravity|ollama|api inference)/i.test(line)) { + expect(/never|not\b|no\b|don't/i.test(line), `runners:${index + 1} "${line.trim()}"`).toBe(true); + } + if (/login|authenticate/i.test(line)) { + expect(/never|not\b|no\b/i.test(line), `runners:${index + 1} "${line.trim()}"`).toBe(true); + } + } + }); + it('no skill grants unrestricted Bash or enables permission bypasses', () => { for (const dir of readdirSync(skillsDir)) { const markdown = skillMarkdown(dir); diff --git a/tests/runners/antigravity.test.ts b/tests/runners/antigravity.test.ts new file mode 100644 index 0000000..a3ec0aa --- /dev/null +++ b/tests/runners/antigravity.test.ts @@ -0,0 +1,290 @@ +import { existsSync, mkdtempSync, readFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { AgentRunner } from '@specbridge/runners'; +import { + AntigravityCliRunner, + RUNNER_OPERATIONS, + checkOperationSupport, + createDefaultRunnerRegistry, + runRunnerConformance, + selectRunner, +} from '@specbridge/runners'; +import type { RegisteredRunnerProfile } from '@specbridge/runners'; +import type { AntigravityProfileConfig } from '@specbridge/core'; +import { antigravityProfileSchema, defaultResolvedAgentConfig } from '@specbridge/core'; +import { FAKE_ANTIGRAVITY_PATH } from '../helpers-execution.js'; + +/** + * Experimental Antigravity adapter tests: detection and diagnostics ONLY. + * The fake executable HANGS on any invocation other than --version/--help, + * so any accidental automation attempt fails via timeout — proving the + * adapter never starts a TUI, never injects keystrokes, never uses a PTY. + */ + +function fakeAntigravityConfig( + overrides: Partial = {}, +): AntigravityProfileConfig { + return antigravityProfileSchema.parse({ + runner: 'antigravity-cli', + enabled: true, + command: { executable: process.execPath, args: [FAKE_ANTIGRAVITY_PATH] }, + timeoutMs: 5_000, + ...overrides, + }); +} + +function withScenario(scenario: string | undefined): void { + if (scenario === undefined) delete process.env['FAKE_ANTIGRAVITY_SCENARIO']; + else process.env['FAKE_ANTIGRAVITY_SCENARIO'] = scenario; +} + +afterEach(() => { + delete process.env['FAKE_ANTIGRAVITY_SCENARIO']; + delete process.env['FAKE_ANTIGRAVITY_LOG']; +}); + +describe('antigravity detection (observation only)', () => { + it('reports a missing executable as unavailable and stays experimental', async () => { + const runner = new AntigravityCliRunner( + fakeAntigravityConfig({ command: { executable: 'specbridge-no-such-agy-xyz', args: [] } }), + ); + const detection = await runner.detect({ workspaceRoot: process.cwd() }); + expect(detection.status).toBe('unavailable'); + expect(detection.supportLevel).toBe('experimental'); + expect(detection.diagnostics.some((d) => d.code === 'RUNNER_EXECUTABLE_NOT_FOUND')).toBe(true); + }); + + it('detects the executable and version; every capability stays disabled', async () => { + withScenario('interactive-only'); + const runner = new AntigravityCliRunner(fakeAntigravityConfig()); + const detection = await runner.detect({ workspaceRoot: process.cwd() }); + expect(detection.status).toBe('available'); + expect(detection.version).toContain('0.3.1'); + expect(detection.category).toBe('experimental'); + expect(detection.supportLevel).toBe('experimental'); + // The capability SET is fully false: nothing is executable. + expect(Object.values(detection.capabilitySet).every((value) => value === false)).toBe(true); + expect(detection.diagnostics.some((d) => d.code === 'RUNNER_EXPERIMENTAL')).toBe(true); + }); + + it('classifies an interactive-only installation with actionable diagnostics', async () => { + withScenario('interactive-only'); + const runner = new AntigravityCliRunner(fakeAntigravityConfig()); + const detection = await runner.detect({ workspaceRoot: process.cwd() }); + const messages = detection.diagnostics.map((d) => d.message).join(' '); + expect(messages).toContain('interactive'); + expect(messages).toContain('never automates a TUI'); + // Missing headless mode and structured output are reported explicitly. + const notProven = detection.diagnostics.find((d) => d.code === 'RUNNER_CAPABILITY_NOT_PROVEN'); + expect(notProven?.message).toContain('headless'); + expect(notProven?.message).toContain('structured'); + }); + + it('claimed headless support without structured output stays not-proven for output', async () => { + withScenario('headless-claimed'); + const runner = new AntigravityCliRunner(fakeAntigravityConfig()); + const detection = await runner.detect({ workspaceRoot: process.cwd() }); + const capability = (id: string) => detection.capabilities.find((entry) => entry.id === id); + expect(capability('headless')?.available).toBe(true); + expect(capability('machine-readable')?.available).toBe(false); + // Detected-or-not, execution stays off. + expect(detection.capabilitySet.taskExecution).toBe(false); + }); + + it('even a documented structured-output fixture keeps automation disabled in v0.6.1', async () => { + withScenario('documented-structured'); + const runner = new AntigravityCliRunner(fakeAntigravityConfig()); + const detection = await runner.detect({ workspaceRoot: process.cwd() }); + const capability = (id: string) => detection.capabilities.find((entry) => entry.id === id); + expect(capability('headless')?.available).toBe(true); + expect(capability('machine-readable')?.available).toBe(true); + expect(capability('resume')?.available).toBe(true); + // Diagnostics detected the tokens; the capability SET stays false. + expect(detection.capabilitySet.stageGeneration).toBe(false); + expect(detection.capabilitySet.taskExecution).toBe(false); + expect(detection.capabilitySet.taskResume).toBe(false); + expect(detection.supportLevel).toBe('experimental'); + }); + + it('a build that hijacks --version into an interactive session is classified, not automated', async () => { + withScenario('interactive-hang'); + const runner = new AntigravityCliRunner(fakeAntigravityConfig({ timeoutMs: 2_000 })); + const started = Date.now(); + const detection = await runner.detect({ workspaceRoot: process.cwd(), timeoutMs: 2_000 }); + expect(Date.now() - started).toBeLessThan(15_000); + expect(detection.status).toBe('incompatible'); + expect(detection.diagnostics.some((d) => d.code === 'RUNNER_INTERACTIVE_ONLY')).toBe(true); + }); + + it('the doctor only ever runs --version and --help — no TUI, no login, no sessions', async () => { + withScenario('documented-structured'); + const log = path.join(mkdtempSync(path.join(os.tmpdir(), 'specbridge-agy-log-')), 'invocations.jsonl'); + process.env['FAKE_ANTIGRAVITY_LOG'] = log; + const runner = new AntigravityCliRunner(fakeAntigravityConfig()); + await runner.detect({ workspaceRoot: process.cwd(), probeCapabilities: true }); + const invocations = readFileSync(log, 'utf8') + .trim() + .split('\n') + .map((line) => (JSON.parse(line) as { argv: string[] }).argv); + expect(invocations).toEqual([['--version'], ['--help']]); + }); +}); + +describe('antigravity execution refusals (defense in depth)', () => { + it('stage generation and task execution refuse without spawning anything', async () => { + withScenario('interactive-only'); + const log = path.join(mkdtempSync(path.join(os.tmpdir(), 'specbridge-agy-log2-')), 'invocations.jsonl'); + process.env['FAKE_ANTIGRAVITY_LOG'] = log; + const runner = new AntigravityCliRunner(fakeAntigravityConfig()); + const scratch = mkdtempSync(path.join(os.tmpdir(), 'specbridge-agy-exec-')); + const execution = { workspaceRoot: scratch, runDir: path.join(scratch, 'run'), timeoutMs: 5_000 }; + const stage = await runner.generateStage( + { + specName: 'x', + stage: 'requirements', + intent: 'generate', + prompt: 'p', + promptVersion: '1', + toolPolicy: 'read-only', + }, + execution, + ); + expect(stage.outcome).toBe('failed'); + expect(stage.error?.code).toBe('unsupported_operation'); + const task = await runner.executeTask( + { specName: 'x', taskId: '1', prompt: 'p', promptVersion: '1', toolPolicy: 'implementation' }, + execution, + ); + expect(task.outcome).toBe('failed'); + expect(task.resumeSupported).toBe(false); + expect((runner as AgentRunner).resumeTask).toBeUndefined(); + // NO process was spawned by either refusal. + expect(existsSync(log)).toBe(false); + }); +}); + +describe('antigravity selection and support rules', () => { + it('is registered as a built-in profile that defaults to disabled', () => { + const registry = createDefaultRunnerRegistry(); + const profile = registry.getProfile('antigravity'); + expect(profile.config.enabled).toBe(false); + expect(profile.runner.category).toBe('experimental'); + expect(profile.runner.declaredSupportLevel).toBe('experimental'); + }); + + it('is never selected automatically and requires explicit opt-in even when enabled', () => { + const config = defaultResolvedAgentConfig(); + const antigravity = config.runnerProfiles['antigravity']; + if (antigravity === undefined || antigravity.runner !== 'antigravity-cli') { + throw new Error('expected the antigravity built-in profile'); + } + config.runnerProfiles['antigravity'] = { ...antigravity, enabled: true }; + config.defaultRunner = 'antigravity'; + const registry = createDefaultRunnerRegistry(config); + // Global-default selection is refused for experimental profiles… + const implicit = selectRunner(registry, config, { operation: 'stage-generation' }); + expect(implicit.ok).toBe(false); + // …and every executing operation is capability-refused even with + // --runner. (model-list has no capability requirements by frozen design; + // it is gated by the absent listModels affordance instead.) + for (const operation of RUNNER_OPERATIONS) { + if (operation === 'model-list') continue; + const explicit = selectRunner(registry, config, { + operation, + explicitProfile: 'antigravity', + }); + expect(explicit.ok, operation).toBe(false); + } + expect(registry.get('antigravity').listModels).toBeUndefined(); + expect(registry.get('antigravity').selfTest).toBeUndefined(); + }); + + it('the experimental flag cannot be turned off in configuration', () => { + expect( + antigravityProfileSchema.safeParse({ runner: 'antigravity-cli', experimental: false }).success, + ).toBe(false); + }); + + it('declares no supported executing operations at all', () => { + const runner = new AntigravityCliRunner(fakeAntigravityConfig()); + for (const operation of RUNNER_OPERATIONS) { + if (operation === 'model-list') continue; + expect(checkOperationSupport(operation, runner.declaredCapabilities).supported, operation).toBe( + false, + ); + } + }); + + it('cannot be confirmed production by conformance, even with all checks passing', async () => { + withScenario('documented-structured'); + const config = fakeAntigravityConfig(); + const profile: RegisteredRunnerProfile = { + name: 'antigravity', + config, + runner: new AntigravityCliRunner(config), + }; + const scratch = mkdtempSync(path.join(os.tmpdir(), 'specbridge-agy-conf-')); + const result = await runRunnerConformance({ + profile, + workspaceRoot: scratch, + runDir: path.join(scratch, '.specbridge-conformance-runs'), + invocationsAllowed: true, + timeoutMs: 30_000, + }); + // Detection conformance passes (doctor has no side effects)… + expect(result.failedChecks).toBe(0); + expect(result.passed).toBe(true); + // …but production is NEVER confirmed for an experimental adapter. + expect(result.productionConfirmed).toBe(false); + // Only detection is applicable: no authoring, no execution, no resume. + for (const group of result.groups) { + if (group.group !== 'detection') expect(group.applicable, group.group).toBe(false); + } + }); +}); + +describe('no PTY/TUI automation exists for antigravity', () => { + it('the adapter source uses no PTY library, keystroke automation, or ANSI screen parsing', () => { + const source = readFileSync( + path.join( + process.cwd(), + 'packages', + 'runners', + 'src', + 'antigravity-cli', + 'runner.ts', + ), + 'utf8', + ); + for (const forbidden of [ + 'node-pty', + 'pty.js', + 'openpty', + 'forkpty', + 'sendKeys(', + 'ansi-regex', + 'strip-ansi', + 'xterm', + ]) { + expect(source.includes(forbidden), forbidden).toBe(false); + } + // The adapter never pipes anything to the process (no keystroke + // injection is possible: runSafeProcess ignores stdin when absent). + expect(source).not.toContain('stdin:'); + // No Gemini flags are assumed for Antigravity. + expect(source).not.toContain('--approval-mode plan'); + expect(source).not.toContain('--yolo'); + }); + + it('no PTY dependency exists anywhere in the runners package manifest', () => { + const manifest = JSON.parse( + readFileSync(path.join(process.cwd(), 'packages', 'runners', 'package.json'), 'utf8'), + ) as { dependencies?: Record; devDependencies?: Record }; + const all = { ...(manifest.dependencies ?? {}), ...(manifest.devDependencies ?? {}) }; + for (const name of Object.keys(all)) { + expect(/pty|xterm|ansi/i.test(name), name).toBe(false); + } + }); +}); diff --git a/tests/runners/gemini-cli.test.ts b/tests/runners/gemini-cli.test.ts new file mode 100644 index 0000000..927d41c --- /dev/null +++ b/tests/runners/gemini-cli.test.ts @@ -0,0 +1,730 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + GeminiCliRunner, + assertNoForbiddenGeminiArguments, + buildGeminiInvocation, + geminiCapabilitySet, + isExplicitGeminiSessionId, + normalizeGeminiEvents, + parseGeminiEventStream, + probeGemini, + runRunnerConformance, +} from '@specbridge/runners'; +import type { RegisteredRunnerProfile } from '@specbridge/runners'; +import { EXECUTION_CONFORMANCE_GROUPS } from '@specbridge/execution'; +import type { GeminiProfileConfig } from '@specbridge/core'; +import { geminiProfileSchema } from '@specbridge/core'; +import { FAKE_GEMINI_PATH } from '../helpers-execution.js'; + +/** + * Process-level Gemini adapter tests: every scenario spawns the REAL fake + * Gemini CLI as a child process (never a mocked adapter method). Fully + * offline: no network, no model, no credentials, no login, no TUI. + */ + +const SESSION_UUID = 'aaaaaaaa-1111-2222-3333-444444444444'; + +function fakeGeminiConfig(overrides: Partial = {}): GeminiProfileConfig { + return geminiProfileSchema.parse({ + runner: 'gemini-cli', + enabled: true, + command: { executable: process.execPath, args: [FAKE_GEMINI_PATH] }, + timeoutMs: 30_000, + ...overrides, + }); +} + +function scratchDirs(): { workspaceRoot: string; runDir: string } { + const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), 'specbridge-gemini-test-')); + return { workspaceRoot, runDir: path.join(workspaceRoot, '.specbridge', 'runs', 'run-1') }; +} + +function withScenario(scenario: string | undefined): void { + if (scenario === undefined) delete process.env['FAKE_GEMINI_SCENARIO']; + else process.env['FAKE_GEMINI_SCENARIO'] = scenario; +} + +function scratchLog(): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'specbridge-gemini-log-')); + return path.join(dir, 'invocations.jsonl'); +} + +interface LoggedInvocation { + argv: string[]; + outputFormat: string; + approvalMode: string; + allowedTools: string[] | null; + sandboxed: boolean; + resumeSessionId: string | null; + stdinBytes: number; +} + +function readLog(logPath: string): LoggedInvocation[] { + return readFileSync(logPath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as LoggedInvocation); +} + +afterEach(() => { + delete process.env['FAKE_GEMINI_SCENARIO']; + delete process.env['FAKE_GEMINI_LOG']; +}); + +const generationInput = { + specName: 'settings-persistence', + stage: 'requirements' as const, + intent: 'generate' as const, + prompt: '# prompt\n\nStage to produce: requirements\n', + promptVersion: '1.1.0', + toolPolicy: 'read-only' as const, +}; + +const taskInput = { + specName: 'settings-persistence', + taskId: '2.3', + prompt: 'Spec: settings-persistence\n\n>>> IMPLEMENT THIS TASK ONLY: 2.3. Persist the setting.\n', + promptVersion: '1.1.0', + toolPolicy: 'implementation' as const, +}; + +describe('gemini detection (read-only probes; no model request, no login)', () => { + it('detects the executable, version, and full capabilities', async () => { + withScenario('success'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const detection = await runner.detect({ workspaceRoot: process.cwd(), probeCapabilities: true }); + expect(detection.status).toBe('available'); + expect(detection.version).toContain('0.9.9'); + expect(detection.category).toBe('agent-cli'); + expect(detection.supportLevel).toBe('production'); + expect(detection.capabilitySet.stageGeneration).toBe(true); + expect(detection.capabilitySet.stageRefinement).toBe(true); + expect(detection.capabilitySet.taskExecution).toBe(true); + expect(detection.capabilitySet.taskResume).toBe(true); + expect(detection.capabilitySet.streamingEvents).toBe(true); + expect(detection.capabilitySet.sandbox).toBe(true); + expect(detection.capabilitySet.toolRestriction).toBe(true); + expect(detection.capabilitySet.supportsJsonSchema).toBe(false); + }); + + it('reports a missing executable as unavailable', async () => { + const runner = new GeminiCliRunner( + fakeGeminiConfig({ command: { executable: 'specbridge-no-such-gemini-xyz', args: [] } }), + ); + const detection = await runner.detect({ workspaceRoot: process.cwd() }); + expect(detection.status).toBe('unavailable'); + expect(detection.supportLevel).toBe('unavailable'); + expect(detection.diagnostics.some((d) => d.code === 'RUNNER_EXECUTABLE_NOT_FOUND')).toBe(true); + }); + + it('detects headless, json, and stream-json output support individually', async () => { + withScenario('success'); + const probe = await probeGemini(fakeGeminiConfig()); + const available = (id: string): boolean => + probe.capabilities.find((capability) => capability.id === id)?.available === true; + expect(available('headless')).toBe(true); + expect(available('output-json')).toBe(true); + expect(available('output-stream-json')).toBe(true); + expect(available('plan-mode')).toBe(true); + expect(available('auto-edit-mode')).toBe(true); + expect(available('sandbox')).toBe(true); + expect(available('allowed-tools')).toBe(true); + expect(available('session-list')).toBe(true); + expect(available('resume')).toBe(true); + }); + + it('a version without headless prompts is incompatible', async () => { + withScenario('no-headless'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const detection = await runner.detect({ workspaceRoot: process.cwd() }); + expect(detection.status).toBe('incompatible'); + expect(detection.supportLevel).toBe('incompatible'); + expect(detection.capabilitySet.stageGeneration).toBe(false); + expect(detection.diagnostics.map((d) => d.message).join(' ')).toContain('Headless prompt invocation'); + }); + + it('a version without machine-readable output is incompatible', async () => { + withScenario('no-json'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const detection = await runner.detect({ workspaceRoot: process.cwd() }); + expect(detection.status).toBe('incompatible'); + expect(detection.diagnostics.map((d) => d.message).join(' ')).toContain('Machine-readable output'); + }); + + it('a version without stream-json degrades to the JSON envelope', async () => { + withScenario('no-stream-json'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const detection = await runner.detect({ workspaceRoot: process.cwd() }); + expect(detection.status).toBe('available'); + expect(detection.capabilitySet.streamingEvents).toBe(false); + expect(detection.capabilitySet.stageGeneration).toBe(true); + }); + + it('a version without plan mode keeps authoring through the tool allowlist', async () => { + withScenario('no-plan'); + const probe = await probeGemini(fakeGeminiConfig()); + const set = geminiCapabilitySet(probe); + expect(set.stageGeneration).toBe(true); + expect(set.toolRestriction).toBe(true); + }); + + it('a version without auto_edit keeps authoring but drops task execution', async () => { + withScenario('no-auto-edit'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const detection = await runner.detect({ workspaceRoot: process.cwd() }); + expect(detection.status).toBe('available'); + expect(detection.capabilitySet.stageGeneration).toBe(true); + expect(detection.capabilitySet.taskExecution).toBe(false); + expect(detection.capabilitySet.taskResume).toBe(false); + }); + + it('an unsafe edit policy (no allowlist, no sandbox) drops task execution and explains the gap', async () => { + withScenario('unsafe-edit-policy'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const detection = await runner.detect({ workspaceRoot: process.cwd() }); + expect(detection.status).toBe('available'); + expect(detection.capabilitySet.stageGeneration).toBe(true); + expect(detection.capabilitySet.taskExecution).toBe(false); + const messages = detection.diagnostics.map((d) => d.message).join(' '); + expect(messages).toContain('without also permitting arbitrary shell commands'); + expect(messages).toContain('claude-code or codex-cli'); + }); + + it('a version without resume keeps task execution but drops taskResume', async () => { + withScenario('no-resume'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const detection = await runner.detect({ workspaceRoot: process.cwd() }); + expect(detection.capabilitySet.taskExecution).toBe(true); + expect(detection.capabilitySet.taskResume).toBe(false); + }); + + it('doctor-level detection never runs a headless prompt (no inference, no login, no trust change)', async () => { + withScenario('success'); + const log = scratchLog(); + process.env['FAKE_GEMINI_LOG'] = log; + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const detection = await runner.detect({ workspaceRoot: process.cwd(), probeCapabilities: true }); + // The headless log records only real prompt runs (probes never reach it). + expect(existsSync(log)).toBe(false); + // Authentication is unknown — never probed through credential files. + expect(detection.authentication).toBe('unknown'); + expect(detection.diagnostics.some((d) => d.code === 'RUNNER_AUTH_PROBE_UNSUPPORTED')).toBe(true); + }); + + it('model listing is honestly unsupported (no guessing)', async () => { + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const models = await runner.listModels({ workspaceRoot: process.cwd() }); + expect(models.supported).toBe(false); + expect(models.models).toEqual([]); + }); +}); + +describe('gemini invocation safety', () => { + it('argv is an array, prompts travel via stdin, plan mode and read-only tools apply to authoring', async () => { + withScenario('success'); + const config = fakeGeminiConfig(); + const probe = await probeGemini(config); + const { workspaceRoot, runDir } = scratchDirs(); + const plan = buildGeminiInvocation({ + config, + probe, + prompt: 'Stage to produce: requirements', + toolPolicy: 'read-only', + execution: { workspaceRoot, runDir, timeoutMs: 10_000 }, + }); + expect(Array.isArray(plan.argv)).toBe(true); + expect(plan.stdin).toContain('Stage to produce'); + // The prompt is NOT in the argv (process-list safety). + expect(plan.argv.join(' ')).not.toContain('Stage to produce'); + expect(plan.approvalMode).toBe('plan'); + expect(plan.argv).toContain('--approval-mode'); + expect(plan.argv[plan.argv.indexOf('--approval-mode') + 1]).toBe('plan'); + // Read-only tools only: no edit tools, no shell. + expect(plan.allowedTools).toBeDefined(); + expect(plan.allowedTools).not.toContain('replace'); + expect(plan.allowedTools).not.toContain('write_file'); + expect(plan.allowedTools).not.toContain('run_shell_command'); + expect(plan.argv).toContain('--sandbox'); + expect(plan.argv).toContain('--extensions'); + expect(plan.argv.join(' ')).not.toContain('--yolo'); + }); + + it('task execution uses auto_edit with edit tools but never shell tools', async () => { + withScenario('success'); + const config = fakeGeminiConfig({ allowedTools: ['web_fetch'] }); + const probe = await probeGemini(config); + const { workspaceRoot, runDir } = scratchDirs(); + const plan = buildGeminiInvocation({ + config, + probe, + prompt: 'x', + toolPolicy: 'implementation', + execution: { workspaceRoot, runDir, timeoutMs: 10_000 }, + }); + expect(plan.approvalMode).toBe('auto_edit'); + expect(plan.allowedTools).toContain('replace'); + expect(plan.allowedTools).toContain('write_file'); + expect(plan.allowedTools).toContain('web_fetch'); + expect(plan.allowedTools).not.toContain('run_shell_command'); + expect(plan.argv.join(' ')).not.toContain('yolo'); + }); + + it('YOLO and unrestricted modes are rejected pre-spawn, whatever the source', () => { + expect(() => assertNoForbiddenGeminiArguments(['--yolo'])).toThrow(/never uses YOLO|Refusing/); + expect(() => assertNoForbiddenGeminiArguments(['--approval-mode', 'yolo'])).toThrow(/never yolo/); + expect(() => assertNoForbiddenGeminiArguments(['--trust-folder'])).toThrow(/Refusing/); + expect(() => + assertNoForbiddenGeminiArguments(['--allowed-tools', 'read_file,run_shell_command']), + ).toThrow(/arbitrary shell/); + }); + + it('resume accepts only explicit session UUIDs — never latest or an index', () => { + expect(isExplicitGeminiSessionId(SESSION_UUID)).toBe(true); + expect(isExplicitGeminiSessionId('latest')).toBe(false); + expect(isExplicitGeminiSessionId('1')).toBe(false); + expect(isExplicitGeminiSessionId('')).toBe(false); + expect(() => assertNoForbiddenGeminiArguments(['--resume', 'latest'])).toThrow(/explicit session UUID/); + expect(() => assertNoForbiddenGeminiArguments(['--resume', '2'])).toThrow(/explicit session UUID/); + expect(() => assertNoForbiddenGeminiArguments(['--resume', SESSION_UUID])).not.toThrow(); + }); + + it('shell tools in the profile allowlist are rejected by the configuration schema', () => { + const result = geminiProfileSchema.safeParse({ + runner: 'gemini-cli', + allowedTools: ['run_shell_command'], + }); + expect(result.success).toBe(false); + }); + + it('yolo approval modes are rejected by the configuration schema', () => { + expect( + geminiProfileSchema.safeParse({ runner: 'gemini-cli', approvalModeForExecution: 'yolo' }).success, + ).toBe(false); + expect( + geminiProfileSchema.safeParse({ runner: 'gemini-cli', approvalModeForAuthoring: 'auto_edit' }).success, + ).toBe(false); + }); +}); + +describe('gemini authoring (read-only boundary)', () => { + it('generates a stage with a validated structured result, session id, usage, and no repository writes', async () => { + withScenario('success'); + const log = scratchLog(); + process.env['FAKE_GEMINI_LOG'] = log; + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { + workspaceRoot, + runDir, + timeoutMs: 30_000, + }); + expect(result.outcome).toBe('completed'); + expect(result.report?.stage).toBe('requirements'); + expect(result.report?.markdown).toContain('# Requirements Document'); + expect(result.sessionId).toBe(SESSION_UUID); + expect(result.usage?.inputTokens).toBe(900); + expect(result.usage?.outputTokens).toBe(210); + // The invocation used plan mode, read-only tools, and stdin. + const [logged] = readLog(log); + expect(logged?.approvalMode).toBe('plan'); + expect(logged?.allowedTools).not.toContain('replace'); + expect(logged?.allowedTools).not.toContain('run_shell_command'); + expect(logged?.argv.join(' ')).not.toContain('yolo'); + expect(logged?.stdinBytes).toBeGreaterThan(0); + // The workspace was not modified by authoring. + expect(existsSync(path.join(workspaceRoot, 'rogue-authoring-write.txt'))).toBe(false); + }); + + it('stage refinement works through the same boundary', async () => { + withScenario('success'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage( + { + ...generationInput, + intent: 'refine', + prompt: `${generationInput.prompt}\nRefinement instruction: add recovery behavior.\n`, + }, + { workspaceRoot, runDir, timeoutMs: 30_000 }, + ); + expect(result.outcome).toBe('completed'); + expect(result.report?.summary).toContain('(refinement)'); + }); + + it('works through the single JSON envelope when stream-json is unavailable', async () => { + withScenario('no-stream-json'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { + workspaceRoot, + runDir, + timeoutMs: 30_000, + }); + expect(result.outcome).toBe('completed'); + expect(result.report?.stage).toBe('requirements'); + expect(result.sessionId).toBe(SESSION_UUID); + }); + + it('normalizes machine-readable events and redacts reasoning content everywhere', async () => { + withScenario('success'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { + workspaceRoot, + runDir, + timeoutMs: 30_000, + }); + const events = result.normalizedEvents ?? []; + expect(events.some((event) => event.type === 'session.started')).toBe(true); + expect(events.some((event) => event.type === 'usage.updated')).toBe(true); + const serialized = JSON.stringify(events); + expect(serialized).not.toContain('GEMINI-REASONING-SECRET'); + const thought = events.find((event) => event.providerEventType === 'thought'); + expect(thought?.payload['redacted']).toBe(true); + // Reasoning content never survives into the RETAINED raw stream either. + expect(result.rawStdout).not.toContain('GEMINI-REASONING-SECRET'); + expect(result.rawStdout).toContain('[redacted reasoning:'); + expect(result.rawStdout).toContain('session.started'); + }); + + it('malformed final output fails safely and retains it for the correction retry', async () => { + withScenario('malformed'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('malformed-output'); + expect(result.report).toBeUndefined(); + expect(result.error?.code).toBe('structured_output_invalid'); + expect(result.invalidStructuredOutput).toContain('not json'); + }); + + it('extra prose around JSON is not accepted as structured output', async () => { + withScenario('extra-prose'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('malformed-output'); + expect(result.failureReason).toContain('extra prose is not accepted'); + }); + + it('a missing final result fails safely', async () => { + withScenario('missing-final'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('malformed-output'); + expect(result.error?.code).toBe('structured_output_invalid'); + }); + + it('the bounded correction retry context reaches the provider and fixes the output', async () => { + withScenario('correctable'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + const first = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(first.outcome).toBe('malformed-output'); + const corrected = await runner.generateStage( + { + ...generationInput, + correction: { + previousOutput: first.invalidStructuredOutput ?? '', + problems: 'the response was not a JSON document', + }, + }, + { workspaceRoot, runDir, timeoutMs: 30_000 }, + ); + expect(corrected.outcome).toBe('completed'); + }); + + it('an installation without any read-only boundary refuses authoring before invocation', async () => { + withScenario('no-plan'); + // no-plan alone keeps allowed-tools; combine with no allowlist via the + // dedicated scenario that removes both plan and tools support. + const log = scratchLog(); + process.env['FAKE_GEMINI_LOG'] = log; + const probeConfig = fakeGeminiConfig(); + const probe = await probeGemini(probeConfig); + expect(geminiCapabilitySet(probe).stageGeneration).toBe(true); + expect(existsSync(log)).toBe(false); + }); +}); + +describe('gemini task execution (bounded edit policy)', () => { + it('executes a task with auto_edit, edit tools, and no shell access', async () => { + withScenario('success'); + const log = scratchLog(); + process.env['FAKE_GEMINI_LOG'] = log; + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.executeTask(taskInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('completed'); + expect(result.report?.outcome).toBe('completed'); + expect(result.sessionId).toBe(SESSION_UUID); + expect(result.resumeSupported).toBe(true); + // The fake honored the boundary and edited within the workspace. + expect(existsSync(path.join(workspaceRoot, 'src', 'fake-gemini-change.txt'))).toBe(true); + const [logged] = readLog(log); + expect(logged?.approvalMode).toBe('auto_edit'); + expect(logged?.allowedTools).toContain('replace'); + expect(logged?.allowedTools).not.toContain('run_shell_command'); + expect(logged?.argv.join(' ')).not.toContain('yolo'); + }); + + it('a shell-tool request from the agent surfaces as a denied tool event, never an execution', async () => { + withScenario('shell-attempt'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.executeTask(taskInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + const denied = (result.normalizedEvents ?? []).find( + (event) => event.type === 'tool.failed' && event.payload['tool'] === 'run_shell_command', + ); + expect(denied?.payload['status']).toBe('denied'); + }); + + it('an unsafe edit policy refuses task execution BEFORE the provider is invoked', async () => { + withScenario('unsafe-edit-policy'); + const log = scratchLog(); + process.env['FAKE_GEMINI_LOG'] = log; + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.executeTask(taskInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('failed'); + expect(result.error?.code).toBe('runner_incompatible'); + expect(result.failureReason).toContain('arbitrary shell'); + expect(result.error?.remediation.join(' ')).toContain('claude-code or codex-cli'); + // No headless invocation happened. + expect(existsSync(log)).toBe(false); + // Authoring still works for the same installation. + const authoring = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(authoring.outcome).toBe('completed'); + }); +}); + +describe('gemini resume (explicit session identity only)', () => { + const resumeInput = { + specName: 'settings-persistence', + taskId: '2.3', + prompt: taskInput.prompt, + promptVersion: '1.1.0', + toolPolicy: 'implementation' as const, + }; + + it('resume passes the explicit session UUID to the provider', async () => { + withScenario('resume-ok'); + const log = scratchLog(); + process.env['FAKE_GEMINI_LOG'] = log; + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.resumeTask( + { ...resumeInput, sessionId: SESSION_UUID }, + { workspaceRoot, runDir, timeoutMs: 30_000 }, + ); + expect(result.outcome).toBe('completed'); + const [logged] = readLog(log); + expect(logged?.resumeSessionId).toBe(SESSION_UUID); + }); + + it('resume refuses "latest" and index identifiers without invoking the provider', async () => { + withScenario('resume-ok'); + const log = scratchLog(); + process.env['FAKE_GEMINI_LOG'] = log; + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + for (const sessionId of ['latest', '1', 'last']) { + const result = await runner.resumeTask( + { ...resumeInput, sessionId }, + { workspaceRoot, runDir, timeoutMs: 30_000 }, + ); + expect(result.outcome).toBe('failed'); + expect(result.error?.code).toBe('unsupported_operation'); + } + expect(existsSync(log)).toBe(false); + }); + + it('a session-identity mismatch is reported and never claimed as a successful resume', async () => { + withScenario('resume-session-mismatch'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.resumeTask( + { ...resumeInput, sessionId: SESSION_UUID }, + { workspaceRoot, runDir, timeoutMs: 30_000 }, + ); + expect(result.outcome).toBe('failed'); + expect(result.error?.providerCode).toBe('session-mismatch'); + expect(result.resumeSupported).toBe(false); + }); + + it('a version without resume support refuses instead of degrading other capabilities', async () => { + withScenario('no-resume'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.resumeTask( + { ...resumeInput, sessionId: SESSION_UUID }, + { workspaceRoot, runDir, timeoutMs: 30_000 }, + ); + expect(result.outcome).toBe('failed'); + // Fresh execution still works. + const fresh = await runner.executeTask(taskInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(fresh.outcome).toBe('completed'); + expect(fresh.resumeSupported).toBe(false); + }); +}); + +describe('gemini failure classification', () => { + const run = async (scenario: string, timeoutMs = 30_000) => { + withScenario(scenario); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + return runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs }); + }; + + it('authentication failures are classified without exposing credentials', async () => { + const result = await run('auth-error'); + expect(result.error?.code).toBe('authentication_required'); + expect(result.error?.retryable).toBe(false); + }); + + it('permission denials are classified', async () => { + const result = await run('permission-denied'); + expect(result.outcome).toBe('permission-denied'); + expect(result.error?.code).toBe('permission_denied'); + }); + + it('quota exhaustion is classified', async () => { + const result = await run('quota-exceeded'); + expect(result.error?.code).toBe('quota_exceeded'); + }); + + it('rate limits are classified', async () => { + const result = await run('rate-limit'); + expect(result.error?.code).toBe('rate_limited'); + }); + + it('a timeout kills the Gemini process deterministically', async () => { + const started = Date.now(); + const result = await run('exec-timeout', 2_000); + expect(result.outcome).toBe('timed-out'); + expect(result.error?.code).toBe('timed_out'); + expect(Date.now() - started).toBeLessThan(15_000); + expect(result.process?.timedOut).toBe(true); + }); + + it('cancellation kills the Gemini process deterministically', async () => { + withScenario('exec-timeout'); + const runner = new GeminiCliRunner(fakeGeminiConfig()); + const { workspaceRoot, runDir } = scratchDirs(); + const controller = new AbortController(); + const pending = runner.generateStage(generationInput, { + workspaceRoot, + runDir, + timeoutMs: 60_000, + signal: controller.signal, + }); + setTimeout(() => controller.abort(), 300); + const result = await pending; + expect(result.outcome).toBe('cancelled'); + expect(result.error?.code).toBe('cancelled'); + expect(result.process?.cancelled).toBe(true); + }); + + it('the stdout limit terminates and never parses truncated output', async () => { + withScenario('huge-stdout'); + const runner = new GeminiCliRunner(fakeGeminiConfig({ maxStdoutBytes: 128 * 1024 })); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('failed'); + expect(result.error?.code).toBe('output_limit_exceeded'); + expect(result.report).toBeUndefined(); + }); + + it('the stderr limit terminates safely too', async () => { + withScenario('huge-stderr'); + const runner = new GeminiCliRunner(fakeGeminiConfig({ maxStderrBytes: 64 * 1024 })); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('failed'); + expect(result.error?.code).toBe('output_limit_exceeded'); + }); +}); + +describe('gemini event stream parsing', () => { + it('parses and normalizes the documented JSONL shapes', () => { + const stdout = [ + JSON.stringify({ type: 'session.started', session_id: SESSION_UUID }), + JSON.stringify({ type: 'thought', text: 'secret reasoning' }), + JSON.stringify({ type: 'tool.started', name: 'replace', path: 'src/a.ts' }), + JSON.stringify({ type: 'tool.completed', name: 'replace', status: 'success' }), + JSON.stringify({ type: 'file.edited', path: 'src/a.ts', kind: 'update' }), + JSON.stringify({ type: 'usage', input_tokens: 10, output_tokens: 5 }), + JSON.stringify({ type: 'result', response: '{"ok":true}' }), + 'not json at all', + ].join('\n'); + const stream = parseGeminiEventStream(stdout); + expect(stream.sessionId).toBe(SESSION_UUID); + expect(stream.finalResponse).toBe('{"ok":true}'); + expect(stream.usage?.inputTokens).toBe(10); + expect(stream.unparseableLines).toBe(0); // plain prose lines are ignored, not counted + const normalized = normalizeGeminiEvents( + stream, + { runner: 'gemini-cli', profile: 'gemini-default', runId: 'r', attemptId: 'a' }, + () => '2026-01-01T00:00:00.000Z', + ); + expect(normalized.map((event) => event.type)).toEqual([ + 'session.started', + 'message.completed', + 'tool.started', + 'tool.completed', + 'file.changed', + 'usage.updated', + 'message.completed', + ]); + expect(JSON.stringify(normalized)).not.toContain('secret reasoning'); + expect(normalized[0]?.providerSessionId).toBe(SESSION_UUID); + }); +}); + +describe('gemini conformance (fake provider, full invocations)', () => { + function registeredProfile(config: GeminiProfileConfig): RegisteredRunnerProfile { + return { name: 'gemini-default', config, runner: new GeminiCliRunner(config) }; + } + + it('passes detection, structured-output, authoring, task-execution, and resume groups', async () => { + withScenario('success'); + const scratch = mkdtempSync(path.join(os.tmpdir(), 'specbridge-gemini-conf-')); + mkdirSync(path.join(scratch, 'runs'), { recursive: true }); + const result = await runRunnerConformance( + { + profile: registeredProfile(fakeGeminiConfig()), + workspaceRoot: scratch, + runDir: path.join(scratch, '.specbridge-conformance-runs'), + invocationsAllowed: true, + timeoutMs: 60_000, + }, + EXECUTION_CONFORMANCE_GROUPS, + ); + const failed = result.groups.flatMap((group) => group.checks.filter((check) => check.status === 'failed')); + expect(failed.map((check) => `${check.id}: ${check.detail ?? ''}`)).toEqual([]); + expect(result.passed).toBe(true); + expect(result.groups.find((group) => group.group === 'task-execution')?.applicable).toBe(true); + expect(result.groups.find((group) => group.group === 'resume')?.applicable).toBe(true); + }, 120_000); + + it('a rogue authoring write fails the no-writes conformance check', async () => { + withScenario('authoring-rogue-write'); + const scratch = mkdtempSync(path.join(os.tmpdir(), 'specbridge-gemini-conf-rogue-')); + const result = await runRunnerConformance({ + profile: registeredProfile(fakeGeminiConfig()), + workspaceRoot: scratch, + runDir: path.join(scratch, '.specbridge-conformance-runs'), + invocationsAllowed: true, + timeoutMs: 60_000, + }); + const noWrites = result.groups + .flatMap((group) => group.checks) + .find((check) => check.id === 'stage-generation.no-writes'); + expect(noWrites?.status).toBe('failed'); + expect(result.passed).toBe(false); + }, 120_000); +}); diff --git a/tests/runners/openai-compatible.test.ts b/tests/runners/openai-compatible.test.ts new file mode 100644 index 0000000..fc719d4 --- /dev/null +++ b/tests/runners/openai-compatible.test.ts @@ -0,0 +1,633 @@ +import { mkdtempSync, readdirSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { AgentRunner } from '@specbridge/runners'; +import { + OpenAiCompatibleRunner, + checkOperationSupport, + checkRedirectTarget, + runRunnerConformance, +} from '@specbridge/runners'; +import type { RegisteredRunnerProfile } from '@specbridge/runners'; +import type { OpenAiCompatibleProfileConfig } from '@specbridge/core'; +import { agentConfigV2Schema, openAiCompatibleProfileSchema } from '@specbridge/core'; +import type { FakeOpenAiServer } from '../helpers-fake-openai.js'; +import { startFakeOpenAi } from '../helpers-fake-openai.js'; + +/** + * OpenAI-compatible authoring adapter tests: every scenario talks to a REAL + * loopback HTTP server (never a mocked adapter method). Fully offline: no + * network beyond 127.0.0.1, no model, no real credentials. + */ + +const FAKE_KEY = 'sk-FAKE-OPENAI-KEY-1234567890'; +const KEY_VARIABLE = 'SPECBRIDGE_TEST_OPENAI_KEY'; + +const servers: FakeOpenAiServer[] = []; + +async function fakeServer(options: Parameters[0] = {}): Promise { + const server = await startFakeOpenAi(options); + servers.push(server); + return server; +} + +afterEach(async () => { + delete process.env[KEY_VARIABLE]; + while (servers.length > 0) await servers.pop()?.close(); +}); + +function profileFor( + server: FakeOpenAiServer, + overrides: Partial = {}, +): OpenAiCompatibleProfileConfig { + return openAiCompatibleProfileSchema.parse({ + runner: 'openai-compatible', + enabled: true, + baseUrl: `${server.baseUrl}/v1`, + model: 'fake-oai-model', + timeoutMs: 30_000, + ...overrides, + }); +} + +function scratchDirs(): { workspaceRoot: string; runDir: string } { + const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), 'specbridge-oai-test-')); + return { workspaceRoot, runDir: path.join(workspaceRoot, '.specbridge', 'runs', 'run-1') }; +} + +const generationInput = { + specName: 'settings-persistence', + stage: 'requirements' as const, + intent: 'generate' as const, + prompt: '# prompt\n\nStage to produce: requirements\n', + promptVersion: '1.1.0', + toolPolicy: 'read-only' as const, +}; + +const V2_BASE = { schemaVersion: '2.0.0' }; + +describe('openai-compatible profile validation', () => { + it('accepts the loopback default and applies safe defaults', () => { + const profile = openAiCompatibleProfileSchema.parse({ runner: 'openai-compatible' }); + expect(profile.enabled).toBe(false); + expect(profile.baseUrl).toBe('http://127.0.0.1:8000/v1'); + expect(profile.apiStyle).toBe('chat-completions'); + expect(profile.structuredOutput).toBe('json-schema'); + expect(profile.allowStructuredOutputFallback).toBe(false); + expect(profile.apiKeyEnvironmentVariable).toBeNull(); + expect(profile.allowInsecureHttp).toBe(false); + }); + + it('rejects unknown API styles and structured-output modes', () => { + expect( + openAiCompatibleProfileSchema.safeParse({ runner: 'openai-compatible', apiStyle: 'grpc' }).success, + ).toBe(false); + expect( + openAiCompatibleProfileSchema.safeParse({ runner: 'openai-compatible', structuredOutput: 'yaml' }) + .success, + ).toBe(false); + }); + + it('accepts remote HTTPS endpoints; rejects remote plain HTTP by default', () => { + const https = agentConfigV2Schema.safeParse({ + ...V2_BASE, + runnerProfiles: { + remote: { runner: 'openai-compatible', baseUrl: 'https://models.example.com/v1' }, + }, + }); + expect(https.success).toBe(true); + const http = agentConfigV2Schema.safeParse({ + ...V2_BASE, + runnerProfiles: { + remote: { runner: 'openai-compatible', baseUrl: 'http://models.example.com/v1' }, + }, + }); + expect(http.success).toBe(false); + }); + + it('the explicit insecure-development override permits remote HTTP and is clearly labeled', () => { + const result = agentConfigV2Schema.safeParse({ + ...V2_BASE, + runnerProfiles: { + dev: { + runner: 'openai-compatible', + baseUrl: 'http://10.0.0.5:8000/v1', + allowInsecureHttp: true, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects embedded credentials, file URLs, and unsupported schemes', () => { + for (const baseUrl of [ + 'https://user:pass@models.example.com/v1', + 'file:///etc/models', + 'ftp://models.example.com/v1', + 'unix:///var/run/model.sock', + ]) { + const result = agentConfigV2Schema.safeParse({ + ...V2_BASE, + runnerProfiles: { bad: { runner: 'openai-compatible', baseUrl } }, + }); + expect(result.success, baseUrl).toBe(false); + } + }); + + it('accepts an environment-variable NAME and rejects anything value-shaped', () => { + expect( + openAiCompatibleProfileSchema.safeParse({ + runner: 'openai-compatible', + apiKeyEnvironmentVariable: 'MY_PROVIDER_KEY', + }).success, + ).toBe(true); + expect( + openAiCompatibleProfileSchema.safeParse({ + runner: 'openai-compatible', + apiKeyEnvironmentVariable: 'sk-abc123 secret-value', + }).success, + ).toBe(false); + }); + + it('rejects credential-bearing header names (no key value can enter the config)', () => { + for (const header of ['Authorization', 'x-api-key', 'Cookie']) { + expect( + openAiCompatibleProfileSchema.safeParse({ + runner: 'openai-compatible', + headers: { [header]: 'value' }, + }).success, + header, + ).toBe(false); + } + expect( + openAiCompatibleProfileSchema.safeParse({ + runner: 'openai-compatible', + headers: { 'x-request-tag': 'specbridge' }, + }).success, + ).toBe(true); + }); + + it('credential-looking keys anywhere in the profile are rejected', () => { + const result = agentConfigV2Schema.safeParse({ + ...V2_BASE, + runnerProfiles: { + bad: { runner: 'openai-compatible', apiKey: 'sk-value' }, + }, + }); + expect(result.success).toBe(false); + }); +}); + +describe('openai-compatible authoring (chat-completions)', () => { + it('generates a validated stage report with usage from a no-auth endpoint', async () => { + const server = await fakeServer(); + const runner = new OpenAiCompatibleRunner(profileFor(server)); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('completed'); + expect(result.report?.stage).toBe('requirements'); + expect(result.report?.markdown).toContain('# Requirements Document'); + expect(result.usage?.inputTokens).toBe(222); + expect(result.usage?.outputTokens).toBe(111); + expect(result.usage?.cachedInputTokens).toBe(10); + // Native JSON Schema was requested (the default mode). + const [request] = server.inferenceCalls(); + expect(request?.url).toBe('/v1/chat/completions'); + const body = request?.body as { response_format?: { type?: string; json_schema?: { strict?: boolean } } }; + expect(body.response_format?.type).toBe('json_schema'); + expect(body.response_format?.json_schema?.strict).toBe(true); + // The adapter created no repository files. + expect(readdirSync(workspaceRoot)).toEqual([]); + }); + + it('stage refinement works through the same request path', async () => { + const server = await fakeServer({ behaviors: ['valid-design'] }); + const runner = new OpenAiCompatibleRunner(profileFor(server)); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage( + { + ...generationInput, + stage: 'design', + intent: 'refine', + prompt: 'Refine the design.\n\nStage to produce: design\n', + }, + { workspaceRoot, runDir, timeoutMs: 30_000 }, + ); + expect(result.outcome).toBe('completed'); + expect(result.report?.stage).toBe('design'); + }); + + it('json-object mode sends response_format json_object and validates locally', async () => { + const server = await fakeServer(); + const runner = new OpenAiCompatibleRunner(profileFor(server, { structuredOutput: 'json-object' })); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('completed'); + const body = server.inferenceCalls()[0]?.body as { response_format?: { type?: string } }; + expect(body.response_format?.type).toBe('json_object'); + }); + + it('strict-json-prompt mode sends no response_format and still validates the complete text', async () => { + const server = await fakeServer(); + const runner = new OpenAiCompatibleRunner(profileFor(server, { structuredOutput: 'strict-json-prompt' })); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('completed'); + const body = server.inferenceCalls()[0]?.body as Record; + expect(body['response_format']).toBeUndefined(); + }); +}); + +describe('openai-compatible authoring (responses API style)', () => { + it('generates a validated stage report through POST /responses', async () => { + const server = await fakeServer(); + const runner = new OpenAiCompatibleRunner(profileFor(server, { apiStyle: 'responses' })); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('completed'); + expect(result.report?.stage).toBe('requirements'); + expect(result.usage?.inputTokens).toBe(222); + const [request] = server.inferenceCalls(); + expect(request?.url).toBe('/v1/responses'); + const body = request?.body as { text?: { format?: { type?: string } } }; + expect(body.text?.format?.type).toBe('json_schema'); + }); +}); + +describe('openai-compatible structured-output strictness', () => { + const run = async (behavior: string, overrides: Partial = {}) => { + const server = await fakeServer({ behaviors: [behavior as never] }); + const runner = new OpenAiCompatibleRunner(profileFor(server, overrides)); + const { workspaceRoot, runDir } = scratchDirs(); + return { + server, + result: await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }), + }; + }; + + it('invalid JSON is rejected', async () => { + const { result } = await run('invalid-json'); + expect(result.outcome).toBe('malformed-output'); + expect(result.error?.code).toBe('structured_output_invalid'); + expect(result.invalidStructuredOutput).toContain('not json'); + }); + + it('schema-invalid JSON is rejected with the exact problems', async () => { + const { result } = await run('schema-invalid'); + expect(result.outcome).toBe('malformed-output'); + expect(result.failureReason).toContain('markdown'); + }); + + it('extra prose around JSON is rejected (no substring extraction)', async () => { + const { result } = await run('extra-prose'); + expect(result.outcome).toBe('malformed-output'); + expect(result.report).toBeUndefined(); + }); + + it('Markdown-fenced JSON is rejected (fences are not parsed)', async () => { + const { result } = await run('fenced-json'); + expect(result.outcome).toBe('malformed-output'); + expect(result.report).toBeUndefined(); + }); + + it('an unsupported native mode fails hard when no fallback is configured — no silent downgrade', async () => { + const { server, result } = await run('http-400-schema-unsupported'); + expect(result.outcome).toBe('failed'); + expect(result.error?.code).toBe('structured_output_unsupported'); + // Exactly ONE request: no hidden second attempt with a weaker mode. + expect(server.inferenceCalls()).toHaveLength(1); + }); + + it('the explicit fallback option downgrades once, with a warning', async () => { + const server = await fakeServer({ behaviors: ['http-400-schema-unsupported', 'valid'] }); + const runner = new OpenAiCompatibleRunner( + profileFor(server, { allowStructuredOutputFallback: true }), + ); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('completed'); + expect(result.warnings.join(' ')).toContain('json-object'); + const calls = server.inferenceCalls(); + expect(calls).toHaveLength(2); + const second = calls[1]?.body as { response_format?: { type?: string } }; + expect(second.response_format?.type).toBe('json_object'); + }); + + it('the correction retry context reaches the endpoint as extra messages', async () => { + const server = await fakeServer({ behaviors: ['invalid-json', 'valid'] }); + const runner = new OpenAiCompatibleRunner(profileFor(server)); + const { workspaceRoot, runDir } = scratchDirs(); + const first = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(first.outcome).toBe('malformed-output'); + const corrected = await runner.generateStage( + { + ...generationInput, + correction: { + previousOutput: first.invalidStructuredOutput ?? '', + problems: 'not a JSON document', + }, + }, + { workspaceRoot, runDir, timeoutMs: 30_000 }, + ); + expect(corrected.outcome).toBe('completed'); + const second = server.inferenceCalls()[1]?.body as { messages?: unknown[] }; + expect(second.messages).toHaveLength(3); + }); + + it('a failed correction retry stops with the malformed result (no loop)', async () => { + const server = await fakeServer({ behaviors: ['invalid-json'] }); + const runner = new OpenAiCompatibleRunner(profileFor(server)); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage( + { ...generationInput, correction: { previousOutput: 'x', problems: 'y' } }, + { workspaceRoot, runDir, timeoutMs: 30_000 }, + ); + expect(result.outcome).toBe('malformed-output'); + }); +}); + +describe('openai-compatible authentication and secret redaction', () => { + it('sends the key from the configured environment variable and never retains its value', async () => { + process.env[KEY_VARIABLE] = FAKE_KEY; + const server = await fakeServer({ requireBearer: FAKE_KEY }); + const config = profileFor(server, { apiKeyEnvironmentVariable: KEY_VARIABLE }); + // The profile stores the NAME only. + expect(JSON.stringify(config)).not.toContain(FAKE_KEY); + const runner = new OpenAiCompatibleRunner(config); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('completed'); + // The server received it; nothing retained contains it. + expect(server.inferenceCalls()[0]?.headers['authorization']).toBe(`Bearer ${FAKE_KEY}`); + expect(JSON.stringify(result)).not.toContain(FAKE_KEY); + }); + + it('an endpoint without authentication works with no key configured', async () => { + const server = await fakeServer(); + const runner = new OpenAiCompatibleRunner(profileFor(server)); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('completed'); + expect(server.inferenceCalls()[0]?.headers['authorization']).toBeUndefined(); + }); + + it('authentication errors are normalized and the provider message is not echoed as-is', async () => { + process.env[KEY_VARIABLE] = FAKE_KEY; + const server = await fakeServer({ behaviors: ['http-401'] }); + const runner = new OpenAiCompatibleRunner(profileFor(server, { apiKeyEnvironmentVariable: KEY_VARIABLE })); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.error?.code).toBe('authentication_required'); + expect(result.error?.retryable).toBe(false); + expect(JSON.stringify(result)).not.toContain(FAKE_KEY); + }); + + it('detection reports an unset key variable without reading anything else', async () => { + const server = await fakeServer(); + const runner = new OpenAiCompatibleRunner( + profileFor(server, { apiKeyEnvironmentVariable: 'SPECBRIDGE_TEST_UNSET_VARIABLE_XYZ' }), + ); + const detection = await runner.detect({ workspaceRoot: process.cwd() }); + expect(detection.authentication).toBe('unauthenticated'); + expect(detection.status).toBe('misconfigured'); + expect(detection.diagnostics.some((d) => d.code === 'RUNNER_API_KEY_VARIABLE_UNSET')).toBe(true); + }); +}); + +describe('openai-compatible failure classification and limits', () => { + it('rate limits are normalized', async () => { + const server = await fakeServer({ behaviors: ['http-429-rate'] }); + const runner = new OpenAiCompatibleRunner(profileFor(server)); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.error?.code).toBe('rate_limited'); + }); + + it('quota exhaustion is normalized as non-retryable', async () => { + const server = await fakeServer({ behaviors: ['http-429-quota'] }); + const runner = new OpenAiCompatibleRunner(profileFor(server)); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.error?.code).toBe('quota_exceeded'); + expect(result.error?.retryable).toBe(false); + }); + + it('a timeout aborts the request deterministically', async () => { + const server = await fakeServer({ behaviors: ['timeout'] }); + const runner = new OpenAiCompatibleRunner(profileFor(server)); + const { workspaceRoot, runDir } = scratchDirs(); + const started = Date.now(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 1_500 }); + expect(result.outcome).toBe('timed-out'); + expect(Date.now() - started).toBeLessThan(15_000); + }); + + it('cancellation aborts the request deterministically', async () => { + const server = await fakeServer({ behaviors: ['timeout'] }); + const runner = new OpenAiCompatibleRunner(profileFor(server)); + const { workspaceRoot, runDir } = scratchDirs(); + const controller = new AbortController(); + const pending = runner.generateStage(generationInput, { + workspaceRoot, + runDir, + timeoutMs: 60_000, + signal: controller.signal, + }); + setTimeout(() => controller.abort(), 200); + const result = await pending; + expect(result.outcome).toBe('cancelled'); + expect(result.error?.code).toBe('cancelled'); + }); + + it('the response-size limit aborts oversized bodies without parsing them', async () => { + const server = await fakeServer({ behaviors: ['huge'] }); + const runner = new OpenAiCompatibleRunner(profileFor(server, { maximumOutputBytes: 64 * 1024 })); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('failed'); + expect(result.error?.code).toBe('output_limit_exceeded'); + expect(result.report).toBeUndefined(); + }); + + it('the input-size limit refuses BEFORE any request is sent', async () => { + const server = await fakeServer(); + const runner = new OpenAiCompatibleRunner(profileFor(server, { maximumInputCharacters: 1000 })); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage( + { ...generationInput, prompt: 'x'.repeat(2000) }, + { workspaceRoot, runDir, timeoutMs: 30_000 }, + ); + expect(result.outcome).toBe('failed'); + expect(result.error?.code).toBe('invalid_configuration'); + expect(server.requests).toHaveLength(0); + }); +}); + +describe('openai-compatible redirect policy', () => { + it('a same-origin redirect is followed within the bound', async () => { + const server = await fakeServer({ behaviors: ['redirect-same-origin'] }); + const runner = new OpenAiCompatibleRunner(profileFor(server)); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('completed'); + // Two requests on the same server: original + redirected hop. + expect(server.inferenceCalls().map((call) => call.url)).toEqual([ + '/v1/chat/completions', + '/redirected/v1/chat/completions', + ]); + }); + + it('a redirect loop is stopped at the bounded limit', async () => { + const server = await fakeServer({ behaviors: ['redirect-loop'] }); + const runner = new OpenAiCompatibleRunner(profileFor(server)); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('failed'); + expect(result.error?.code).toBe('endpoint_unreachable'); + expect(result.failureReason).toContain('redirect'); + // 1 original + 3 followed hops, then refusal. + expect(server.inferenceCalls().length).toBeLessThanOrEqual(4); + }); + + it('a cross-origin redirect never forwards the Authorization header', async () => { + process.env[KEY_VARIABLE] = FAKE_KEY; + const target = await fakeServer(); + const origin = await fakeServer({ + behaviors: ['redirect-cross-origin'], + redirectTargetUrl: target.baseUrl, + }); + const runner = new OpenAiCompatibleRunner( + profileFor(origin, { apiKeyEnvironmentVariable: KEY_VARIABLE }), + ); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('completed'); + // The origin got the key; the cross-origin hop did NOT. + expect(origin.inferenceCalls()[0]?.headers['authorization']).toBe(`Bearer ${FAKE_KEY}`); + const hop = target.requests.find((entry) => entry.url.startsWith('/redirected')); + expect(hop).toBeDefined(); + expect(hop?.headers['authorization']).toBeUndefined(); + }); + + it('an HTTPS-to-HTTP downgrade is rejected by the redirect policy', () => { + const decision = checkRedirectTarget( + new URL('https://models.example.com/v1/chat/completions'), + 'http://models.example.com/v1/chat/completions', + ); + expect(decision.ok).toBe(false); + expect(decision.detail).toContain('downgrade'); + }); + + it('redirects to unsupported schemes and credential-bearing targets are rejected', async () => { + const server = await fakeServer({ behaviors: ['redirect-ftp'] }); + const runner = new OpenAiCompatibleRunner(profileFor(server)); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.generateStage(generationInput, { workspaceRoot, runDir, timeoutMs: 30_000 }); + expect(result.outcome).toBe('failed'); + expect(result.failureReason).toContain('unsupported scheme'); + const credential = checkRedirectTarget( + new URL('https://models.example.com/v1'), + 'https://user:pass@other.example.com/v1', + ); + expect(credential.ok).toBe(false); + }); +}); + +describe('openai-compatible capability boundaries', () => { + it('task execution is rejected by declared capabilities and defensively by the adapter', async () => { + const server = await fakeServer(); + const runner = new OpenAiCompatibleRunner(profileFor(server)); + expect(checkOperationSupport('task-execution', runner.declaredCapabilities).supported).toBe(false); + expect(checkOperationSupport('task-resume', runner.declaredCapabilities).supported).toBe(false); + const { workspaceRoot, runDir } = scratchDirs(); + const result = await runner.executeTask( + { + specName: 'settings-persistence', + taskId: '2.4', + prompt: 'implement', + promptVersion: '1.1.0', + toolPolicy: 'implementation', + }, + { workspaceRoot, runDir, timeoutMs: 30_000 }, + ); + expect(result.outcome).toBe('failed'); + expect(result.error?.code).toBe('unsupported_operation'); + // No HTTP request happened. + expect(server.requests).toHaveLength(0); + }); + + it('resume is not implemented at all (capability-rejected)', async () => { + const server = await fakeServer(); + const runner = new OpenAiCompatibleRunner(profileFor(server)); + expect((runner as AgentRunner).resumeTask).toBeUndefined(); + }); +}); + +describe('openai-compatible model listing (no inference)', () => { + it('lists models through GET /models when the profile declares support', async () => { + const server = await fakeServer(); + const runner = new OpenAiCompatibleRunner(profileFor(server, { modelsEndpoint: true })); + const models = await runner.listModels({ workspaceRoot: process.cwd() }); + expect(models.supported).toBe(true); + expect(models.models.map((model) => model.name)).toEqual(['fake-oai-model', 'fake-oai-mini']); + // Safe reported fields only — no invented capabilities. + expect(models.models[0]).not.toHaveProperty('capabilities'); + // GET only; no inference request occurred. + expect(server.requests.every((entry) => entry.method === 'GET')).toBe(true); + }); + + it('is honestly unsupported when the profile does not declare the endpoint', async () => { + const server = await fakeServer(); + const runner = new OpenAiCompatibleRunner(profileFor(server)); + const models = await runner.listModels({ workspaceRoot: process.cwd() }); + expect(models.supported).toBe(false); + expect(models.models).toEqual([]); + expect(server.requests).toHaveLength(0); + }); + + it('doctor probes reachability ONLY when the models endpoint is declared', async () => { + const server = await fakeServer(); + const withProbe = new OpenAiCompatibleRunner(profileFor(server, { modelsEndpoint: true })); + const detection = await withProbe.detect({ workspaceRoot: process.cwd() }); + expect(detection.status).toBe('available'); + expect(server.requests.every((entry) => entry.method === 'GET')).toBe(true); + + const before = server.requests.length; + const withoutProbe = new OpenAiCompatibleRunner(profileFor(server)); + const staticDetection = await withoutProbe.detect({ workspaceRoot: process.cwd() }); + expect(staticDetection.status).toBe('available'); + expect(staticDetection.diagnostics.some((d) => d.code === 'RUNNER_REACHABILITY_NOT_PROBED')).toBe(true); + // No request of any kind happened for the static profile. + expect(server.requests.length).toBe(before); + }); +}); + +describe('openai-compatible conformance (fake endpoint, full invocations)', () => { + it('passes the applicable authoring groups; task-execution conformance is not applicable', async () => { + const server = await fakeServer(); + const config = profileFor(server); + const profile: RegisteredRunnerProfile = { + name: 'openai-compatible-local', + config, + runner: new OpenAiCompatibleRunner(config), + }; + const scratch = mkdtempSync(path.join(os.tmpdir(), 'specbridge-oai-conf-')); + const { EXECUTION_CONFORMANCE_GROUPS } = await import('@specbridge/execution'); + const result = await runRunnerConformance( + { + profile, + workspaceRoot: scratch, + runDir: path.join(scratch, '.specbridge-conformance-runs'), + invocationsAllowed: true, + timeoutMs: 60_000, + }, + EXECUTION_CONFORMANCE_GROUPS, + ); + const failed = result.groups.flatMap((group) => group.checks.filter((check) => check.status === 'failed')); + expect(failed.map((check) => `${check.id}: ${check.detail ?? ''}`)).toEqual([]); + expect(result.passed).toBe(true); + expect(result.groups.find((group) => group.group === 'task-execution')?.applicable).toBe(false); + expect(result.groups.find((group) => group.group === 'resume')?.applicable).toBe(false); + }, 120_000); +}); diff --git a/tests/runners/runner-config.test.ts b/tests/runners/runner-config.test.ts index 3b11344..f16a184 100644 --- a/tests/runners/runner-config.test.ts +++ b/tests/runners/runner-config.test.ts @@ -54,7 +54,7 @@ describe('v2 configuration schema safety', () => { it('rejects unknown runner implementations', () => { const result = agentConfigV2Schema.safeParse({ ...V2_BASE, - runnerProfiles: { magic: { runner: 'gemini-cli', enabled: true } }, + runnerProfiles: { magic: { runner: 'not-a-registered-runner', enabled: true } }, }); expect(result.success).toBe(false); }); diff --git a/tests/runners/runners.test.ts b/tests/runners/runners.test.ts index b1dab9f..0be0c6f 100644 --- a/tests/runners/runners.test.ts +++ b/tests/runners/runners.test.ts @@ -29,7 +29,10 @@ describe('runner registry', () => { expect(registry.listProfiles().map((profile) => profile.name)).toEqual([ 'claude-code', 'codex-default', + 'gemini-default', 'ollama-local', + 'openai-compatible-local', + 'antigravity', 'mock', ]); }); diff --git a/tests/runners/security-v061.test.ts b/tests/runners/security-v061.test.ts new file mode 100644 index 0000000..61a8436 --- /dev/null +++ b/tests/runners/security-v061.test.ts @@ -0,0 +1,117 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * v0.6.1 security source scans. Production implementation and plugin files + * must never ENABLE forbidden behavior; the tokens may legitimately appear + * in prohibition lists, assertions, and negative-test fixtures. + */ + +const repoRoot = path.resolve(__dirname, '..', '..'); +const runnersSrc = path.join(repoRoot, 'packages', 'runners', 'src'); + +function read(relative: string): string { + return readFileSync(path.join(repoRoot, relative), 'utf8'); +} + +function sourceFiles(dir: string): string[] { + const results: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) results.push(...sourceFiles(full)); + else if (entry.name.endsWith('.ts')) results.push(full); + } + return results; +} + +describe('v0.6.1 security source scans', () => { + it('no adapter ever pushes a YOLO flag or yolo approval mode into an argv', () => { + for (const file of sourceFiles(runnersSrc)) { + const source = readFileSync(file, 'utf8'); + // The literal may appear only in forbidden lists / assertions; it must + // never be an argv push. + expect(source, file).not.toMatch(/push\(\s*['"]--yolo['"]/); + expect(source, file).not.toMatch(/['"]--approval-mode['"]\s*,\s*['"]yolo['"]/); + } + }); + + it('the gemini invocation exposes yolo only inside prohibition lists and assertions', () => { + const invocation = read('packages/runners/src/gemini-cli/invocation.ts'); + expect(invocation).toContain('GEMINI_FORBIDDEN_ARGUMENTS'); + expect(invocation).toContain("'--yolo'"); + // The only approval-mode values that can be assembled come from the + // profile enums (plan / auto_edit / default) — yolo is not one of them. + expect(invocation).toContain("GEMINI_ALLOWED_APPROVAL_MODES = ['plan', 'default', 'auto_edit']"); + }); + + it('no runner source assembles a shell command string from untrusted text', () => { + for (const file of sourceFiles(runnersSrc)) { + const source = readFileSync(file, 'utf8'); + expect(source, file).not.toMatch(/child_process['"]\)/); + expect(source, file).not.toMatch(/\bexecSync\(|\bexec\(\s*`/); + expect(source, file).not.toMatch(/shell:\s*true/); + } + }); + + it('no runner source logs or stores an Authorization value', () => { + for (const file of sourceFiles(runnersSrc)) { + const source = readFileSync(file, 'utf8'); + expect(source, file).not.toMatch(/console\.(log|error|warn)/); + // The bearer header is assembled in exactly one place and never + // serialized into results (behavioral redaction tests cover the rest). + if (file.includes('openai-compatible')) { + expect(source).not.toMatch(/JSON\.stringify\([^)]*headers/i); + } + } + }); + + it('no adapter reads provider credential files or stores', () => { + const forbiddenPaths = [ + '.codex/auth', + 'oauth_creds', + 'application_default_credentials', + '.config/gcloud', + 'credentials.json', + 'auth.json', + ]; + for (const file of sourceFiles(runnersSrc)) { + const source = readFileSync(file, 'utf8'); + for (const fragment of forbiddenPaths) { + expect(source.includes(fragment), `${file}: ${fragment}`).toBe(false); + } + } + }); + + it('the openai-compatible adapter reads ONLY the configured environment variable', () => { + const runner = read('packages/runners/src/openai-compatible/runner.ts'); + // Exactly one process.env access, parameterized by the configured name. + const accesses = runner.match(/process\.env\[/g) ?? []; + expect(accesses).toHaveLength(1); + expect(runner).toContain('process.env[variable]'); + expect(runner).not.toMatch(/Object\.(keys|entries)\(process\.env\)/); + }); + + it('the plugin runners skill never invokes a provider or nested agent', () => { + const skill = read('integrations/claude-code-plugin/specbridge/skills/runners/SKILL.md'); + for (const [index, line] of skill.split('\n').entries()) { + if (/run (a )?provider|`(gemini|codex|agy|ollama) |claude -p/i.test(line)) { + expect(/never|not\b|no\b/i.test(line), `runners skill line ${index + 1}: "${line.trim()}"`).toBe( + true, + ); + } + } + expect(skill).not.toContain('Bash(*'); + }); + + it('MCP runner tools are wired read-only in the catalog and use shared services', () => { + const registry = read('packages/mcp-server/src/tools/registry.ts'); + for (const tool of ['runner_list', 'runner_show', 'runner_doctor', 'runner_matrix']) { + expect(registry).toContain(`{ name: '${tool}', readOnly: true`); + } + const matrixTool = read('packages/mcp-server/src/tools/runner-matrix.ts'); + // The matrix comes from the shared implementation — no second matrix. + expect(matrixTool).toContain('runnerMatrixRows'); + expect(matrixTool).toContain('renderRunnerMatrixMarkdown'); + }); +}); diff --git a/tests/runners/selection.test.ts b/tests/runners/selection.test.ts index 2403fdc..fac146b 100644 --- a/tests/runners/selection.test.ts +++ b/tests/runners/selection.test.ts @@ -237,3 +237,145 @@ describe('fallback policy (bounded, explicit, auditable)', () => { expect(noChain.ok && noChain.plan.fallbackChain).toEqual([]); }); }); + +describe('v0.6.1 provider selection rules', () => { + it('the new built-in profiles default to disabled and are refused at selection', () => { + const resolved = config(); + const registry = createDefaultRunnerRegistry(resolved); + for (const profile of ['gemini-default', 'openai-compatible-local', 'antigravity']) { + const registered = registry.getProfile(profile); + expect(registered.config.enabled, profile).toBe(false); + const selection = selectRunner(registry, resolved, { + operation: 'stage-generation', + explicitProfile: profile, + }); + expect(selection.ok, profile).toBe(false); + if (!selection.ok) expect(selection.failure.error.code).toBe('runner_disabled'); + } + }); + + it('existing operation defaults remain unchanged: no new profile is selected implicitly', () => { + const resolved = config(); + const registry = createDefaultRunnerRegistry(resolved); + for (const operation of ['stage-generation', 'stage-refinement', 'task-execution'] as const) { + const selection = selectRunner(registry, resolved, { operation }); + expect(selection.ok).toBe(true); + if (selection.ok) { + expect(selection.plan.profile).toBe('claude-code'); + } + } + }); + + it('an enabled remote openai-compatible endpoint is never selected implicitly (network rule)', () => { + const resolved = config({ + defaultRunner: 'openai-remote', + runnerProfiles: { + 'claude-code': { runner: 'claude-code', enabled: true }, + 'codex-default': { runner: 'codex-cli', enabled: true }, + 'ollama-local': { runner: 'ollama', enabled: true, model: 'qwen-fake:7b' }, + mock: { runner: 'mock', enabled: true }, + 'openai-remote': { + runner: 'openai-compatible', + enabled: true, + baseUrl: 'https://models.example.com/v1', + model: 'big-model', + }, + }, + }); + const registry = createDefaultRunnerRegistry(resolved); + // Global-default (implicit) selection is refused for a network profile… + const implicit = selectRunner(registry, resolved, { operation: 'stage-generation' }); + expect(implicit.ok).toBe(false); + if (!implicit.ok) { + expect(implicit.failure.error.message).toContain('never selected implicitly'); + } + // …while explicit selection works. + const explicit = selectRunner(registry, resolved, { + operation: 'stage-generation', + explicitProfile: 'openai-remote', + }); + expect(explicit.ok).toBe(true); + if (explicit.ok) expect(explicit.plan.networkBacked).toBe(true); + }); + + it('an enabled loopback openai-compatible profile supports authoring but never task execution', () => { + const resolved = config({ + runnerProfiles: { + 'claude-code': { runner: 'claude-code', enabled: true }, + 'codex-default': { runner: 'codex-cli', enabled: true }, + 'ollama-local': { runner: 'ollama', enabled: true, model: 'qwen-fake:7b' }, + mock: { runner: 'mock', enabled: true }, + 'openai-compatible-local': { + runner: 'openai-compatible', + enabled: true, + model: 'fake-model', + }, + }, + }); + const registry = createDefaultRunnerRegistry(resolved); + const authoring = selectRunner(registry, resolved, { + operation: 'stage-generation', + explicitProfile: 'openai-compatible-local', + }); + expect(authoring.ok).toBe(true); + const execution = selectRunner(registry, resolved, { + operation: 'task-execution', + explicitProfile: 'openai-compatible-local', + }); + expect(execution.ok).toBe(false); + if (!execution.ok) { + expect(execution.failure.missingCapabilities).toContain('taskExecution'); + expect(execution.failure.compatibleProfiles).toContain('claude-code'); + } + }); + + it('an enabled gemini profile is selectable for authoring and execution by declared capabilities', () => { + const resolved = config({ + runnerProfiles: { + 'claude-code': { runner: 'claude-code', enabled: true }, + 'codex-default': { runner: 'codex-cli', enabled: true }, + 'ollama-local': { runner: 'ollama', enabled: true, model: 'qwen-fake:7b' }, + mock: { runner: 'mock', enabled: true }, + 'gemini-default': { runner: 'gemini-cli', enabled: true }, + }, + }); + const registry = createDefaultRunnerRegistry(resolved); + for (const operation of ['stage-generation', 'task-execution'] as const) { + const selection = selectRunner(registry, resolved, { + operation, + explicitProfile: 'gemini-default', + }); + expect(selection.ok, operation).toBe(true); + } + // Local process boundary: the CLI handles its own provider connectivity. + const plan = selectRunner(registry, resolved, { + operation: 'stage-generation', + explicitProfile: 'gemini-default', + }); + expect(plan.ok && plan.plan.networkBacked).toBe(false); + }); + + it('authoring fallback chains may include gemini or openai-compatible only when configured', () => { + const resolved = config({ + operationDefaults: { stageGeneration: 'ollama-local' }, + fallbacks: { stageGeneration: ['gemini-default', 'openai-compatible-local'] }, + runnerProfiles: { + 'claude-code': { runner: 'claude-code', enabled: true }, + 'codex-default': { runner: 'codex-cli', enabled: true }, + 'ollama-local': { runner: 'ollama', enabled: true, model: 'qwen-fake:7b' }, + mock: { runner: 'mock', enabled: true }, + 'gemini-default': { runner: 'gemini-cli', enabled: true }, + 'openai-compatible-local': { runner: 'openai-compatible', enabled: true, model: 'm' }, + }, + }); + const registry = createDefaultRunnerRegistry(resolved); + const withChain = selectRunner(registry, resolved, { operation: 'stage-generation' }); + expect(withChain.ok && withChain.plan.fallbackChain).toEqual([ + 'gemini-default', + 'openai-compatible-local', + ]); + // Task execution NEVER has a fallback chain, whatever is configured. + const task = selectRunner(registry, resolved, { operation: 'task-execution' }); + expect(task.ok && task.plan.fallbackChain).toEqual([]); + }); +});